← Blog
Platform Engineering·18. September 2026·22 min Lesezeit

Ersetzt Power Fx das C#-Plugin? Eine Messung an 1000 Zeilen

Dataverse Functions ersetzen Custom-API-Endpunkte, Automated Low-Code Plug-ins ersetzen Plugins. Beides habe ich gebaut und gegen das C#-Gegenstück gemessen: Kennt eine Regel den alten Feldwert? Was kostet sie? Und was geht nicht? Ein Befund widerspricht der offiziellen Dokumentation, einer beendet das häufigste Custom-API-Szenario.

Es gibt eine Handvoll Aufgaben, für die in jedem Dataverse-Projekt irgendwann C# geschrieben wird. Zwei kommen immer wieder: Ein Feld ändert sich auf einer Zeile, und der Wert soll auf eine andere Zeile wandern — dafür ein Plugin. Oder es soll etwas berechnet und zurückgegeben werden — dafür eine Custom API mit einem Plugin dahinter. Fachlich je zehn Zeilen Logik, drumherum ein Visual-Studio-Projekt, eine signierte Assembly, das Plugin Registration Tool und eine Solution, die das alles transportiert.

Seit einiger Zeit verspricht Microsoft für beides einen kürzeren Weg: Power Fx statt C#. Die Frage, um die es hier geht, ist nicht „geht das?”, sondern: Ist das der bessere Weg — gemessen an Laufzeit, an ALM und daran, wie es sich schreiben lässt?

Ich habe beide Wege gebaut, jeweils daneben das C#-Gegenstück, und gegeneinander gemessen. Ein Befund widerspricht der offiziellen Formelreferenz. Ein zweiter beendet das häufigste Custom-API-Szenario, bevor es anfängt.

Zwei Dinge, die ähnlich heißen

Wer nach dem Thema sucht, findet zwei Features, die leicht verwechselt werden:

löst ausersetzt
Dataverse Functions (früher Instant Low-Code Plugins)manuell, per AufrufCustom-API-Endpunkte
Automated Low-Code Plug-insCreate / Update / Deleteklassische Plugins

Die meisten Blogposts zeigen die Functions — sie sind der hübschere Teil, weil man ihnen beim Ausführen zusehen kann. Für „etwas soll passieren, weil jemand speichert” braucht man aber die anderen.

Dieser Artikel nimmt sich beide vor, erst die Automated Plug-ins, dann die Functions. Wer nur wissen will, welche Aufgaben sich heute damit lösen lassen, springt zur Liste der User Stories.

Beides führt Microsoft als Preview. Das ist keine Formalie, wie sich gleich zeigen wird.

Beide brauchen den Dataverse Accelerator, eine Solution aus dem Katalog. Die Dokumentation sagt, neue Umgebungen bekämen ihn automatisch — in meiner Trial-Umgebung war er nicht da. Die Power-Fx-Laufzeit dagegen lag seit einem halben Jahr in der Umgebung. Es fehlte nur die Oberfläche zum Bauen.

Der Aufbau

Alles, was folgt, stammt aus einer Trial-Umgebung mit deutscher Basissprache — das ist wichtig, weil an einer Stelle die Sprache mitredet. Der Accelerator lag in Version 1.0.5.41 vor.

Bemerkenswert schon hier: Die Installation braucht kein Portal.

POST https://api.powerplatform.com/appmanagement/environments/<env>/applicationPackages/DataverseAccelerator_Anchor/install?api-version=2022-03-01-preview

Nach keinen fünf Minuten steht der Status auf Installed. Für alles Weitere reicht ein Zugangstoken aus der Azure CLI:

az account set --tenant <tenant-id>
az account get-access-token --resource https://<org>.crm4.dynamics.com --query accessToken -o tsv

Teil 1: Der Plugin-Ersatz

Die Kernfrage: Kennt eine Regel den alten Wert?

Ein Plugin, das nicht weiß, wie eine Zeile vorher aussah, kann die wichtigste Frage nicht beantworten: Hat sich das Feld überhaupt geändert? Ohne sie schreibt die Logik bei jedem Speichern los, auch wenn jemand nur eine Telefonnummer korrigiert hat. In C# löst man das mit einem PreImage.

Gibt es dafür ein Gegenstück in Power Fx? Die offizielle Formelreferenz kennt ThisRecord. Einige Blogs aus der Community schreiben von OldRecord und NewRecord. Diese Namen tauchen in der Dokumentation nicht auf.

