29.08.2026 · Fabricio Ruch

Der God Service ist kein Service

Computer ScienceProgramming PrinciplesQuality AssuranceSoftware ArchitectureSoftware Engineering

Die Function ist dünn. Der Reviewer nickt. Dann öffnet man die Klasse, an die sie delegiert.

public class SignupUser
{
    private readonly SignupService _signupService;

    [Function("SignupUser")]
    public async Task<IActionResult> Run(...)
    {
        var payload = await frc.GetPayLoad<SignupRequest>();
        await _signupService.CreateSignup(payload.Signup, eventId, frc, payload.UserId);
        return new StatusCodeResult(201);
    }
}

Das sieht nach Schicht aus. Der Einstieg kennt weder Tabelle noch Graph. Genau das habe ich an anderer Stelle gelobt.

Der Fehler sitzt eine Datei tiefer. Nicht im Controller. Im Ding, das Service heisst.


Der Konstruktor ist die Diagnose

public class SignupService : ISignupService
{
    private readonly GraphApiService _graphApiService;
    private readonly EmailService _emailService;
    private readonly ICalendarService _calendarService;
    private readonly StorageService _storageService;
    private readonly ExcelService _excelService;
    private readonly AdminService _adminService;
    private readonly PermissionsService _permissionsService;
}

Sieben Abhängigkeiten. Davon mehrere konkret. Persistenz, Verzeichnis, Kalender, Mail, Export, Rechte, Vorlagen.

Das ist kein Service. Das ist ein Betriebssystem mit Methodennamen.

Ein Service hat einen Grund, sich zu ändern. Diese Klasse hat sieben. Ändert sich Graph, muss sie mit. Ändert sich das Mail-Template, muss sie mit. Ändert sich das Kalender-SDK, muss sie mit. Ändert sich die Table-Entity, muss sie mit. Ändert sich die Excel-Library, muss sie mit.

Hohe Kopplung, niedrige Kohäsion. Der Name SignupService beschreibt nicht, was die Klasse ist. Er beschreibt nur, über welchen Use Case sie irgendwann gewachsen ist.


Eine Methode, fünf Jobs

CreateSignup ist der eigentliche Beweis. Nicht die Zeilenzahl. Die Jobs, die nacheinander in derselben Methode sterben.

Zuerst Verzeichnis:

User user = await _graphApiService.GetUserById(userId, frc);

Dann Persistenz. Mehrfach. Header, Slots, bestehende Anmeldungen, Einladungen, Warteliste — alles über GetTableClient<T>() in derselben Methode.

Dann die Fachregel, die hier tatsächlich hingehört: Ist der Slot voll? Kommt die Person auf die Warteliste? Darf sie sich anmelden?

Dann Kalender. Einträge anlegen, alte löschen, Attachments aus Blob ziehen, für Gäste ICS bauen, Graph-Attachments zurück in die Tabelle schreiben.

Dann Mail. Ablehnung, Warteliste, Bestätigung intern, Bestätigung extern, Umbuchung, Benachrichtigung an die Organisatoren. Jeder Pfad holt Templates, filtert Sprachen, trennt Gast und Mitarbeiter, ruft dieselbe fette SendMailToRecipientType-Methode.

Am Ende noch: Warteliste nachrücken.

Das ist kein Use Case. Das ist eine Pipeline aus fünf Systemen, die zufällig hinter einem HTTP-Verb steht.

Die Function durfte dünn bleiben, weil die God Class die Dicke übernommen hat. Thin controller, fat everything else. Das ist keine Schicht. Das ist Verschieben.


Kohäsion ist ein Änderungsgrund, kein Gefühl

Man merkt niedrige Kohäsion nicht am Bauch. Man merkt sie an der Frage: Warum muss diese Datei heute auf?

ÄnderungMuss der Signup-Service mit?Müsste er mit, wenn er ein Service wäre?
Graph-SDK MajorJaNein
SMTP statt Graph-MailJaNein
Neues Kalender-ProduktJaNein
Table-Entity um ein FeldJaNein
Excel-Export-FormatJaNein
Anmelde-Regel: Slot vollJaJa
Wartelisten-LogikJaJa

Zwei Ja in der rechten Spalte. Fünf in der linken. Die Klasse verdient den Namen für die zwei. Die anderen fünf hat sie geschluckt, weil sie in der Nähe lagen.

Das ist der Entstehungsmechanismus. Nicht Böswilligkeit. Bequemlichkeit.

Der Kalender gehört „irgendwie zur Anmeldung“. Mail auch. Rechte auch. Also kommt _calendarService in den Konstruktor. Dann _emailService. Dann noch schnell der Blob für die Anhänge. Nach zwei Jahren ist die Anmelde-Regel nicht mehr findbar. Sie sitzt zwischen GetUserById und SendMailToRecipientType.


Der Nachbar ist derselbe Fehler mit anderem Etikett

Neben dem Signup-Service steht oft eine EmailService ohne Interface. Der Name klingt fokussiert. Der Konstruktor nicht:

public class EmailService
{
    private readonly StorageService _storageService;
    private readonly IMailService _mailService;
    private readonly GraphApiService _graphService;
    private readonly QueueService _queueService;
    private readonly JobLogService _jobLogService;
}

SendMailToRecipientType liest Header, Einladungen, Anmeldungen, letzte Mails. Baut Empfängerlisten. Schreibt Payloads in Blob. Enqueued Jobs. Schreibt Job-Logs. Und irgendwo ganz unten ruft sie den schmalen Transport IMailService.SendMailAsync.

