← Zurück zum Blog

Event Subscriber Best Practices in Business Central - typische Fehler, Performance & Reihenfolge

Ein typischer Projektstart:

„Wir machen es sauber - wir machen alles über Events."

Drei Monate später sieht die Realität so aus:

  • Buchungen dauern 10 Sekunden
  • Irgendwas triggert doppelt
  • Job Queue crasht
  • Und keiner weiß, woher es kommt

Der Developer ist ratlos.
Der Product Owner ist genervt.
Und das Management fragt: „Warum ist das System so langsam geworden?"

Und das Spannende ist:

Events sind das beste Erweiterungskonzept in Business Central - aber sie sind kein Freifahrtschein.

In diesem Artikel schauen wir uns an, warum Event Subscriber nicht automatisch „sauber" sind, welche typischen Fallen es gibt und wie man Subscriber wirklich strukturiert.

1) Warum Events in BC so wichtig sind (kurz)

Events sind das offizielle Erweiterungsmodell in Microsoft Dynamics 365 Business Central.

Der Grund ist einfach:

  • Extensions dürfen Standard-Code nicht ändern
  • Copy/Paste von Standardobjekten ist nicht erlaubt
  • Events sind vom Standard vorgesehene Erweiterungspunkte
  • Dadurch bleibt der Code upgrade-sicher

Das ist auch gut so - denn sonst wäre jede Extension ein potenzielles Update-Problem.

Aber: Events sind nicht automatisch „gut". Sie sind nur das Werkzeug.

2) Die 3 Event-Arten - und wann man welche nimmt

Bevor wir zu den Fehlern kommen, müssen wir die drei Event-Typen verstehen.

Integration Events

Das sind Events, die Microsoft (oder andere Extensions) explizit im Code platziert haben.

Beispiel:

[IntegrationEvent(false, false)]
local procedure OnBeforePostSalesDoc(var SalesHeader: Record "Sales Header")
begin
end;

Eigenschaften:

  • Explizit gesetzt
  • Meist stabil über Updates hinweg
  • Dokumentiert (oder sollte es sein)
  • Upgrade-sicher

Wann nutzen:
Immer, wenn es einen passenden Integration Event gibt.

Business Events

Das sind Events, die primär für externe Systeme gedacht sind (z. B. Power Automate, Logic Apps).

Sie sind weniger für AL-Subscriber gedacht, sondern für Event Grid / Webhooks.

Wann nutzen:
Für Integrationen außerhalb von BC.

Trigger Events (Table/Page Events)

Das sind Events, die automatisch von Table- und Page-Operations gefeuert werden:

  • OnAfterInsertEvent
  • OnBeforeModifyEvent
  • OnAfterDeleteEvent
  • OnAfterValidateEvent
  • etc.

Eigenschaften:

  • Mächtig
  • Aber: Performance-kritisch
  • Können sehr oft gefeuert werden
  • Keine explizite Dokumentation

Wann nutzen:
Nur wenn es keinen passenden Integration Event gibt - und mit Vorsicht.

3) Der #1 Anfängerfehler: Subscriber als Business-Logik-Mülleimer

Das ist einer der häufigsten Fehler in Business-Central-Projekten.

Entwickler bauen:

  • Überall kleine Subscriber
  • Überall kleine Sonderlogiken
  • Überall direkte Datenbankzugriffe

Ergebnis: „Event Spaghetti"

Nach einem Jahr sieht das so aus:

  • 50+ Subscriber Codeunits
  • Niemand weiß, was wann triggert
  • Debugging wird zur Hölle
  • Performance bricht ein

Was ist das Problem?

Das Problem ist nicht der Event Subscriber selbst - sondern dass er als Container für Fachlogik missbraucht wird.

Anti-Pattern:

[EventSubscriber(ObjectType::Codeunit, Codeunit::"Sales-Post", 'OnBeforePostSalesDoc', '', false, false)]
local procedure OnBeforePostSalesDoc(var SalesHeader: Record "Sales Header")
var
    Customer: Record Customer;
    Item: Record Item;
begin
    // 50 Zeilen Fachlogik direkt hier
    Customer.Get(SalesHeader."Sell-to Customer No.");
    if Customer."Customer Type" = Customer."Customer Type"::VIP then begin
        // noch mehr Logik
    end;
    // noch mehr...
end;

Das ist nicht testbar, nicht wiederverwendbar und nicht wartbar.

Best Practice

Subscriber orchestrieren, Logik in Services auslagern:

