Die Einsicht aus der letzten Lektion war: Einzigartigkeit ist in Ordnung, der globale Zugriffspunkt ist es nicht. Genau diese Trennung leistet der DI-Container.
builder.Services.AddSingleton<IKonfiguration, Konfiguration>();Eine Zeile, und du bekommst alles, wofür das Pattern gedacht war:
- Es gibt genau eine Instanz im Prozess.
- Sie wird beim ersten Bedarf erzeugt, threadsicher.
- Konsumenten fordern
IKonfigurationim Konstruktor an — die Abhängigkeit steht in der Signatur. - Im Test registrierst du eine Attrappe. Kein statischer Zustand überlebt den Testfall.
- Wird pro Mandant eine eigene gebraucht, änderst du eine Zeile auf
AddScoped.
Die Klasse selbst braucht dafür weder privaten Konstruktor noch Instanz-Eigenschaft. Sie ist eine ganz normale Klasse; einzigartig ist nicht sie, sondern ihre Registrierung.
Die drei Lebensdauern
| Lebensdauer | Eine Instanz pro … | Typisch für |
|---|---|---|
AddSingleton | Anwendung | Konfiguration, Caches, HttpClient-Factory, teure Verbindungen |
AddScoped | HTTP-Anfrage (bzw. Scope) | DbContext, Unit of Work, angemeldeter Benutzer |
AddTransient | Auflösung | zustandslose, leichte Dienste |
Im Zweifel Scoped für alles, was mit Daten einer Anfrage arbeitet, und Singleton nur für das, was wirklich geteilt gehört.
Ein Singleton muss threadsicher sein
Das gilt unverändert — der Container nimmt dir das nicht ab. Ein Singleton wird von allen Anfragen gleichzeitig benutzt. Veränderlicher Zustand darin gehört in ConcurrentDictionary, hinter Interlocked oder hinter ein lock. Am besten hat es gar keinen.
Die Falle: Captive Dependency
Der häufigste Fehler mit Lebensdauern ist dieser:
builder.Services.AddSingleton<PreisCache>(); // lebt ewig
builder.Services.AddDbContext<ShopContext>(); // ist Scoped
public sealed class PreisCache
{
private readonly ShopContext _db; // ← festgehalten
public PreisCache(ShopContext db) => _db = db;
}PreisCache wird einmal erzeugt und hält den ShopContext, den er dabei bekommen hat, für immer fest. Der Context sollte am Ende der Anfrage entsorgt werden — wird er aber nicht, weil noch jemand auf ihn zeigt.
Die Folgen tauchen später und woanders auf: Der Change-Tracker wächst, bis der Speicher knapp wird; gelesene Entitäten sind veraltet, weil sie aus dem Tracker kommen statt aus der Datenbank; irgendwann meldet der Context, dass er bereits entsorgt sei.
Die Lösung ist die aus dem Factory-Modul — pro Vorgang einen Scope öffnen:
public sealed class PreisCache
{
private readonly IServiceScopeFactory _scopes;
public PreisCache(IServiceScopeFactory scopes) => _scopes = scopes;
public decimal Hole(string artikelNr)
{
using (var scope = _scopes.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<ShopContext>();
return db.Artikel.Single(a => a.Nr == artikelNr).Preis;
}
}
}Den Fehler findet der Container für dich
In der Entwicklungsumgebung prüft ASP.NET Core beim Start, ob ein Singleton eine scoped Abhängigkeit hält, und bricht mit einer klaren Meldung ab. In der Produktion ist diese Prüfung aus Startzeit-Gründen abgeschaltet — richte deshalb nie einen Dienst ein, den du nur produktiv gestartet hast.
Wann der klassische Singleton doch bleibt
Es gibt Fälle, in denen kein Container zur Verfügung steht:
- Eine Bibliothek, die keinen DI-Container voraussetzen darf.
- Ein
static-Kontext, in dem nichts hereingereicht werden kann. - Ein unveränderliches Objekt ohne Zustand —
Encoding.UTF8,Comparer<T>.Default,Task.CompletedTask. Dass es davon eines gibt, kann niemandem schaden, weil sich nichts daran ändern lässt.
Die letzte Zeile ist die eigentliche Regel: Ein Singleton ohne veränderlichen Zustand ist harmlos. Eines mit veränderlichem Zustand ist eine globale Variable.