Der Transport ist die einzige echte Fähigkeit. Alles davor ist Orchestrierung, Persistenz und Verzeichnis — in einer Klasse, die Email heisst, weil der letzte Schritt eine Mail ist.

Caller hängen an der konkreten Klasse. Es gibt kein IMailOrchestrator. Es gibt acht Stellen, die SendMailToRecipientType kennen, inklusive der Parameter für ICS, Sprachen und isManual.

Zwei God Services, die sich gegenseitig rufen. Das ist keine Architektur. Das ist ein Kreisverkehr.


Ein Interface macht ihn nicht schmaler

SignupService : ISignupService existiert. In der DI wird SignupService registriert. Die Function injiziert SignupService. Das Interface ist die Inhaltsangabe einer zu dicken Klasse.

Dasselbe gilt für zwanzig Methoden auf dem Interface. CreateSignup, DeclineAll, DeleteSignups, GetWaitinglist, GetAttachments, GetSignupsFile. Ein Interface mit der Fläche eines Backends ist kein Vertrag. Es ist ein Telefonbuch.

Ein schmaler Service hätte ungefähr das:

public interface ISignupService
{
    Task SignUpAsync(SignupCommand command, CancellationToken cancellationToken);
}

Kalender, Mail, Warteliste, Export sind dann Ports, die dieser Service ruft — oder, noch besser, Dinge, die nach der Anmeldung passieren, nicht in ihr.

public sealed class SignupService : ISignupService
{
    private readonly ISignupRepository _signups;
    private readonly IRegistrationPolicy _policy;

    public async Task SignUpAsync(SignupCommand command, CancellationToken cancellationToken)
    {
        var decision = await _policy.DecideAsync(command, cancellationToken);
        await _signups.SaveAsync(decision, cancellationToken);
    }
}

Mail und Kalender gehören nicht in dieselbe Transaktion, nur weil sie im selben User-Klick vorkommen. Sie sind Folgen. Folgen kann man auslösen, ohne sie zu sein.


Warum das Upgrade und der Test dort sterben

Im vorigen Stück ging es um den Controller, der Graph kennt. Hier kennt ihn der Service. Für das Upgrade ist das derselbe Schaden. Nur besser versteckt.

Die Function bleibt beim Isolated-Worker-Schnitt unberührt. Der Reviewer sieht dünne Handler. Dann muss CreateSignup trotzdem mit, weil sie Microsoft.Graph.Models.User hält, Table Clients direkt anspricht, Blob-Container öffnet und eine konkrete EmailService ruft, die selbst wieder Graph und Queue kennt.

Ein Test für die Anmelde-Regel braucht in diesem Schnitt:

  • einen Graph-User
  • vier Table Clients
  • Kalender
  • Mail-Templates
  • Blob-Streams
  • Permissions

Deshalb entstehen Tests, die IStorageService mocken und dann nur noch selbst gebaute Testdaten asserten. Die Regel wird nie ausgeführt. Die Pipeline ist grün. Die God Class ist ungetestet.

Das ist keine Testschwäche. Das ist die Klasse, die sich nicht isolieren lässt.


Was man schneidet, ohne das System neu zu bauen

Nicht die 800 Zeilen in einem Sprint zerlegen. Die Reihenfolge entscheidet, ob der Schnitt hält.

Zuerst den Konstruktor lesen.
Jede konkrete Abhängigkeit, die nicht die Anmelde-Regel ist, ist ein Kandidat für einen Port. Graph, Mail, Kalender, Excel, Storage.

Dann die Methode in Jobs zerlegen, nicht in private Helpers.
private BuildIcsFiles in derselben Klasse ist keine Trennung. Es ist Einrücken. Ein Job wird eine Klasse, wenn er einen eigenen Änderungsgrund hat.

Die Fachregel zuerst herausziehen.
Slot voll, Warteliste, Einladung nötig. Das ist der einzige Teil, der SignupService heissen darf. Er braucht Daten und eine Entscheidung. Kein SDK.

Folgen hinter Ports legen.
ICalendarScheduler.Schedule(signup). ISignupNotifier.Notify(signup). Der Signup-Service ruft Absicht, nicht Graph-Attachments.

Persistenz hinter einen Data Service.
Nicht GetTableClient<SignupEntity>() in der Fachmethode. ISignupRepository.Save(signup). Partition Keys sterben eine Schicht tiefer.

Mail nicht als God Service stehen lassen.
Orchestrierung darf existieren. Sie braucht einen Vertrag. Und sie darf nicht selbst Tabelle und Graph sein. Empfänger auflösen ist Verzeichnis. Senden ist Transport. Beides ist nicht SendMailToRecipientType.

Der Controller bleibt dünn. Der Service wird es zum ersten Mal.


Service ist ein Versprechen

Service heisst: Hier endet eine Verantwortung. Wer diese Klasse ändert, ändert eine Fähigkeit. Nicht das halbe System.

Ein God Service bricht das Versprechen. Er sammelt alles, was in einem Request vorkommt, und nennt die Summe nach dem Request. Signup. Email. Event. Document. Die Function darf dann dünn aussehen. Die Abhängigkeit ist nur umgezogen.

Man erkennt ihn nicht an der Zeilenzahl. Man erkennt ihn am Konstruktor und an der Frage, warum die Datei heute aufmuss.

Wenn die Antwort mehr als einen Lieferanten und eine Fachregel enthält, ist es kein Service. Es ist die Stelle, an der die nächste Änderung teuer wird — unabhängig davon, wie sauber der Controller darüber sitzt.