[EventSubscriber(ObjectType::Codeunit, Codeunit::"Sales-Post", 'OnBeforePostSalesDoc', '', false, false)]
local procedure OnBeforePostSalesDoc(var SalesHeader: Record "Sales Header")
var
    SalesValidationService: Codeunit "Sales Validation Service";
begin
    SalesValidationService.ValidateVIPCustomer(SalesHeader);
end;

Die eigentliche Logik liegt in der Service Codeunit:

codeunit 50100 "Sales Validation Service"
{
    procedure ValidateVIPCustomer(var SalesHeader: Record "Sales Header")
    var
        Customer: Record Customer;
    begin
        if not Customer.Get(SalesHeader."Sell-to Customer No.") then
            exit;

        if Customer."Customer Type" <> Customer."Customer Type"::VIP then
            exit;

        // Fachlogik hier
    end;
}

Vorteile:

  • Service ist testbar
  • Logik ist wiederverwendbar
  • Subscriber ist dünn und klar

4) Reihenfolge: Das Problem, das jeder irgendwann hat

Hier wird es richtig kritisch.

Viele Developer gehen davon aus:

„Mein Subscriber läuft zuerst."

Das ist falsch.

Die Wahrheit über Subscriber-Reihenfolge

Business Central garantiert keine Reihenfolge zwischen Subscribern.

Das bedeutet:

  • Du kannst nicht davon ausgehen, dass Subscriber A vor Subscriber B läuft
  • Andere Extensions können ebenfalls subscriben
  • Updates können die (interne) Reihenfolge verändern
  • AppSource-Apps können dazwischenfunken

Typisches Problem:

// Extension A
[EventSubscriber(..., 'OnBeforePostSalesDoc', ...)]
local procedure SetCustomField(var SalesHeader: Record "Sales Header")
begin
    SalesHeader."Custom Field" := 'Value';
end;

// Extension B
[EventSubscriber(..., 'OnBeforePostSalesDoc', ...)]
local procedure UseCustomField(var SalesHeader: Record "Sales Header")
begin
    if SalesHeader."Custom Field" <> 'Value' then
        Error('Custom Field not set!');
end;

Das funktioniert manchmal - und manchmal nicht.

Warum ist das so gefährlich?

Weil:

  • Es im Test funktioniert
  • Es in Sandbox funktioniert
  • Aber in Production plötzlich bricht (weil eine andere Extension dazugekommen ist)

Best Practice

Baue niemals Abhängigkeiten zwischen Subscribern.

Jeder Subscriber muss eigenständig funktionieren.

Wenn du Daten zwischen Subscribern teilen musst, nutze:

  • SingleInstance Codeunits (als temporärer State-Container)
  • Temporary Tables
  • oder ändere die Architektur

5) Performance: Warum 1 Subscriber = ok, 50 Subscriber = Katastrophe

Jetzt wird es richtig praktisch.

Event Subscriber können Performance-Killer sein - wenn man nicht aufpasst.

Typische Performance-Fallen

Fall 1: FindSet() in jedem Subscriber

[EventSubscriber(ObjectType::Table, Database::"Sales Line", 'OnAfterModifyEvent', '', false, false)]
local procedure OnAfterModifySalesLine(var Rec: Record "Sales Line")
var
    Item: Record Item;
begin
    Item.SetRange("No.", Rec."No.");
    if Item.FindSet() then // ← Falscher Query
        repeat
            // Logik
        until Item.Next() = 0;
end;

Problem:

Dieser Subscriber wird pro Sales Line getriggert.

Bei 100 Zeilen = 100x FindSet().

Fall 2: Modify() ohne Grund

[EventSubscriber(...)]
local procedure UpdateSomething(var Rec: Record "Sales Header")
begin
    Rec."Custom Field" := 'Value';
    Rec.Modify(); // ← Triggert wieder Events!
end;

Das kann zu Endlosschleifen führen (siehe nächster Abschnitt).

Fall 3: Unnötige CalcFields()

[EventSubscriber(...)]
local procedure CheckBalance(var Customer: Record Customer)
begin
    Customer.CalcFields(Balance, "Balance (LCY)"); // ← Pro Event!
    if Customer.Balance > 10000 then
        // Logik
end;

Problem:
CalcFields() macht SQL-Queries - und das pro Event-Aufruf.

Fall 4: COMMIT() im Subscriber

Das ist extrem gefährlich:

[EventSubscriber(...)]
local procedure LogSomething(var Rec: Record "Sales Header")
begin
    // Logging
    Commit(); // ← Bricht Transaktionen ab!
end;

Warum gefährlich?

  • COMMIT beendet die aktuelle Transaktion
  • Fehler danach können nicht mehr zurückgerollt werden
  • Daten-Inkonsistenz ist die Folge

