← Zurück zum Blog

SingleInstance Codeunits in Business Central - Wann sinnvoll, wann ein Risiko

Ein typischer Projektmoment:

„Wir machen das sauber - wir nutzen SingleInstance, damit wir den Cache nicht jedes Mal neu laden."

Ein paar Wochen später sieht die Realität so aus:

  • User A sieht Daten von User B
  • Job Queue verhält sich anders als der Client
  • Tests laufen random falsch
  • Und niemand versteht warum

Der Developer sucht verzweifelt nach einem Bug.
Der Product Owner fragt: „Warum ist das so unzuverlässig?"
Und das Management will wissen: „Können wir das überhaupt in Production einsetzen?"

Und das Spannende ist:

SingleInstance ist ein mächtiges Werkzeug - aber es ist kein Freifahrtschein.

In diesem Artikel schauen wir uns an, was SingleInstance wirklich bedeutet, warum es nicht „Singleton wie in C#" ist und wann es sinnvoll - oder gefährlich - wird.

1) Was ist eine SingleInstance Codeunit überhaupt?

Eine Codeunit mit SingleInstance = true wird pro Session nicht jedes Mal neu instanziert, sondern wiederverwendet.

Normal (ohne SingleInstance):

codeunit 50100 "My Service"
{
    var
        Counter: Integer;

    procedure IncrementCounter()
    begin
        Counter += 1;
        Message('Counter: %1', Counter);
    end;
}

Wenn du IncrementCounter() zweimal hintereinander aufrufst, bekommst du:

Counter: 1
Counter: 1

Warum? Weil die Codeunit jedes Mal neu instanziert wird.

Mit SingleInstance:

codeunit 50100 "My Service"
{
    SingleInstance = true;

    var
        Counter: Integer;

    procedure IncrementCounter()
    begin
        Counter += 1;
        Message('Counter: %1', Counter);
    end;
}

Wenn du IncrementCounter() zweimal hintereinander aufrufst, bekommst du:

Counter: 1
Counter: 2

Die Codeunit behält ihren State zwischen den Aufrufen.

Wichtig zu verstehen

  • Die Codeunit kann Variablenwerte behalten
  • Zwischen Aufrufen wird sie nicht neu initialisiert
  • Dadurch kann sie State speichern

Das klingt mächtig - und das ist es auch.

Aber es kommt mit Risiken.

2) Die wichtigste Wahrheit: SingleInstance ≠ global

Das ist der Punkt, an dem 90% aller SingleInstance-Probleme entstehen.

Viele Developer denken:

„SingleInstance bedeutet: global, einmal pro Tenant."

Das ist falsch.

Die Realität

SingleInstance ist:

  • Sessionbasiert (nicht tenantweit)
  • Nicht mandantenübergreifend
  • Nicht „serverweit"
  • Nicht zwischen Usern geteilt

Merksatz:

SingleInstance ist ein „Session-Singleton", kein „System-Singleton".

Was bedeutet das konkret?

Wenn User A und User B gleichzeitig arbeiten:

  • User A hat seine eigene Session
  • User B hat seine eigene Session
  • Beide haben ihre eigene SingleInstance-Instanz

Die beiden Instanzen teilen keine Daten.

Aber:

Wenn User A mehrmals hintereinander eine Funktion aufruft, läuft das in derselben Session - und die SingleInstance Codeunit behält ihren State.

Warum ist das wichtig?

Weil es bedeutet:

  • SingleInstance ist kein globaler Cache für alle User
  • Es ist ein Session-spezifischer State-Container
  • Und Sessions verhalten sich oft anders, als man denkt

3) Wann SingleInstance sinnvoll ist (Best Cases)

Jetzt wird es praktisch.

Es gibt Szenarien, in denen SingleInstance genau das Richtige ist.

a) Caching von Setup-Daten

Das ist der klassische Use Case.

Beispiel:

codeunit 50110 "Setup Cache"
{
    SingleInstance = true;

    var
        SalesSetup: Record "Sales & Receivables Setup";
        IsLoaded: Boolean;

    procedure GetSalesSetup(): Record "Sales & Receivables Setup"
    begin
        if not IsLoaded then begin
            SalesSetup.Get();
            IsLoaded := true;
        end;
        exit(SalesSetup);
    end;

    procedure ClearCache()
    begin
        Clear(SalesSetup);
        IsLoaded := false;
    end;
}

Vorteil:

  • Setup wird nur 1x pro Session geladen
  • Nicht bei jedem Aufruf
  • Performance-Gewinn bei häufigen Zugriffen