Der Prüfstand dafür ist unspektakulär: Man legt für jeden Namen eine echte Regel an und sieht zu, ob die Plattform sie annimmt. Wichtig ist, jeden Namen einzeln zu prüfen — steht in einer Formel ein unbekannter Name, verdeckt dessen Fehler alles andere.

Welche Kontextnamen eine Automated Low-Code Plug-in-Regel annimmt: OldRecord und NewRecord kompilieren, ThisRecord und PreImage werden abgelehnt

Es steht also genau andersherum als in der Dokumentation. Die Community hat recht.

Am deutlichsten wird es an einer Fehlermeldung, die die Plattform selbst ausgibt, wenn man Patch falsch benutzt:

Patch function cannot be used with NewRecord or OldRecord as the second argument.
Use Set(NewRecord.<fieldname>, <newValue>) instead.

Ein Compiler, der zwei Namen in einem Hinweis ausspricht, kennt sie. Und er verrät gleich die richtige Schreibweise.

Der Nachweis

Kompilieren heißt noch nicht funktionieren. Also eine Regel an account / Update / Stage 20:

If(OldRecord.name <> NewRecord.name,
   Set(NewRecord.description, "vorher=" & OldRecord.name & " nachher=" & NewRecord.name))

Eine Firma anlegen als „WK Beweis ALT”, dann umbenennen auf „WK Beweis NEU”. Danach steht in der Beschreibung:

vorher=WK Beweis ALT nachher=WK Beweis NEU

Die Gegenprobe ist der eigentliche Beweis: Ein Update, das den Namen nicht anfasst — nur telephone1 — lässt die Beschreibung unberührt. OldRecord trägt also wirklich den Zustand vor der Änderung und nicht eine zweite Sicht auf den neuen.

Damit ist die Voraussetzung erfüllt, ein C#-Plugin überhaupt ersetzen zu können.

Übrigens muss man die benötigten Felder nirgends registrieren. Die Plattform rechnet sich aus der Formel aus, was sie laden muss, und legt es in der Regel ab:

{"RequiredEntityAttributeMap": {"contact": ["jobtitle", "parentcustomerid"],
                                "account": ["description"]}}

Das ist der angenehmste Unterschied zum C#-Plugin: kein vergessenes PreImage-Attribut, das erst in Produktion auffällt.

Regeln anlegen, ohne die App zu öffnen

Der Accelerator bringt eine Canvas-App mit, in der man Regeln klickt. Für ALM ist das die falsche Ebene. Er bringt aber auch Custom-API-Endpunkte mit, und die sind der eigentliche Fund:

EndpunktZweck
GenerateAutomatedPluginRegel und sdkmessageprocessingstep anlegen
UpdateAutomatedPluginbestehende Regel ändern
DeleteAutomatedPluginRegel samt Schritt entfernen
ExecuteFxExpressioneinen Ausdruck freihändig ausführen

Damit lässt sich eine Regel aus einem Skript heraus anlegen:

curl -s -X POST \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "Name": "wk_position_auf_firma",
    "EntityLogicalName": "contact",
    "MessageName": "Update",
    "Stage": 20,
    "Expression": "If(OldRecord.jobtitle <> NewRecord.jobtitle, Patch(account, AsType(OldRecord.Firmenname, account), {description: \"Position: \" & NewRecord.jobtitle}))"
  }' \
  "https://<org>.crm4.dynamics.com/api/data/v9.2/GenerateAutomatedPlugin"

Die Antwort enthält eine FxExpressionId. Dass pluginid dabei null bleibt, sieht nach Fehlschlag aus, ist aber keiner — der Verarbeitungsschritt wird trotzdem angelegt.

Ein Detail, das mich einen halben Abend gekostet hat: Die Regeln liegen in der Tabelle fxexpression, nicht in powerfxrule. In powerfxrule lässt sich zwar schreiben, die Zeile bleibt aber ein totes Stück Text — kein Kompilat, kein Verarbeitungsschritt. Wer dort landet, sucht den Fehler bei der Plattform statt bei der Tabelle.

Umgekehrt räumt das Löschen einer fxexpression-Zeile den Verarbeitungsschritt mit ab.

Was angelegt wurde, sieht man dort auch nach — ohne die App zu öffnen:

Die angelegten Regeln, direkt aus der Tabelle fxexpression gelesen: drei Automated Plug-ins und eine Function

