Der God Service ist kein Service

Programming PrinciplesSoftware Architecture

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

  1. Ein Service sollte einen Hauptgrund haben, sich zu ändern — nicht sieben fremde (Graph, Mail, Kalender, …).
  2. Dünner HTTP-Einstieg + dicke Service-Methode ist kein Layering, sondern Verschieben der Komplexität.
  3. Negative Muster erkennen: vollgestopfter Konstruktor, Mega-Methode, private Helpers statt echter Grenzen, Interface-Telefonbuch, Tests die nur Mocks asserten.
  4. 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:

  1. Oben steht immer Infrastruktur (Graph, Tables).
  2. Mitte steht die eigentliche Entscheidung — leicht zu übersehen.
  3. 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:

  1. Was ist der Auslöser? (z. B. «Graph SDK 5.x», «Warteliste für VIPs», «neues Bestätigungs-Template»)
  2. Welche Abschnitte der Klasse müssen deshalb angefasst werden?
  3. 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.» → _calendarService in den Konstruktor.
  • «Mail auch.» → _emailService.
  • «Anhänge liegen im Blob.» → _storageService noch 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:

  1. Konstruktor lesen — jede konkrete Abhängigkeit ohne Fachbezug → Port-Kandidat.
  2. Fachregel extrahieren (IRegistrationPolicy / RegistrationPolicy) — erste echte Unit-Tests.
  3. Repository (ISignupRepository) — GetTableClient verschwindet aus der Fachmethode.
  4. Folgen hinter Ports — ISignupNotifier, ICalendarScheduler — Signup ruft Absicht.
  5. Mail-Orchestrierung vom Transport trennen — kein SendMailToRecipientType mit zwölf Parametern als öffentliche API.
  6. 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.

Mehr zu diesem Thema