Aber:

Nur sinnvoll, wenn:

  • Setup sich nicht ändert (oder selten)
  • oder du Cache invalidieren kannst
  • oder du bewusst mit „veralteten" Daten leben kannst

b) Recursion Guards / Re-Entry Protection

Das ist ein sehr guter Use Case.

Wie in unserem Event Subscriber Artikel erklärt:

codeunit 50120 "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 (die ggf. wieder Modify aufruft)

    Guard.ClearRunning();
end;

Warum funktioniert das?

Weil innerhalb derselben Session die gleiche SingleInstance Codeunit verwendet wird.

c) Kontext speichern (nur innerhalb einer Aktion)

Beispiel:

Du startest einen Posting-Prozess:

  • Mehrere Subscriber feuern
  • Du willst eine Transaction Context ID allen Subscribern zur Verfügung stellen
  • Ohne sie überall als Parameter durchzuschleifen
codeunit 50130 "Process Context"
{
    SingleInstance = true;

    var
        CurrentProcessGuid: Guid;

    procedure StartProcess(): Guid
    begin
        CurrentProcessGuid := CreateGuid();
        exit(CurrentProcessGuid);
    end;

    procedure GetCurrentProcessGuid(): Guid
    begin
        exit(CurrentProcessGuid);
    end;

    procedure EndProcess()
    begin
        Clear(CurrentProcessGuid);
    end;
}

Wichtig:

Nach dem Prozess muss EndProcess() aufgerufen werden - sonst bleibt der State drin.

d) Telemetry / Logging Sammeln

Während eines Posting-Prozesses:

  • Verschiedene Stellen loggen Ereignisse
  • Am Ende wird einmal alles zusammen geschrieben
  • Statt 50 einzelner Writes
codeunit 50140 "Telemetry Collector"
{
    SingleInstance = true;

    var
        LogEntries: List of [Text];

    procedure AddEntry(Entry: Text)
    begin
        LogEntries.Add(Entry);
    end;

    procedure FlushAndClear()
    var
        Entry: Text;
    begin
        foreach Entry in LogEntries do
            Session.LogMessage('PROCESS', Entry, Verbosity::Normal, DataClassification::SystemMetadata, TelemetryScope::All, 'Category', 'Process');

        LogEntries := LogEntries; // Clear
    end;
}

Vorteil:

  • Weniger DB-Writes
  • Atomare Log-Einträge
  • Bessere Performance

4) Wann SingleInstance gefährlich ist (die echten Risiken)

Jetzt wird es kritisch.

Das sind die Szenarien, in denen SingleInstance richtig Probleme macht.

❌ a) State Leaks zwischen Prozessen

Das ist der häufigste Fehler überhaupt.

Anti-Pattern:

codeunit 50150 "Sales Processor"
{
    SingleInstance = true;

    var
        CurrentCustomerNo: Code[20];
        CurrentOrderNo: Code[20];
        ProcessingActive: Boolean;

    procedure ProcessOrder(SalesHeader: Record "Sales Header")
    begin
        CurrentCustomerNo := SalesHeader."Sell-to Customer No.";
        CurrentOrderNo := SalesHeader."No.";
        ProcessingActive := true;

        // Logik...
    end;

    procedure GetCurrentCustomer(): Code[20]
    begin
        exit(CurrentCustomerNo);
    end;
}

Problem:

Wenn du ProcessOrder() für Order A aufrufst, dann für Order B - bleibt der State von Order A teilweise erhalten.

Wenn irgendwo GetCurrentCustomer() aufgerufen wird, bekommst du:

  • Manchmal den Customer von Order A
  • Manchmal den von Order B
  • Je nachdem, wo du in der Prozess-Kette bist

Ergebnis:

  • Falsche Daten
  • Unvorhersehbares Verhalten
  • Bugs, die nur manchmal auftreten

❌ b) Multi-User Annahmen

Auch wenn SingleInstance nicht zwischen Usern geteilt ist, kann es trotzdem „komisch" wirken.

Szenarien:

  • User A startet Prozess
  • Lässt Browser offen
  • Startet zweiten Prozess (gleiche Session!)
  • SingleInstance hat noch State vom ersten Prozess

Oder:

  • Webservice Requests können Session-Verhalten haben
  • Tests laufen in eigener Session
  • Job Queue hat eigene Session

Das bedeutet:

State, den du für „pro Aufruf" hältst, ist eigentlich „pro Session".

❌ c) Job Queue & Background Sessions

Das ist extrem wichtig - und wird oft vergessen.