Die vierte Zeile ist die Function aus Teil 2. Sie hat weder Tabelle noch Nachricht, weil sie auf keinen Speichervorgang wartet, sondern aufgerufen wird.

Was aussieht wie eingerichtet und trotzdem nichts tut

Hier kommt die Preview durch. Dieselbe Regel, nur die Verarbeitungsstufe variiert:

Stage 10 und 20 wirken, Stage 40 bleibt wirkungslos — obwohl der Verarbeitungsschritt registriert und aktiv ist

Bei Stage 40 wird die Regel angenommen, der Schritt ist registriert, statecode steht auf aktiv — und beim Update passiert nichts. Kein Fehler, keine Warnung, kein Eintrag im Ablaufprotokoll, obwohl die Umgebung auf vollständige Protokollierung stand.

Auffällig ist die dritte Spalte: Das Kompilat meldet in allen drei Fällen "Stage":20. Der angeforderte Wert landet nur im Verarbeitungsschritt, nicht in der kompilierten Regel.

Für die Praxis heißt das: Prüfen Sie nach dem Anlegen, ob die Regel tatsächlich etwas tut. Der Blick auf „ist angelegt und aktiv” genügt nicht.

Kind auf Eltern: der Weg ist nicht der erwartete

Zurück zum Ausgangsszenario. Auf einem Kontakt ändert sich die Position, der Wert soll in die zugehörige Firma. Der naheliegende Weg scheitert reihenweise:

VersuchErgebnis
NewRecord.accountidisn't recognized
NewRecord.Firma (der Anzeigename dazu)isn't recognized
NewRecord.AccountId (der Schemaname)isn't recognized
NewRecord.Firmenname (= parentcustomerid)erkannt, aber Invalid argument type (Polymorphic)
AsType(OldRecord.Firmenname, account)läuft
IsType(NewRecord.Firmenname, account)Expecting a UntypedObject value

Zwei Dinge stecken darin.

contact.accountid sieht man im Datenmodell, aber nicht in Power Fx. Das Feld ist abgeleitet — Dataverse füllt es aus parentcustomerid, wenn dort eine Firma steht. Die Formelsprache zeigt es nicht an. Wer es in der Tabelle sieht und in der Formel nicht findet, sucht an der falschen Stelle.

Polymorphe Lookups brauchen AsType. parentcustomerid kann auf eine Firma oder auf einen Kontakt zeigen, und Patch weigert sich, damit zu arbeiten. AsType legt den Typ fest. Die übliche Absicherung davor — erst mit IsType prüfen, dann umwandeln — steht nicht zur Verfügung. Man legt den Typ also fest, ohne ihn vorher prüfen zu können.

Die Fassung, die funktioniert:

If(OldRecord.jobtitle <> NewRecord.jobtitle,
   Patch(account, AsType(OldRecord.Firmenname, account),
         {description: "Position: " & NewRecord.jobtitle}))

Der Gegenspieler

Für die Messung braucht es ein C#-Plugin, das dasselbe tut — und zwar wirklich dasselbe, sonst vergleicht man Äpfel mit Birnen. Es hängt an derselben Tabelle, derselben Nachricht, derselben Stufe und bekommt ein PreImage über jobtitle und parentcustomerid: genau die Felder, die die Plattform der Regel von sich aus stellt.

public class PositionAufFirma : IPlugin
{
    public void Execute(IServiceProvider serviceProvider)
    {
        var kontext = (IPluginExecutionContext)serviceProvider.GetService(typeof(IPluginExecutionContext));

        if (!(kontext.InputParameters.Contains("Target") && kontext.InputParameters["Target"] is Entity ziel))
            return;
        if (!kontext.PreEntityImages.Contains("vorher"))
            return;

        var vorher = kontext.PreEntityImages["vorher"];
        if (!ziel.Contains("jobtitle"))
            return;

        var neu = ziel.GetAttributeValue<string>("jobtitle");
        var alt = vorher.GetAttributeValue<string>("jobtitle");
        if (string.Equals(alt, neu, StringComparison.Ordinal))
            return;

        var firma = vorher.GetAttributeValue<EntityReference>("parentcustomerid");
        if (firma == null || firma.LogicalName != "account")
            return;

        var fabrik = (IOrganizationServiceFactory)serviceProvider.GetService(typeof(IOrganizationServiceFactory));
        var dienst = fabrik.CreateOrganizationService(kontext.UserId);

        dienst.Update(new Entity("account", firma.Id) { ["description"] = "Position: " + neu });
    }
}

