Der God Service ist kein Service

Du öffnest einen Pull Request. Die Azure Function ist schlank — zwanzig Zeilen, ein Aufruf, Status 201. Der Reviewer nickt: Thin controller, sauber. Dann scrollst du eine Datei tiefer, in die Klasse, die Service heißt. Dort steckt der Rest des Systems: Verzeichnis, Tabellen, Kalender, Mail, Blobs, Rechte, Export. Der Controller war nicht die Architektur. Er war nur die erste Tür.
Dieser Beitrag vertieft eine einfache Idee aus einem früheren Text: Wenn der Einstieg dünn ist, heißt das nicht, dass die Schichten stimmen. Im Fachjargon spricht man von God Object oder God Service — einer Klasse, die zu viel weiß und zu viel tut. Der Text ist für Leser geschrieben, die Patterns aus Büchern kennen, aber in echten Codebasen nicht sofort die Grenze zwischen «Orchestrierung» und «Alles-in-einem» sehen.
Domäne (vereinfacht): Interne oder externe Event-Anmeldungen — Slots, Warteliste, Einladungen, Kalendereinträge, Bestätigungs-Mails. Technik: C#, Azure Functions, Microsoft Graph, Table Storage, Queues. Die Regeln gelten überall dort, wo *Service am Ende steht.
Kurzfassung
- Ein Service sollte einen Hauptgrund haben, sich zu ändern — nicht sieben fremde (Graph, Mail, Kalender, …).
- Dünner HTTP-Einstieg + dicke Service-Methode ist kein Layering, sondern Verschieben der Komplexität.
- Negative Muster erkennen: vollgestopfter Konstruktor, Mega-Methode,
privateHelpers statt echter Grenzen, Interface-Telefonbuch, Tests die nur Mocks asserten. - Heilung in Reihenfolge: Fachregel → Repository → Ports für Folgen (Mail/Kalender) — kein Big-Bang.
Begriffe
| Begriff | Bedeutung |
|---|---|
| God Service | «Service» im Namen, Betriebssystem im Konstruktor und in der Hauptmethode. |
| Kohäsion | Innen: Gehören Methoden und Daten zum selben fachlichen Thema (z. B. nur Anmelde-Entscheidungen)? |
| Kopplung | Außen: Wie viele fremde Systeme/SDK-Typen kennt die Klasse im Konstruktor? |
| Verantwortung (SRP) | Änderungsdruck: Aus welchem Ticket-Anlass musst du die Datei anfassen — einer, oder viele unabhängige? |
| Port | Schmale Abstraktion (ISignupNotifier) — Fachcode spricht Absicht, nicht Graph. |
| Folge / Side Effect | Mail senden, Kalender schreiben — oft nach der Entscheidung, nicht in derselben Methode. |
| Schein-Refactor | Code in private Methoden zerteilt, ohne Abhängigkeiten oder Änderungsgründe zu trennen. |
Muster 1: Die täuschend dünne Function (Anti-Pattern)
Was der Review sieht: wenig Code, klare Delegation.
Was fehlt: die Größe der Zielklasse und die Anzahl der Systeme in einem Aufruf.
// Anti-Pattern: Der Handler ist "sauber" — die Komplexität ist nur unsichtbar.
public class SignupUser
{
// Smell: konkrete God Class, kein schmaler Vertrag (ISignupService mit einer Methode).
private readonly SignupService _signupService;
[Function("SignupUser")]
public async Task<IActionResult> Run(
[HttpTrigger(AuthorizationLevel.Function, "post")] HttpRequest req,
FunctionContext frc,
string eventId)
{
var payload = await frc.GetPayLoad<SignupRequest>();
// Ein Aufruf — aber dahinter: Graph, Tables, Kalender, Mail, Blobs, Rechte …
await _signupService.CreateSignup(
payload.Signup,
eventId,
frc,
payload.UserId);
return new StatusCodeResult(201);
}
}
Warum das trügt: Metriken wie «Zeilen im Controller» oder «kein SQL im Handler» sind notwendig, aber nicht hinreichend. Die Gesamt-Kopplung des Features sitzt in CreateSignup. Jede neue Anforderung («ICS für Gäste», «Excel-Export», «zweite Warteliste») landet dort, weil der Name schon «Signup» heißt.
Besser (Richtung, nicht Pflicht): Handler kennt einen schmalen Vertrag; schwere Folgen laufen asynchron oder in separaten Schritten.
// Richtung: Vertrag beschreibt eine Fach-Aktion, nicht ein halbes Backend.
public class SignupUser
{
private readonly ISignupService _signupService;
[Function("SignupUser")]
public async Task<IActionResult> Run(..., CancellationToken cancellationToken)
{
var command = await MapToCommandAsync(req, cancellationToken);
await _signupService.SignUpAsync(command, cancellationToken);
return new StatusCodeResult(201);
}
}
Der Unterschied ist nicht «mehr Interfaces». Der Unterschied ist: SignUpAsync darf intern nicht 800 Zeilen bleiben.
Muster 2: Der Einkaufs-Konstruktor (Anti-Pattern)
Regel: Öffne die Klasse von oben. Zähle Felder. Mehr als zwei bis drei fremde Systeme → Alarm.
// Anti-Pattern: Sieben Welten in einer Klasse — viele konkret, nicht austauschbar.
public class SignupService : ISignupService
{
private readonly GraphApiService _graphApiService; // Verzeichnis / Identität
private readonly EmailService _emailService; // Mail + Orchestrierung + …
private readonly ICalendarService _calendarService; // Kalender-Produkt
private readonly StorageService _storageService; // Blobs, Anhänge
private readonly ExcelService _excelService; // Export
private readonly AdminService _adminService; // Admin-Konfiguration
private readonly PermissionsService _permissionsService; // Rechte
public SignupService(
GraphApiService graphApiService,
EmailService emailService,
ICalendarService calendarService,
StorageService storageService,
ExcelService excelService,
AdminService adminService,
PermissionsService permissionsService)
{
_graphApiService = graphApiService;
_emailService = emailService;
_calendarService = calendarService;
_storageService = storageService;
_excelService = excelService;
_adminService = adminService;
_permissionsService = permissionsService;
}
}
Was hier schief läuft (in Alltagssprache):
| Feld | Legitimer Grund, diese Datei zu öffnen? |
|---|---|
_permissionsService |
Nur wenn du Anmelde-Regeln änderst — selten. |
_graphApiService |
Nein — Graph-Upgrade ist Infrastruktur, nicht «Slot voll». |
_emailService |
Nein — Template-Änderung soll nicht Signup-Datei berühren. |
_excelService |
Nein — Export ist ein eigenes Feature. |
Single Responsibility heiß nicht «eine Methode». Es heiß: Ein Hauptgrund für Änderungen. Hier sind es sieben.
Merksatz: Der Klassenname ist ein Use Case (
Signup). Der Konstruktor ist ein Einkaufszentrum.
Muster 3: Die Mega-Methode (Anti-Pattern, verkürzt)
Echte CreateSignup-Methoden können hunderte Zeilen haben. Das Muster lässt sich in einem verkürzten Negativbeispiel zeigen — ohne jeden Zweig, aber mit der Struktur, die du in Reviews wiedererkennen sollst:
// Anti-Pattern: Eine Methode = komplette Pipeline. Fachlogik ist nur ein Block darin.
public async Task CreateSignup(SignupDto dto, string eventId, FunctionContext frc, string userId)
{
// --- Job 1: Verzeichnis (Graph) ---
User graphUser = await _graphApiService.GetUserById(userId, frc);
// --- Job 2: Persistenz (Table Storage direkt in der Fachmethode) ---
var headerClient = _storageService.GetTableClient<EventHeaderEntity>();
var slotClient = _storageService.GetTableClient<SlotEntity>();
var signupClient = _storageService.GetTableClient<SignupEntity>();
var header = await headerClient.GetAsync(eventId);
var slots = await slotClient.QueryAsync(...);
var existing = await signupClient.QueryAsync(...);
// --- Job 3: Fachregel (das Einzige, das hier hingehört — 5–15 Zeilen in der Realität) ---
if (slots.Count >= header.MaxParticipants && !dto.IsVip)
{
dto.Status = SignupStatus.WaitingList;
}
// --- Job 4: Kalender (ICS, Attachments, Graph-Events) ---
await _calendarService.DeleteOldEntriesAsync(eventId, userId);
var icsBytes = await BuildIcsForGuestAsync(dto, graphUser);
await _calendarService.CreateEventWithAttachmentsAsync(..., icsBytes);
await signupClient.UpsertAsync(MapToEntity(dto));
// --- Job 5: Mail (viele Zweige, eine fette Hilfsmethode) ---
if (dto.Status == SignupStatus.Rejected)
await _emailService.SendMailToRecipientType(..., template: "reject", ...);
else if (dto.Status == SignupStatus.WaitingList)
await _emailService.SendMailToRecipientType(..., template: "waitlist", ...);
else
await _emailService.SendMailToRecipientType(..., template: "confirm", ...);
// --- Job 6: Warteliste nachrücken ---
await PromoteWaitingListAsync(eventId, signupClient, slotClient);
}
Lesart für Nicht-Reviewer:
- Oben steht immer Infrastruktur (Graph, Tables).
- Mitte steht die eigentliche Entscheidung — leicht zu übersehen.
- Unten kommen Side Effects (Kalender, Mail), die bei Fehlern teilweise schon passiert sind → komplizierte Rollbacks.
Das ist kein Use Case. Das ist eine Batch-Pipeline hinter einem HTTP-POST.
Die Function blieb dünn, weil die God Class die Dicke übernommen hat. Thin controller, fat everything else. Das ist Verschieben, nicht Schichten.
Schein-Refactor: private Methoden (noch ein Anti-Pattern)
Teams «räumen auf», ohne Grenzen zu ziehen:
// Anti-Pattern: Lesbarer — aber dieselbe Klasse, dieselben sieben Abhängigkeiten.
public async Task CreateSignup(...)
{
var user = await LoadUserFromGraphAsync(userId, frc);
var context = await LoadTableContextAsync(eventId);
ApplyRegistrationRules(dto, context);
await SyncCalendarAsync(dto, user, context);
await SendAllMailsAsync(dto, user, context);
await PromoteWaitingListAsync(context);
}
private async Task SyncCalendarAsync(...) { /* immer noch Graph + Blob + Table */ }
private async Task SendAllMailsAsync(...) { /* ruft EmailService mit 12 Parametern */ }
Signal: Datei wird kürzer pro Methode, Konstruktor und Testaufwand bleiben gleich. Echte Trennung heißt: neuer Typ, neuer Port, eigener Test ohne Graph.
Kohäsion, Kopplung und Verantwortung — nicht ein Slogan
In älteren Fassungen dieses Textes stand sinngemäß: Kohäsion ist ein Änderungsgrund, kein Gefühl und die Frage Warum muss diese Datei heute auf? Das ist missverständlich — deshalb hier präziser.
Kohäsion und «ein Grund zum Ändern» (Single Responsibility) sind verwandt, aber nicht dasselbe:
| Kohäsion | Verantwortung / SRP | |
|---|---|---|
| Blickrichtung | Innen: Passen die Teile in der Klasse zusammen? | Außen: Wie viele unabhängige Anlässe (Tickets, Stakeholder) führen zu Änderungen an dieser Datei? |
| Typische Frage | «Gehören Graph-Aufruf und Wartelisten-Regel konzeptionell in denselben Typ?» | «Wenn nur das Mail-Template wechselt — muss dann SignupService mitgeändert werden?» |
| God Service | Oft niedrig: viele fachlich und technisch heterogene Schritte in einer Methode | Oft verletzt: Graph-Upgrade, Mail, Export und Anmelde-Regel treffen dieselbe Datei |
«Warum ist die Datei heute offen?» allein hilft wenig — jede Datei wird irgendwann geöffnet (Bugfix, Review, Refactor, Neugier). Sinnvoller im Pull Request oder beim Ticket:
- Was ist der Auslöser? (z. B. «Graph SDK 5.x», «Warteliste für VIPs», «neues Bestätigungs-Template»)
- Welche Abschnitte der Klasse müssen deshalb angefasst werden?
- Wenn die Antwort mehrere unabhängige Welten ist (Verzeichnis + Mail + Export), ist das Struktur — kein Bauchgefühl, sondern sichtbar am Konstruktor und an der Methode.
Review-Tabelle: Soll diese Klasse bei dem Anlass mitgehen?
| Geplante Änderung (Anlass) | Toucht SignupService heute? |
Sollte er es? |
|---|---|---|
| Graph-SDK Major | Ja | Nein |
| SMTP statt Graph-Mail | Ja | Nein |
| Neues Kalender-Produkt | Ja | Nein |
| Table-Entity um ein Feld | Ja | Nein |
| Excel-Export-Format | Ja | Nein |
| Anmelde-Regel: Slot voll | Ja | Ja |
| Wartelisten-Logik | Ja | Ja |
Zwei «Sollte ja» in der rechten Spalte, fünf «sollte nein». Der Klassenname passt zu den zwei; die anderen fünf Anlässe wurden in eine Datei gezogen, weil sie im Request zeitlich nacheinander laufen — das ist niedrige Kohäsion und zu viele Verantwortungen zugleich.
Entstehung (typischer Dialog):
- «Kalender gehört zur Anmeldung.» →
_calendarServicein den Konstruktor. - «Mail auch.» →
_emailService. - «Anhänge liegen im Blob.» →
_storageServicenoch tiefer in derselben Methode.
Nach zwei Jahren findest du die Regel «Slot voll» nicht mehr — sie liegt zwischen GetUserById und SendMailToRecipientType.
Muster 4: Der God Service mit Mail-Etikett
Der Nachbar heißt EmailService. Der Name klingt fokussiert. Der Konstruktor nicht:
// Anti-Pattern: "Email" im Namen — aber 80 % Persistenz, Queue und Verzeichnis.
public class EmailService
{
private readonly StorageService _storageService;
private readonly IMailService _mailService; // echter Transport (schmal)
private readonly GraphApiService _graphService;
private readonly QueueService _queueService;
private readonly JobLogService _jobLogService;
// Anti-Pattern: Öffentliche API mit Produkt- und Infrastruktur-Parametern vermischt.
public async Task SendMailToRecipientType(
string eventId,
RecipientType recipientType,
string templateKey,
string language,
bool attachIcs,
Stream icsStream,
bool isManual,
FunctionContext frc,
/* … weitere Parameter … */)
{
var header = await _storageService.GetTableClient<HeaderEntity>().GetAsync(eventId);
var signups = await _storageService.GetTableClient<SignupEntity>().QueryAsync(...);
var recipients = ResolveRecipientsFromGraph(header, signups, recipientType, _graphService);
var payloadBlob = await WritePayloadToBlobAsync(...);
await _queueService.EnqueueAsync(new MailJob { Blob = payloadBlob, ... });
await _jobLogService.WriteAsync(...);
// Erst ganz unten:
await _mailService.SendMailAsync(...);
}
}
Lesart:
- Transport =
IMailService.SendMailAsync(das darf «Mail» heißen). - Alles darüber = Orchestrierung, Verzeichnis, Persistenz — gehört nicht in eine Klasse, die so heißt, als würde sie nur senden.
Anti-Pattern Kreisverkehr: SignupService → EmailService → wieder Table/Graph → evtl. zurück zu Signup-Daten. Zwei God Classes, die sich kennen.
Muster 5: Interface-Telefonbuch (Anti-Pattern)
Ein Interface ohne Verkleinerung der Implementierung:
// Anti-Pattern: ISignupService spiegelt die ganze God Class — kein Schutz für Caller.
public interface ISignupService
{
Task CreateSignup(SignupDto dto, string eventId, FunctionContext frc, string userId);
Task DeclineAll(string eventId, FunctionContext frc);
Task DeleteSignups(string eventId, IEnumerable<string> userIds, FunctionContext frc);
Task<IReadOnlyList<SignupDto>> GetWaitinglist(string eventId);
Task<Stream> GetAttachments(string eventId, string userId);
Task<Stream> GetSignupsFile(string eventId);
// … weitere Methoden aus dem gleichen «Backend in einer Klasse» …
}
DI verstärkt das noch:
// Anti-Pattern: Interface registriert — Caller injizieren trotzdem die konkrete Klasse.
services.AddSingleton<ISignupService, SignupService>();
services.AddSingleton<SignupService>(); // für Functions, die "doch schnell" die Concrete wollen
Was ein schmaler Vertrag anders macht: Caller sehen eine Fachaktion; Implementierung darf intern orchestrieren — aber in eigenen Typen, nicht in einer Methode.
// Gegenbeispiel: Vertrag = eine Absicht.
public interface ISignupService
{
Task SignUpAsync(SignupCommand command, CancellationToken cancellationToken);
}
// Gegenbeispiel: Implementierung = Regel + Speichern — keine Graph-User-Typen in der Signatur.
public sealed class SignupService : ISignupService
{
private readonly ISignupRepository _signups;
private readonly IRegistrationPolicy _policy;
public async Task SignUpAsync(SignupCommand command, CancellationToken cancellationToken)
{
RegistrationDecision decision = await _policy.DecideAsync(command, cancellationToken);
await _signups.SaveAsync(decision, cancellationToken);
}
}
Mail und Kalender sind Folgen — auslösbar per Domain Event, Queue oder separater Handler nach erfolgreichem Save — nicht zwingend in derselben Transaktion wie die Regel.
Muster 6: Tests, die die God Class schützen (Anti-Pattern)
// Anti-Pattern: Test heißt "SignupService" — prüft aber nur Mock-Rückgaben, nicht die Regel.
[Fact]
public async Task CreateSignup_when_slot_full_sets_waiting_list()
{
var storage = new Mock<IStorageService>();
storage.Setup(s => s.GetTableClient<SlotEntity>())
.Returns(Mock.Of<TableClient>());
// … zehn weitere Setups für Graph, Mail, Kalender …
var sut = new SignupService(
graph: Mock.Of<GraphApiService>(),
email: Mock.Of<EmailService>(),
/* alle sieben Konstruktor-Parameter … */);
await sut.CreateSignup(dto, eventId, frc, userId);
storage.Verify(s => s.GetTableClient<SignupEntity>(), Times.AtLeastOnce);
// Assert sagt nichts über WaitingList — nur dass Table aufgerufen wurde.
}
Warum das passiert: Die Klasse lässt sich nicht mit kleinem Setup instanziieren. Teams mocken Infrastruktur, nicht Verhalten.
Gegenbeispiel (nach Policy-Extraktion):
[Fact]
public void Decide_when_slot_full_and_not_vip_returns_waiting_list()
{
var policy = new RegistrationPolicy();
var command = new SignupCommand { MaxSlots = 10, CurrentCount = 10, IsVip = false };
RegistrationDecision decision = policy.Decide(command);
Assert.Equal(RegistrationOutcome.WaitingList, decision.Outcome);
}
Kein Graph. Kein Table Client. Die Regel ist testbar — weil sie existiert.
Upgrade-Schaden (kurz, aber wichtig)
Die Function bleibt beim Isolated-Worker unberührt. Trotzdem muss bei Graph SDK Major die God Class mit — weil Microsoft.Graph.Models.User, Table Clients, Blob APIs und EmailService in derselben Datei verwoben sind. Der Reviewer sah nur die Function. Die Upgrade-Fläche lag eine Ebene tiefer.
Heilung ohne Big-Bang
Nicht «800 Zeilen in einem Sprint». Reihenfolge:
- Konstruktor lesen — jede konkrete Abhängigkeit ohne Fachbezug → Port-Kandidat.
- Fachregel extrahieren (
IRegistrationPolicy/RegistrationPolicy) — erste echte Unit-Tests. - Repository (
ISignupRepository) —GetTableClientverschwindet aus der Fachmethode. - Folgen hinter Ports —
ISignupNotifier,ICalendarScheduler— Signup ruft Absicht. - Mail-Orchestrierung vom Transport trennen — kein
SendMailToRecipientTypemit zwölf Parametern als öffentliche API. - Interface verkleinern — Telefonbuch streichen.
| Schritt | Ergebnis | Wenn übersprungen |
|---|---|---|
| Policy | Regeln testbar | Refactor ohne Netz |
| Repository | Table-Details gekapselt | Doppelte Queries |
| Ports | Mail/Kalender austauschbar | Umbenannter God Service |
| Schmales Interface | Klare Caller | Weiterhin Concrete Injection |
Persistenz-Anti-Pattern vs. Gegenbeispiel:
// Anti-Pattern: Partition Keys und Table-Namen in der Fachmethode.
await _storageService.GetTableClient<SignupEntity>().UpsertAsync(entity);
// Gegenbeispiel: Fachcode kennt nur die Domäne.
await _signups.SaveAsync(decision, cancellationToken);
Service ist ein Versprechen
Service heisst: Wer diese Klasse ändert, ändert eine Fähigkeit — nicht Graph und Mail und Excel, weil sie im selben Request vorkamen.
Man erkennt den God Service am Konstruktor, an der Mega-Methode (oder vielen private Helfern in derselben Klasse), am Interface-Telefonbuch und an Tests, die Infrastruktur statt Regeln prüfen.
Wenn unabhängige Ticket-Anlässe (Mail, Graph, Export, …) dieselbe Klasse treffen wie die Fachregel, ist es kein Service — egal wie dünn der Controller darüber glänzt.
Fazit
Gute HTTP-Einstiege sind wertvoll. Sie ersetzen keine Grenzen in der Domäne. Nimm dir für den nächsten *Service fünf Minuten: Konstruktor lesen, Mega-Methode skizzieren, Review-Tabelle mit einem echten Ticket-Anlass ausfüllen. Wenn «sollte nein» oft «ja heute» ist, zieh zuerst die Fachregel heraus — nicht «noch schnell» Mail in dieselbe Methode.
Negative Beispiele in diesem Text sind bewusst verdichtet (vereinfachte Signaturen, fiktive Typnamen). Sie sollen Muster trainieren, nicht eine konkrete Codebase 1:1 abbilden.