Nur in absoluten Ausnahmefällen nutzen.

Fall 5: Dialog/Message (GuiAllowed)

Wie in unserem GuiAllowed-Artikel erklärt:

[EventSubscriber(...)]
local procedure ShowMessage(var Rec: Record "Sales Header")
begin
    Message('Something happened'); // ← Crasht in Job Queue!
end;

Immer prüfen:

if GuiAllowed then
    Message('Something happened');

Die Faustregel

Ein Subscriber, der pro Sales Line 1x eine Tabelle liest, ist okay.
Ein Subscriber, der pro Sales Line 5 Tabellen scanned, killt jede Buchung.

6) Recursion / Endlosschleifen (der Klassiker)

Das ist einer der häufigsten Fehler überhaupt.

Das Problem

[EventSubscriber(ObjectType::Table, Database::"Sales Header", 'OnAfterModifyEvent', '', false, false)]
local procedure OnAfterModify(var Rec: Record "Sales Header")
begin
    Rec."Last Modified By" := UserId;
    Rec.Modify(); // ← Triggert OnAfterModifyEvent → Endlosschleife!
end;

Was passiert:

  1. User ändert Sales Header
  2. Subscriber wird getriggert
  3. Subscriber macht Rec.Modify()
  4. Das triggert wieder OnAfterModifyEvent
  5. Schleife beginnt
  6. Stack overflow / Performance-Tod

Wie verhindert man das?

Lösung 1: xRec vergleichen

[EventSubscriber(...)]
local procedure OnAfterModify(var Rec: Record "Sales Header"; var xRec: Record "Sales Header")
begin
    if Rec."Last Modified By" = xRec."Last Modified By" then
        exit; // Schon gesetzt → raus

    Rec."Last Modified By" := UserId;
    Rec.Modify();
end;

Lösung 2: Flag in SingleInstance Codeunit

codeunit 50110 "Recursion Guard"
{
    SingleInstance = true;

    var
        IsRunning: Boolean;

    procedure SetRunning()
    begin
        IsRunning := true;
    end;

    procedure ClearRunning()
    begin
        IsRunning := false;
    end;

    procedure IsAlreadyRunning(): Boolean
    begin
        exit(IsRunning);
    end;
}

Dann im Subscriber:

[EventSubscriber(...)]
local procedure OnAfterModify(var Rec: Record "Sales Header")
var
    Guard: Codeunit "Recursion Guard";
begin
    if Guard.IsAlreadyRunning() then
        exit;

    Guard.SetRunning();

    // Logik hier

    Guard.ClearRunning();
end;

Lösung 3: RunTrigger := false (nur wenn sinnvoll)

Rec."Last Modified By" := UserId;
Rec.Modify(false); // RunTrigger = false

Aber Vorsicht:
Das deaktiviert alle Trigger - auch die, die du vielleicht brauchst.

7) „Subscriber feuert nicht" - die häufigsten Ursachen

Das ist ein sehr häufiges Troubleshooting-Szenario.

Checkliste: Warum feuert mein Subscriber nicht?

ProblemLösung
Falscher EventOnBefore vs OnAfter verwechselt
Falsche Parameter-SignaturParameter-Namen müssen exakt passen
Event ist in anderer AppDependency in app.json fehlt
Subscriber Codeunit nicht registriertCodeunit muss in Extension sein
Event nur bei bestimmten Prozessenz. B. nur bei Posting, nicht bei Release
Event ist conditionalEvent wird nur unter bestimmten Bedingungen gefeuert
Scope falschOnPrem vs SaaS / Cloud

Typisches Beispiel

Du subscribst auf:

[EventSubscriber(ObjectType::Codeunit, Codeunit::"Sales-Post", 'OnBeforePostSalesDoc', '', false, false)]

Aber der Event existiert als:

OnBeforePostSalesDocument

Namen stimmt nicht überein → Subscriber wird nie getriggert.

Debugging-Tipp

Setze einen Breakpoint im Subscriber und prüfe:

  • Wird der Subscriber überhaupt getroffen?
  • Wenn nein: Event-Name/Signatur prüfen
  • Wenn ja, aber Logik läuft nicht: Bedingungen prüfen

8) Subscriber & Posting: Warum man hier besonders vorsichtig sein muss

Posting-Prozesse sind in Business Central extrem kritisch.

Warum ist Posting besonders?

  • Performance-kritisch (oft hunderte Zeilen)
  • Transaktions-kritisch (muss alles oder nichts sein)
  • Fehler = Buchung bricht ab (User-Impact sofort sichtbar)
  • COMMIT-Verhalten gefährlich (siehe oben)