Nebenbei: Auch das Registrieren braucht kein Werkzeug mit Oberfläche. Assembly, Typ, Verarbeitungsschritt und PreImage lassen sich über die Web-API anlegen — pluginassembly nimmt die signierte DLL als Base64 entgegen, sdkmessageprocessingstepimage trägt das PreImage nach. Wer eine Deployment-Kette ohne manuelle Schritte will, kommt auch hier ohne das Plugin Registration Tool aus.

Nachsehen lässt sich das Ergebnis genauso:

Der registrierte Verarbeitungsschritt mit seinem PreImage, über die Web-API abgefragt statt im Plugin Registration Tool

stage 20 ist PreOperation, mode 0 synchron, imagetype 0 ein PreImage. Das filteringattributes sorgt dafür, dass der Schritt nur läuft, wenn sich jobtitle ändert — das Gegenstück zur If-Bedingung in der Formel.

Vor jeder Messung prüft ein kurzer Testlauf, dass beide Umsetzungen dasselbe in die Beschreibung schreiben. Ohne diesen Schritt misst man vielleicht zwei verschiedene Dinge.

Die Messung

Aufbau: Jeder Kontakt bekommt eine eigene Firma. Schrieben alle Kontakte auf dieselbe Firma, würde man Sperrkonflikte auf einer Zeile messen statt der Logik. Umgeschaltet wird über den statecode des Verarbeitungsschritts, nicht durch Anlegen und Löschen — sonst kompiliert die Regel bei jedem Wechsel neu und verfälscht die ersten Durchläufe. Jeder Lauf enthält seine eigene Nullmessung ohne jede Logik.

Laufzeitvergleich: ohne Logik 228 ms, C#-Plugin 370 ms, Low-Code-Regel 419 ms je Zeile — und die Serverzeit ohne Netzweg mit 124 gegen 187 ms

LaufZeilenohne LogikC#-PluginLow-CodeAufschlag C#Aufschlag Regel
1100223 ms372 ms427 ms+149 ms+204 ms
2100229 ms362 ms400 ms+133 ms+171 ms
31000239 ms363 ms437 ms+124 ms+198 ms
41000228 ms370 ms419 ms+142 ms+191 ms

Die Werte sind Wanduhrzeiten je Zeile, gemessen vom Entwicklungsrechner aus, also samt Netzweg. Aussagekräftig ist der Abstand, nicht der absolute Wert.

Auf 1000 Datensätze gerechnet:

Dauer
ohne Logikrund 4 Minuten
mit C#-Pluginrund 6 Minuten
mit Low-Code-Regelrund 7 Minuten

Der Aufschlag des Plugins ist über alle Läufe stabil, der der Regel liegt durchweg darüber. Der Abstand schwankt zwischen 38 und 74 ms je Zeile — deshalb nenne ich hier keine exakte Zahl, sondern eine Größenordnung: rund 50 ms je Zeile, etwa ein Drittel mehr Aufschlag. Genauer gibt eine Trial-Umgebung ohne Lastgarantie es nicht her.

Ohne Netzweg

Die 220 ms Strecke zum Rechenzentrum überdecken den eigentlichen Unterschied. Der Server verbucht aber je Aufruf eine eigene Dauer im Ablaufprotokoll:

Medianmin
C#-Plugin124 ms93 ms
Low-Code-Regel187 ms156 ms

63 ms Unterschied je Aufruf — das deckt sich mit den 38 bis 74 ms der Wanduhrmessung. Die Regel braucht für dieselbe Arbeit etwa die Hälfte länger.

Zwei Fallen beim Messen

Der erste Lauf war unfair, und zwar zuungunsten der Regel. Die Plattform protokolliert eine Low-Code-Regel von sich aus, ein C#-Plugin nur dann, wenn es selbst etwas schreibt. Die Regel trug also Kosten, die das Plugin nicht hatte. Mit gedrosseltem Protokoll sank ihr Aufschlag von 204 auf 171 ms — das Protokollieren allein kostet rund 17 ms je Aufruf.

Im Betrieb ist das kein reiner Nachteil. Man bekommt die Nachvollziehbarkeit geschenkt, für die ein Plugin eigenen Code braucht. Für die Serverzeitmessung oben lief deshalb eine zweite Plugin-Klasse mit einem Trace-Aufruf, damit beide Seiten dieselben Kosten tragen.