Wie in unserem GuiAllowed-Artikel erklärt:

Job Queue läuft:

  • In eigener Session
  • Ohne UI
  • Mit anderem Timing
  • Mit anderen Berechtigungen

Typischer Effekt:

  • Manuell funktioniert es (Client-Session)
  • Job Queue schlägt fehl (Background-Session)

Warum?

Weil die SingleInstance Codeunit:

  • Im Client einen State aufbaut
  • In der Job Queue Session leer startet

Oder umgekehrt:

  • Job Queue baut State auf
  • Bleibt zwischen mehreren Job Queue Runs erhalten
  • Führt zu State Leaks

❌ d) Tests werden unzuverlässig

Das ist ein massives Problem in automatisierten Tests.

Szenario:

Du hast 10 Tests.

Test 1 setzt einen Flag in einer SingleInstance Codeunit.

Test 2 läuft - und sieht den Flag von Test 1.

Ergebnis:

  • Tests sind reihenfolgeabhängig
  • Tests schlagen „random" fehl
  • Test-Suite wird unzuverlässig

Best Practice:

Wenn du SingleInstance nutzt, musst du:

  • State in OnBeforeTestRun zurücksetzen
  • Oder explizite ClearCache() / Reset() Methoden aufrufen
  • Oder Tests in eigenen Sessions laufen lassen (teuer)

❌ e) Setup wird geändert, Cache bleibt alt

Der Klassiker.

Szenario:

  1. User öffnet BC
  2. Setup wird gecacht (per SingleInstance)
  3. User ändert Setup
  4. Cache bleibt alt
  5. System verhält sich „falsch", bis Session neu ist

Beispiel:

// Setup gecacht
SalesSetup."Credit Limit Warning" := true;

// User ändert Setup auf false
// Aber Cache bleibt bei true

// System warnt weiter bei Credit Limit
// User sagt: "Ich habe es doch deaktiviert!"

Lösung:

Cache invalidieren bei Änderungen:

table 311 "Sales & Receivables Setup"
{
    trigger OnAfterModify()
    var
        SetupCache: Codeunit "Setup Cache";
    begin
        SetupCache.ClearCache();
    end;
}

Aber: funktioniert nur, wenn die Änderung in derselben Session passiert.

5) Performance: lohnt sich SingleInstance wirklich?

Viele nutzen SingleInstance wegen Performance.

Aber: lohnt sich das wirklich?

Was SingleInstance spart

  • Wiederholtes Laden von Setup (1x Get statt 100x)
  • Wiederholtes Berechnen von Werten
  • Objektinstanzierung

Was SingleInstance nicht spart

  • Die meisten Reads sind in BC sowieso schnell
  • Die echten Bottlenecks sind oft:
    • Loops (FindSet mit 10.000 Records)
    • Modify pro Line (Sales Line Schleife)
    • FlowFields (CalcFields in jedem Event Subscriber)
    • Posting Subscriber (50 Subscriber pro Buchung)

Die Wahrheit

SingleInstance ist selten der Performance-Gamechanger, den man erwartet.

Faustregel:

Wenn du Setup 100x pro Posting lädst - kann SingleInstance helfen.

Wenn du eine Tabelle mit 10.000 Zeilen durchläufst - hilft SingleInstance nichts.

Wann lohnt es sich?

Nur bei:

  • Setup-Tables (sehr häufig gelesen, selten geändert)
  • Lookup-Daten (Länder, Währungen, Unit of Measure)
  • Konfigurationsdaten

Nicht bei:

  • Business-Daten (Sales Lines, Item Ledger Entries)
  • User-spezifischen Daten
  • Häufig ändernden Daten

6) Best Practices (klar & streng)

Hier die Regeln, die du immer befolgen solltest:

✅ Nur Setup cachen, nie Business-Daten

Setup = gut:

  • Sales & Receivables Setup
  • General Ledger Setup
  • Custom Configuration

Business-Daten = schlecht:

  • Sales Orders
  • Items
  • Customer Data

✅ Nie Record-Variablen als „globalen Zustand" halten

Falsch:

var
    GlobalSalesHeader: Record "Sales Header";

Das führt zu State Leaks.

Richtig:

Parameter übergeben.

✅ Cache immer resetbar machen

procedure ClearCache()
begin
    Clear(CachedSetup);
    IsLoaded := false;
end;

Dann kannst du:

  • Nach Änderungen invalidieren
  • In Tests zurücksetzen
  • Bei Bedarf neu laden

✅ SingleInstance niemals als „Datenbank-Ersatz" missbrauchen