Best Practices für Posting-Subscriber

✅ Keine UI

// ❌ Falsch
Message('Sales Order posted');

// ✅ Richtig
if GuiAllowed then
    Message('Sales Order posted');

✅ Keine unnötigen Writes

Jedes Modify() / Insert() kostet Zeit und kann Fehler werfen.

✅ Logging statt Message

// Statt Message:
Session.LogMessage('SALES_POST', 'Order posted', Verbosity::Normal, DataClassification::SystemMetadata, TelemetryScope::All, 'Category', 'SalesPosting');

✅ Klare Fehlertexte

Wenn dein Subscriber einen Fehler wirft, sollte klar sein, wo er herkommt:

Error('Custom validation failed in OnBeforePostSalesDoc: %1', DetailedMessage);

✅ Early Exit

Prüfe so früh wie möglich, ob der Subscriber überhaupt laufen muss:

[EventSubscriber(...)]
local procedure OnBeforePostSalesDoc(var SalesHeader: Record "Sales Header")
begin
    if SalesHeader."Document Type" <> SalesHeader."Document Type"::Order then
        exit; // Nicht für uns

    if not SalesHeader."Custom Field" then
        exit; // Feature nicht aktiv

    // Jetzt erst die eigentliche Logik
end;

9) Best Practices (kompakt)

Hier meine wichtigsten Regeln für Event Subscriber:

✅ Subscriber dünn halten, Logik in Services auslagern

Subscriber = Orchestrierung
Services = Fachlogik

✅ Niemals auf Reihenfolge verlassen

Jeder Subscriber muss eigenständig funktionieren.

✅ Keine Datenbank-Schleifen in Line-Events

Ein FindSet() pro Sales Line = Performance-Tod.

✅ Recursion vermeiden (xRec + Guards)

xRec vergleichen oder SingleInstance Guard nutzen.

✅ Kein COMMIT im Subscriber

Nur in absoluten Ausnahmefällen - und dann dokumentieren warum.

✅ GuiAllowed nie voraussetzen

Siehe GuiAllowed-Artikel.

✅ Debugging/Telemetry einbauen

Session.LogMessage() nutzen, um Subscriber-Aufrufe nachzuvollziehen.

✅ Performance messen

Nutze BC Telemetry oder eigene Logging-Mechanismen, um langsame Subscriber zu identifizieren.

10) Bonus: So sieht ein guter Subscriber aus

Hier ein konkretes Beispiel:

codeunit 50120 "Sales Order Subscriber"
{
    [EventSubscriber(ObjectType::Codeunit, Codeunit::"Sales-Post", 'OnBeforePostSalesDoc', '', false, false)]
    local procedure OnBeforePostSalesDoc(var SalesHeader: Record "Sales Header"; var HideProgressWindow: Boolean)
    var
        SalesValidationService: Codeunit "Sales Validation Service";
        Guard: Codeunit "Recursion Guard";
    begin
        // Early Exit: Feature nur für Orders
        if SalesHeader."Document Type" <> SalesHeader."Document Type"::Order then
            exit;

        // Early Exit: Feature nicht aktiv
        if not SalesHeader."Custom Validation Active" then
            exit;

        // Guard gegen Recursion
        if Guard.IsAlreadyRunning() then
            exit;

        Guard.SetRunning();

        // Eigentliche Logik in Service ausgelagert
        SalesValidationService.ValidateCustomRules(SalesHeader);

        Guard.ClearRunning();
    end;
}

Was macht das Beispiel richtig?

  • Early Exits → Performance
  • Recursion Guard → Keine Endlosschleife
  • Service Codeunit → Testbar & wiederverwendbar
  • Keine DB-Calls → Schnell
  • Keine UI → Funktioniert auch in Job Queue
  • Klar strukturiert → Lesbar

Fazit

Event Subscriber sind der sauberste Weg, Business Central zu erweitern.

Aber sie skalieren nur dann gut, wenn man sie wie Architektur behandelt - nicht wie „Quick Fixes".

Die wichtigsten Erkenntnisse:

  • Subscriber orchestrieren, Services enthalten Logik
  • Reihenfolge ist nicht garantiert
  • Performance ist kritisch bei Table Events
  • Recursion ist der Klassiker
  • Posting-Subscriber brauchen besondere Vorsicht

Wenn du diese Regeln befolgst, bleiben deine Extensions:

  • Wartbar
  • Performant
  • Upgrade-sicher
  • Und sie funktionieren auch dann noch, wenn andere Extensions dazukommen

Wenn dein System nur funktioniert, solange niemand eine zweite Extension installiert, ist es nicht fertig.