Die zweite Falle: plugintypestatistic, die Tabelle mit Ausführungsstatistiken je Plugin-Typ, bleibt in Online-Umgebungen leer. Der naheliegende Weg zu einer unverfälschten Serverzeit führt ins Leere.

Was mit ALM passiert

Hier war meine Erwartung am deutlichsten falsch. Power Fx spricht in Dataverse Anzeigenamen — in einer deutschen Umgebung heißt account eben Firmen. Das klingt nach einer Falle beim Transport zwischen Umgebungen verschiedener Sprache.

Ist es nicht. Zwei Dinge entschärfen das:

Der logische Name funktioniert immer. Was scheitert, ist der englische Anzeigename in einer deutschen Umgebung — also die aus einem englischsprachigen Blog kopierte Formel, nicht die Sprache an sich.

Beim Anlegen wird normalisiert. Alle drei Schreibweisen

If(OldRecord.Firmenname <> NewRecord.Firmenname, Set(NewRecord.Beschreibung, "x"))
If(OldRecord.name       <> NewRecord.name,       Set(NewRecord.description,  "x"))
If(OldRecord.name       <> NewRecord.Firmenname, Set(NewRecord.description,  "x"))

liegen danach identisch in der Zeile:

If(OldRecord.name <> NewRecord.name, Set(NewRecord.description, "x"))

Gespeichert wird also sprachneutral. Den Transport selbst habe ich nicht geprüft — das bleibt offen.

Drei Punkte aus der Datenstruktur, die man vor einer Entscheidung kennen sollte:

  • OwnershipType: UserOwned — jede Regel hat einen Besitzer. Eine registrierte Assembly hat das nicht. Was passiert, wenn dieser Mensch das Unternehmen verlässt, ist ungeprüft.
  • IsAuditEnabled: False — Änderungen an Regeln werden nicht protokolliert. Wer eine Formel ändert, hinterlässt keine Spur.
  • Die Steuerung über GenerateAutomatedPlugin und die beiden Geschwister macht Regeln skriptbar. Das ist für eine Deployment-Kette mehr wert als die Canvas-App.

Eine kaputte Regel legt die Tabelle still

Das habe ich mir selbst eingebrockt und schreibe es deshalb auf. Eine Testregel ohne Abbruchbedingung:

Patch(account, LookUp(account, true), {description: "x"})

Sie schreibt auf eine Zeile, das löst ein Update aus, das die Regel wieder auslöst. Danach scheiterte jedes Update an account:

Plugin wk_iso_ohnekontext failed with: … This low-code plugin's execution
was cancelled because the plugin logic caused a …

Bis die Regel gelöscht war, ließ sich keine Firma mehr ändern. Der Rekursionsschutz muss von Hand in die Formel — in den Beispielen oben ist es die If-Bedingung, die beim Folge-Update nicht mehr greift.

Ein C#-Plugin hat dasselbe Problem. Der Unterschied ist, wer die Regel schreibt: Eine Assembly baut jemand, der Plugin-Tiefe und Rekursion kennt. Eine Formel tippt womöglich jemand, der beides nie gehört hat.

Teil 2: Der Custom-API-Ersatz

Die andere Hälfte. Eine Function wird nicht durch ein Speichern ausgelöst, sondern aufgerufen — von einer App, einem Flow, einem fremden System. Genau das, wofür man sonst eine Custom API anlegt und ein Plugin dahinterhängt.

Auch hier braucht es die Oberfläche nicht:

EndpunktEingabe
GenerateInstantPluginName, Description, Expression, dazu optional Parameters
UpdateInstantPlugindasselbe, adressiert über FxExpressionUniqueName
DeleteInstantPluginnur FxExpressionUniqueName

Parameters ist ein JSON-String. Die Typschlüssel sind dieselben wie bei Custom-API-Parametern: 2 für Decimal, 7 für Integer, 10 für String.

{"InputParameter":[{"name":"Umsatz","type":2}],
 "OutputParameter":[{"name":"Stufe","type":10}]}

Ein Aufruf erzeugt drei Objekte auf einmal — die Antwort nennt sie alle:

{"msdyn_functionid": "8f6fdb21-…", "FxExpressionId": "4869db1b-…", "CustomApiId": "4a69db1b-…"}