SingleInstance ist kein In-Memory-Datastore.

Wenn du komplexe Datenstrukturen speichern willst, nutze:

  • Temporäre Tabellen
  • Datenbank-Tables
  • Externe Caches (Redis etc.)

✅ Immer an Job Queue & Tests denken

Frag dich:

  • Funktioniert das in Job Queue?
  • Funktioniert das in Tests?
  • Was passiert, wenn zwei Prozesse parallel laufen?

✅ Flags nach Prozessende zurücksetzen

procedure StartProcess()
begin
    IsProcessing := true;
end;

procedure EndProcess()
begin
    IsProcessing := false; // ← Wichtig!
end;

Sonst bleibt der Flag gesetzt und beeinflusst den nächsten Aufruf.

7) Sauberes Pattern: Cache + Reset

Hier ein solides Pattern, das gut funktioniert:

codeunit 50160 "Sales Setup Cache"
{
    SingleInstance = true;

    var
        SalesSetup: Record "Sales & Receivables Setup";
        IsLoaded: Boolean;

    procedure GetSetup(): Record "Sales & Receivables Setup"
    begin
        if not IsLoaded then
            LoadSetup();
        exit(SalesSetup);
    end;

    local procedure LoadSetup()
    begin
        if not SalesSetup.Get() then
            SalesSetup.Init();
        IsLoaded := true;
    end;

    procedure ClearCache()
    begin
        Clear(SalesSetup);
        IsLoaded := false;
    end;

    [EventSubscriber(ObjectType::Table, Database::"Sales & Receivables Setup", 'OnAfterModifyEvent', '', false, false)]
    local procedure OnAfterModifySalesSetup()
    begin
        ClearCache();
    end;
}

Was macht das Pattern gut?

  • ✅ Setup wird gecacht
  • ✅ Cache kann invalidiert werden
  • ✅ Auto-Invalidierung bei Änderungen
  • ✅ Klar strukturiert
  • ✅ Testbar

8) Was man stattdessen tun sollte (wenn man nur „State" braucht)

Oft braucht man gar keine SingleInstance.

Alternative 1: Übergabe per Parameter (sauberste Lösung)

procedure ProcessOrder(SalesHeader: Record "Sales Header"; ProcessContext: Record "Process Context")
begin
    // Alle Daten per Parameter übergeben
end;

Vorteil:

  • Komplett transparent
  • Keine versteckten Abhängigkeiten
  • Testbar

Alternative 2: Temporäre Tabellen

var
    TempProcessData: Record "Process Data" temporary;

TempProcessData."Key" := 'CurrentCustomer';
TempProcessData."Value" := CustomerNo;
TempProcessData.Insert();

Vorteil:

  • Strukturiert
  • Kann beliebig viele Werte speichern
  • Klar definierte Lifetime

Alternative 3: Dictionary / List lokal im Prozess

var
    ProcessState: Dictionary of [Text, Text];

ProcessState.Add('CurrentCustomer', CustomerNo);

Vorteil:

  • Flexibel
  • Lokal im Prozess
  • Kein globaler State

Alternative 4: „Context Codeunit" pro Prozess (nicht SingleInstance)

codeunit 50170 "Process Context"
{
    // NICHT SingleInstance!

    var
        CustomerNo: Code[20];
        OrderNo: Code[20];

    procedure Initialize(SalesHeader: Record "Sales Header")
    begin
        CustomerNo := SalesHeader."Sell-to Customer No.";
        OrderNo := SalesHeader."No.";
    end;

    // Getter-Methoden...
}

Vorteil:

  • Pro Aufruf eine frische Instanz
  • Kein State Leak
  • Funktioniert wie erwartet

Fazit

SingleInstance ist ein Werkzeug.

Wenn man es für Cache und Guards nutzt, ist es super.

Wenn man damit versucht, „globale Zustände" zu bauen, wird es früher oder später unzuverlässig.

Die wichtigsten Erkenntnisse:

  • SingleInstance ist sessionbasiert, nicht global
  • Setup cachen: ja - Business-Daten: nein
  • Cache muss invalidierbar sein
  • Flags müssen zurückgesetzt werden
  • Job Queue und Tests verhalten sich anders
  • Performance-Gewinn ist oft kleiner als erwartet

Wenn du diese Regeln befolgst, bleibt deine Extension:

  • Zuverlässig
  • Testbar
  • Wartbar
  • Und funktioniert auch dann noch, wenn User mehrere Prozesse parallel starten

Wenn dein Prozess nur funktioniert, solange die Session frisch ist, ist er nicht fertig.