Die entstandene Custom API heißt new_<name>, ist ungebunden und keine OData-Function, sondern eine Action. Sie wird also per POST aufgerufen.

Was beim Anlegen einer Function entsteht — und die Falle bei der Ausgabe: ein einzelner Wert scheitert, ein Record funktioniert

Die Ausgabe muss ein Record sein

Der Stolperstein, der mich am längsten aufgehalten hat. Bei benannten Ausgabeparametern erwartet die Laufzeit einen Record, keinen einzelnen Wert:

Ausdruckbeim Aufruf
If(Umsatz >= 100000, "A", …)Unable to cast object of type 'Microsoft.PowerFx.Types.StringValue' to type 'Microsoft.PowerFx.Types.RecordValue'
{Stufe: If(Umsatz >= 100000, "A", …)}{"Stufe":"A"}

Die Meldung nennt den Typ, aber nicht die Lösung. Und sie erscheint erst beim Aufruf — angelegt wird die Function in beiden Fällen anstandslos. Wer sie anlegt und für fertig hält, merkt es erst, wenn jemand sie benutzt.

Mehrere Ausgabewerte in einem Aufruf sind der eigentliche Gebrauchswert:

{Stufe: If(Umsatz >= 100000, "A", Umsatz >= 25000, "B", "C"),
 Rabatt: If(Umsatz >= 100000, 15, Umsatz >= 25000, 7, 0),
 Begruendung: "Schwelle " & Text(Umsatz)}

Der Aufruf mit Umsatz = 50000:

{"Stufe":"B","Rabatt":7,"Begruendung":"Schwelle 50000"}

Tabellenzugriff geht ebenfalls: {Anzahl: CountRows(Filter(contact, jobtitle = Position))} liefert {"Anzahl":1}.

Was nicht geht: nach draußen telefonieren

Das häufigste Custom-API-Szenario überhaupt — eine fremde Web-API abfragen und das Ergebnis für Anzeige oder Weiterverarbeitung zurückgeben — lässt sich als Function nicht bauen.

Die Grenze der Formelsprache: Plattformdienste ja, freier HTTP-Aufruf nein — und die Laufzeit mit 130 gegen 88 Millisekunden

FunktionErgebnis beim Anlegen
XHttpRequestunbekannt
XSendHttpRequestunbekannt
XSendEmailFromTemplateangenommen
XCreateUrlActionangenommen
XSendAppNotificationerkannt

Die X-Funktionen decken Plattformdienste ab: Mail aus einer Vorlage, Benachrichtigung in der App, Link-Aktion. Für einen freien HTTP-Aufruf gibt es nichts.

Im context-Feld jeder Regel sind ConnectionReferences, TabularConnectionReferences und ActionConnectorConnectionReferences vorgesehen. Der Weg nach draußen führt demnach über Connectors, nicht über die Formelsprache. Das habe ich nicht geprüft — dafür braucht es eine Connection, und die entsteht nicht per API.

Für die Einschätzung heißt das: Eine Function ersetzt die rechnende Custom API, nicht die integrierende. Also konkret:

Was die Custom API tun sollFunction?
Rabattstufe, Provision, Prüfziffer, Frist ausrechnenja
Adresse formatieren, Text zusammensetzenja
Einen Bestand aus Dataverse zurückgebenja
Mehrere Werte gebündelt liefern, auch als JSONja
Wechselkurs, Bonitätsauskunft, Adressvalidierung von außen holennein
Ein Dokument bei einem Fremdsystem erzeugen lassennein

Wer anreichert, bleibt bei C# oder nimmt einen Flow. Wer rechnet, kann wechseln.

Laufzeit gegen eine Custom API mit Plugin

Dieselbe Berechnung, beide ungebunden, beide mit drei Ausgabewerten, beide per POST aufgerufen. Vorab geprüft: Die Antworten sind zeichengenau gleich. 50 Aufrufe je Weg nach einem Warmlauf:

Medianminmax
Function (Power Fx)130 ms119 ms273 ms
Custom API (C#)88 ms77 ms281 ms

42 ms Unterschied je Aufruf — dieselbe Größenordnung wie im ersten Teil. Hier wiegt es sogar schwerer: Es wird keine Zeile geschrieben, die Netzlatenz ist der einzige weitere Posten, und trotzdem braucht die Formel die Hälfte länger.

Die Verbotsliste stimmt nur zur Hälfte

Beim Prüfen fiel nebenbei auf: Zwei Seiten der Dokumentation widersprechen sich. Die eine führt eine Liste nicht unterstützter Funktionen, die andere listet dieselben Funktionen als verfügbar.

Läuft, obwohl als nicht unterstützt geführt: JSON, AddColumns, DropColumns, ShowColumns, RenameColumns, SortByColumns, Search, Weekday, IsEmpty, PlainText

Tatsächlich gesperrt: GroupBy, Concurrent, Notify, UpdateIf, RemoveIf, ClearCollect — jeweils mit recognized but not supported. UTCNow ist schlicht unknown.

Die Plattform unterscheidet sauber zwischen „kenne ich nicht” und „kenne ich, unterstütze ich aber nicht”. Die Dokumentation wirft beides zusammen, und zwar mit Funktionen, die längst laufen. Im Zweifel also nicht die Liste lesen, sondern den Ausdruck ausprobieren.

Ein Wort zur Vorsicht dabei: ExecuteFxExpression, der freihändige Testweg, ist dafür nicht zuverlässig. Er meldet ThisRecord als unbekannt, obwohl es Regeln gibt, die damit laufen, und XSendAppNotification ebenfalls — obwohl eine mitgelieferte Regel derselben Umgebung genau diese Funktion benutzt. Was zählt, ist die Anlage einer echten Regel oder Function.

Welche User Story geht heute?

Die eigentliche Frage vor einer Entscheidung ist nicht, was die Technik im Prinzip kann, sondern welche Anforderung sich morgen damit umsetzen lässt. Deshalb habe ich einen Stapel typischer Anforderungen genommen und jede einzeln gegen die Plattform geschickt. Was hier steht, wurde angelegt — nicht nachgelesen.

Welche User Stories sich heute umsetzen lassen: acht beim Speichern, fünf beim Aufruf, drei Dinge gehen nicht

Reagieren, wenn jemand speichert

AnforderungAuslöserFormel
Beim Anlegen einen Zeitstempel setzencontact / Create / 20Set(NewRecord.description, "Angelegt am " & Text(Now()))
Wert auf die Elternzeile übertragencontact / Update / 20If(OldRecord.jobtitle <> NewRecord.jobtitle, Patch(account, AsType(OldRecord.Firmenname, account), {…}))
Nach Schwellwert einstufenaccount / Update / 20If(NewRecord.revenue >= 100000, Set(NewRecord.description, "A-Kunde"), Set(NewRecord.description, "Standard"))
Speichern verhinderncontact / Create / 20If(IsBlank(NewRecord.lastname), Error("Bitte einen Nachnamen angeben."))
Beim Löschen einen Vermerk setzencontact / Delete / 20Patch(account, AsType(OldRecord.Firmenname, account), {description: "Kontakt entfernt"})
Kindzeilen zählencontact / Create / 20… Text(CountRows(Filter(contact, Firmenname = NewRecord.Firmenname)))
Kindzeilen summierencontact / Update / 20… {revenue: Sum(Filter(contact, Firmenname = OldRecord.Firmenname), annualincome)}
Eine neue Zeile anlegenaccount / Create / 20Collect(contact, {lastname: "Noch offen", description: "Platzhalter"})
Mail aus einer Vorlage sendenaccount / Update / 40XSendEmailFromTemplate(…)
Hinweis in der App schickenaccount / Update / 20XSendAppNotification(Titel, Empfänger)

Create, Update und Delete sind damit alle drei als Auslöser bestätigt.

Die Validierung wirkt wirklich

Das ist die Story, die am meisten wert ist, deshalb habe ich sie nicht nur angelegt, sondern ausgelöst. Regel an contact / Create / Stage 20 mit Error(), dann ein Kontakt ohne Nachnamen:

Plugin wk_val failed with: Bitte einen Nachnamen angeben.

Die Zeile entsteht nicht. Ein Kontakt mit Nachnamen wird normal angelegt. Die eigene Meldung kommt beim Aufrufer an — mit dem technischen Präfix Plugin <name> failed with: davor. Für eine Oberfläche, die Endnutzer sehen, ist das nicht schön; brauchbar ist es trotzdem.

Aufgerufen werden

AnforderungFormel
Eine App fragt nach der Rabattstufe{Stufe: If(Umsatz >= 100000, "A", Umsatz >= 25000, "B", "C")}
Ein Flow braucht die Anschrift als eine Zeile{Zeile: Concatenate(Strasse, ", ", Text(Plz), " ", Ort)}
Ein fremdes System fragt einen Bestand ab{Anzahl: CountRows(Filter(contact, jobtitle = Position))}
Das Ergebnis gleich als JSON{Daten: JSON({stufe: If(Umsatz >= 100000, "A", "B"), umsatz: Umsatz})}
Eine Frist ausrechnen{Faellig: Text(DateAdd(Stichtag, 30, TimeUnit.Days))}

Was nicht geht

WegMeldung
XHttpRequest, XSendHttpRequestunknown or unsupported function
Patch(contact, Defaults(contact), {…})'Defaults' is a recognized but not supported function
Patch(contact, {…}) ohne ZielzeileDependency check failed

Eine Zeile anzulegen geht also — nur nicht auf dem aus Canvas Apps gewohnten Weg: Collect statt Patch mit Defaults.

Von den vier Anforderungen, die in der ersten Runde scheiterten, blieb nach genauem Hinsehen nur eine echte Grenze übrig: der Aufruf nach draußen. Die anderen drei lagen an meinen Formeln — eine falsch gesetzte Klammer, XSendAppNotification mit drei statt zwei oder vier Argumenten, und der Versuch mit Defaults. Wer also auf eine Fehlermeldung stößt, sollte sie zweimal lesen, bevor er das Feature abschreibt.

Die Entscheidung

Entscheidungshilfe: wann die Low-Code-Regel reicht, wann das C#-Plugin nötig ist, und was in jedem Fall zu prüfen ist

Nehmen Sie Power Fx, wenn

  • die Logik in zwei, drei Zeilen passt: Feld vergleichen, Wert übertragen, Wert berechnen
  • die Zahl der betroffenen Zeilen überschaubar ist — bei Einzelspeicherungen im Formular fallen 50 ms nicht auf
  • niemand im Team C# schreibt und die Alternative wäre, es gar nicht zu lösen
  • Sie Nachvollziehbarkeit brauchen, ohne sie zu programmieren
  • Stage 10 oder 20 genügt

Nehmen Sie C#, wenn

  • Massenverarbeitung dazugehört: Import, Migration, nächtlicher Lauf. Bei 100.000 Zeilen sind aus 50 ms je Zeile gut eineinhalb Stunden Unterschied geworden
  • ein fremdes System abgefragt werden soll — dafür gibt es in der Formelsprache nichts
  • die Logik Verzweigungen, Schleifen oder Fehlerbehandlung braucht
  • Sie auf PostOperation angewiesen sind
  • polymorphe Lookups eine echte Fallunterscheidung brauchen — ohne IsType wird das in Power Fx unangenehm
  • die Lösung produktiv trägt und Preview als Fundament nicht in Frage kommt

Prüfen Sie in jedem Fall

  1. Tut die Regel wirklich etwas? Eine Zeile ändern und nachsehen — „angelegt und aktiv” genügt nicht.
  2. Gibt die Function einen Record zurück? Sonst scheitert erst der Aufruf, nicht die Anlage.
  3. Steht ein Rekursionsschutz in der Formel?
  4. Wer ist Besitzer der Regel, und was passiert, wenn dieser Mensch geht?
  5. Liegt die Regel in der Solution, die nach Prod geht?

Die unbequemen Teile

Beides ist Preview, und das merkt man. Stage 40 lässt sich anlegen, gilt als aktiv und tut nichts — das ist kein Randfall, sondern die Stufe, auf der ein großer Teil der klassischen Plugins läuft. IsType fehlt. Die Dokumentation nennt einen Kontextnamen, den es nicht gibt, und verschweigt die beiden, die es gibt.

Dazu kommt die Laufzeit. Eine halbe Größenordnung langsamer ist in einem Formular nicht zu merken und in einer Migration sehr wohl. Wer heute mit Low-Code anfängt und in einem Jahr Massendaten verarbeitet, schreibt die Logik ein zweites Mal.

Was bleibt: Für den Plugin-Ersatz ist die härteste Hürde genommen — OldRecord und NewRecord gibt es, die Änderungserkennung funktioniert, und von den geprüften Anforderungen ließ sich jede einzelne umsetzen. Für den Custom-API-Ersatz gilt das nur halb: Rechnen ja, Daten lesen ja, fremde Systeme nein. Für die zehn Zeilen, die in jedem Projekt anfallen, ist das genug. Für alles darüber lohnt der Blick auf die Uhr.

Siehe auch