Solange der Controller Graph kennt, ist das Upgrade ein Rewrite

Programming PrinciplesSoftware Architecture

Warum eine Abstraktionsschicht um eine konkrete API noch keine lose Kopplung ist — und warum Schichten entscheiden, was ein Runtime-Upgrade wirklich kostet.

Es gibt eine besonders gefährliche Form von Architektur, weil sie sich richtig anfühlt.

Man hängt nicht mehr direkt am Microsoft Graph SDK. Man hat einen GraphApiService. Mail läuft darüber. User-Lookup auch. Einladungen, Gruppen, Kalender. Alles an einer Stelle. Das Team sagt: Wir sind nicht mehr von Graph abhängig. Wir haben einen Wrapper.

Das ist der Moment, in dem die eigentliche Kopplung erst richtig entsteht.

Denn der Caller hängt jetzt nicht mehr am SDK. Er hängt am Graph-Service. Und der Graph-Service ist keine Fähigkeit. Er ist eine konkrete Implementierung mit einem konkreten Lieferanten, konkreten Typen und einer konkreten Semantik. Wenn die Anforderung später heisst, über SMTP zu senden, ändert sich nicht der Wrapper. Es ändert sich der ganze Code, der den Wrapper kennt.

Genau das ist der Unterschied zwischen einer Abstraktionsschicht und einem Interface.


Abhängigkeit ist eine Frage der Sprache, nicht der Datei

Lose Kopplung bedeutet nicht, dass irgendwo ein Service existiert. Es bedeutet, dass der Caller in der Sprache seiner eigenen Absicht spricht.

Ein Use Case, der eine Bestätigung verschicken will, braucht kein Graph. Er braucht Mail.

public interface IMailSender
{
    Task SendAsync(MailMessage message, CancellationToken cancellationToken);
}

public sealed record MailMessage(
    string Subject,
    string Body,
    IReadOnlyList<string> Recipients,
    string? SenderId = null);

Das ist klein. Absichtlich. Es kennt keinen SendMailPostRequestBody. Keinen GraphServiceClient. Keinen Tenant. Keinen Client Secret. Es kennt nur das, was der Use Case tatsächlich will: eine Nachricht an Empfänger.

Die Graph-Implementierung sitzt dahinter.

public sealed class GraphMailSender : IMailSender
{
    private readonly GraphApiService _graphApiService;

    public GraphMailSender(GraphApiService graphApiService)
    {
        _graphApiService = graphApiService;
    }

    public Task SendAsync(MailMessage message, CancellationToken cancellationToken)
    {
        var request = MapToGraphRequest(message);
        return _graphApiService.SendMail(request, message.SenderId);
    }
}

Und die SMTP-Variante auch.

public sealed class SmtpMailSender : IMailSender
{
    public Task SendAsync(MailMessage message, CancellationToken cancellationToken)
    {
        // SMTP. Kein Graph-Typ. Kein Graph-Caller.
    }
}

Der Wechsel sitzt in der Composition Root. Nicht in den Features.

services.AddSingleton<IMailSender>(sp =>
    settings.MailTransport == MailTransport.Smtp
        ? new SmtpMailSender(...)
        : new GraphMailSender(...));

Jeder Use Case, der IMailSender injiziert, merkt von diesem Wechsel nichts. Das ist der Punkt.


Warum der Graph-Service als einzige Schicht nicht reicht

Der typische Fehler sieht sauberer aus als GraphServiceClient im Controller. Deshalb überlebt er länger.

public sealed class SignupService
{
    private readonly GraphApiService _graphApiService;

    public SignupService(GraphApiService graphApiService)
    {
        _graphApiService = graphApiService;
    }

    public async Task ConfirmAsync(string userId)
    {
        var user = await _graphApiService.GetUserById(userId);
        await _graphApiService.SendMail(BuildGraphMail(user), user.Id);
    }
}

Das ist bereits eine Abstraktion. Das SDK ist nicht mehr überall. Bei einem Graph-SDK-Umbau ändert man zuerst GraphApiService. Das ist ein echter Gewinn. Ich komme später darauf zurück, warum dieser Service trotzdem unverzichtbar ist.

Aber für den Use Case ist GraphApiService immer noch die konkrete Welt.

Er spricht Graph. Er gibt Graph-Typen zurück. Er vermischt Directory, Mail, Einladungen und oft noch Kalender in einer Klasse. Jeder Caller, der ihn injiziert, denkt in Graph-Begriffen. Und sobald die Anforderung wechselt — SMTP statt Graph, anderer Mail-Provider, Mail nur noch über eine Queue — müssen alle diese Caller mit.

Das ist keine lose Kopplung. Das ist Kopplung mit einem freundlicheren Namen.

Die richtige Grenze sitzt eine Schicht höher und ist schmaler:

Was der Caller brauchtFalsches InterfaceRichtiges Interface
Eine Mail sendenIGraphApiService / GraphMailServiceIMailSender
Eine Person findenIGraphApiServiceIUserDirectory
Empfänger auflösenIGraphApiServiceIRecipientResolver
Eine Entität speichernIStorageService / TableClientIOrderDataService

Der Graph-Service darf existieren. Er darf sogar dick sein. Er darf SDK-Details, Retry, Caching und Mapping kennen. Aber er ist Infrastruktur. Use Cases dürfen ihn nicht sehen.

Wenn das Interface SendMailPostRequestBody oder Microsoft.Graph.Models.User in der Signatur trägt, ist es kein Anwendungsinterface. Es ist ein durchgereichter SDK-Vertrag. Dann ändert sich bei einem Providerwechsel nicht nur die Implementierung. Es ändert sich die Sprache der gesamten Anwendung.


Interfaces, die Azure oder Graph leaken, sind keine Interfaces

Es gibt eine zweite, stillere Variante desselben Fehlers.

Man führt ein Interface ein — und leakt trotzdem die Infrastruktur.

public interface IStorageService
{
    TypedTableClient<T> GetTableClient<T>();
    BlobContainerClient GetBlobContainer(string name);
}

Das kompiliert. Es lässt sich injizieren. In Reviews sieht es nach Architektur aus.

In Wirklichkeit hat man Azure Table und Blob zum öffentlichen Vertrag der Anwendung gemacht. Jeder Caller kennt Partition Keys, Table Queries und Storage-Entities. Ein Mock muss weiterhin Table sprechen. Ein Wechsel der Persistenz ist unmöglich, ohne die Caller zu öffnen. Das Interface hat die Abhängigkeit nicht entfernt. Es hat sie zertifiziert.

Dasselbe gilt für Graph:

public interface IGraphApiService
{
    Task<User> GetUserById(string id);
    Task SendMail(SendMailPostRequestBody body, string senderId);
}

Und für Blob, nachdem man „endlich ein Interface“ hat:

public interface IBlobStorageService
{
    Task<BlockBlobClient> UploadFileAsync(string containerName, string blobName, Stream content);
    Uri GenerateSasUri(BlockBlobClient blobClient, ...);
}

User kommt aus dem SDK. SendMailPostRequestBody auch. BlockBlobClient ebenfalls. Wer dieses Interface nutzt, ist an den Lieferanten gebunden — nur ohne den Client direkt zu konstruieren.

Ein Interface ist erst dann eine Abstraktion, wenn der Caller ohne das darunterliegende Produkt denken kann.

Interfaces, die niemand injiziert, zählen erst recht nicht. Eine Codebasis kann dreizehn Interface-Dateien haben und in der DI trotzdem durchgängig Konkrettypen registrieren. Dann sind die Interfaces Dokumentation. Die Architektur entscheidet sich in den Konstruktoren.


Schichten sind kein Ordner. Sie sind eine Abhängigkeitsrichtung.

Lose Kopplung allein reicht nicht. Man braucht auch Kohäsion. Und man braucht Schichten, die beides erzwingen.

Lose Kopplung heisst: Eine Änderung bleibt lokal. Der Controller kennt die Tabelle nicht. Der Use Case kennt Graph nicht. Mail kennt Azure Table nicht.

Hohe Kohäsion heisst: Eine Klasse hat einen Grund, sich zu ändern. Der Mail-Transport ändert sich, wenn sich das Senden ändert. Nicht, wenn sich das Signup-Regelwerk ändert. Der Data Service ändert sich, wenn sich die Persistenz ändert. Nicht, wenn sich das HTTP-Routing ändert.

Eine tragfähige Backend-Struktur sieht unspektakulär aus:

Functions / Controller
    → spricht Use Cases, nicht Storage
App Services
    → spricht Domain + Ports
      IMailSender, IUserDirectory, IOrderDataService
Data Services
    → spricht Storage, nicht HTTP
Infrastructure
    → Graph, SMTP, Table, Blob, Queue

Die Richtung ist das eigentliche Design. Unten kennt oben nicht. Oben kennt unten nur über schmale Verträge.

Was dabei entsteht, ist nicht Eleganz. Es ist Austauschbarkeit unter Druck.


Drei Systeme, drei Rechnungen

Ich habe dasselbe Problem in drei Systemen gesehen. Nicht als Diagramm. Als Upgrade.

Das übernommene System ohne Schichten

Ich habe dieses System nicht gebaut. Ich habe es übernommen.

Der HTTP- oder Queue-Handler holt sich den Table Client. Dann den Graph-Service. Dann schreibt er eine Entity. Dann lädt er einen User. Dann löscht er einen anderen Eintrag. Dann schickt er eine Mail. Alles in einer Methode. Alles konkret.

public sealed class ExternalInviteFunction
{
    private readonly StorageService _storage;
    private readonly GraphApiService _graph;
    private readonly EmailService _mail;

    [Function("ProcessInvite")]
    public async Task Run(InviteJob job)
    {
        var userId = await _graph.SendInvite(job.ToGraphInvitation());
        await _storage.GetTableClient<InviteEntity>()
            .DeleteAsync(job.Email, job.EventId);
        await _storage.GetTableClient<InviteEntity>()
            .InsertAsync(new InviteEntity(userId, job.EventId));
        await _mail.SendToRecipientType("individual_users", job.EventId, userId);
    }
}

Das ist kein Use Case. Das ist ein Integrationsskript hinter einem Function-Trigger.

Interfaces existieren auf der Festplatte. In der DI werden sie nicht registriert. Die Services werden als Konkrettyp injiziert. Manche werden mit new gebaut. IStorageService gibt den Table Client zurück. IGraphApiService deckt nur einen Teil der tatsächlich genutzten Methoden ab — wer den Rest braucht, muss die konkrete Klasse nehmen. Storage-Entities rufen selbst Graph auf.

Dann wächst ein God Service. Signup, Persistenz, Directory, Kalender, Blob und Mail in einer Klasse. Eine Methode, die alles kann. Hohe Kopplung, niedrige Kohäsion.

Ein Sprung auf Isolated Worker und eine neue Runtime ist in so einem System kein Upgrade. Es ist Archäologie. Nicht weil .NET so schwer ist. Sondern weil die Änderung keine Grenze findet, an der sie stoppen könnte. Jeder Handler, der selbst TableClient anspricht, muss mit. Jede Entity, die Infrastruktur kennt, muss mit. Statische Caches, Environment-Variablen mitten in der Methode, Graph-Typen in Signaturen — alles kommt gleichzeitig auf den Tisch.

Das selbst gebaute System mit drei Schichten

Das Gegenstück haben wir selbst so geschnitten.

Functions parsen den Request, rufen einen App Service und geben das Resultat zurück. App Services kennen Domain-Modelle und Ports. Sie importieren weder Table noch Graph SDK. Persistenz sitzt in Data Services. Graph sitzt in einem Service, den die Fachlogik nicht injiziert. Mail hat einen Transport-Port: Graph oder SMTP, gewählt in der Composition Root.

Als dasselbe Runtime-Thema kam — Isolated Worker, neue .NET-Version — mussten im Wesentlichen die Data Services und das Host-Setup angepasst werden. Die Fachlogik hat den Wechsel nicht einmal importiert. Die Consumer haben nichts davon mitbekommen.

Das ist der Test, ob eine Schicht wirklich eine Schicht ist.

Das übernommene System, das ohne Refactor nicht upgraden konnte

Es gibt noch die dritte Variante. Wieder übernommen, nicht entworfen. Klein von der Dateizahl. Trotzdem eine God-Class: dreizehn HTTP-Endpunkte plus Blob-Cleanup in einer Datei. Null Interfaces. Der Einstieg sprach den Legacy-Proxy direkt an, baute Auth-Token, setzte Cookies, schrieb Blob über In-Process-Bindings in der Methodensignatur. Ein Timer-Handler konstruierte den Storage-Client im parameterlosen Konstruktor, ausserhalb von DI.

Es gab einen GraphApiService. Er war registriert und in die God-Class injiziert. Keine Methode, kein Aufruf. Ein Service, den niemand ruft, schützt niemanden.

Isolated Worker und der Sprung von .NET 6 auf .NET 8 waren hier kein Versionsbump. Sie waren ein anderes Programmiermodell.

[FunctionName] wird zu [Function]. FunctionsStartup wird zu Program.cs und HostBuilder. Die Class Library wird zur Executable. Blob-Bindings in der Signatur fallen weg. ClaimsPrincipal ist nicht mehr einfach da. Die Utility-Library für den Request-Kontext ändert die Signatur.

Ohne Schicht heisst das: jeder Endpunkt muss gleichzeitig umgeschrieben werden. Nicht weil sich die Fachregel ändert. Weil der Einstieg die Infrastruktur ist.

Also kam beides zusammen. Zuerst die Nähte, dann das Modell. Functions wurden dünn und hängen nur noch an Interfaces. Die Interfaces werden registriert und benutzt. Fehlerbehandlung sitzt in einer Basis, nicht in dreizehn Copy-Paste-Blöcken. Settings hören auf, Environment.GetEnvironmentVariable mitten in der Methode zu sein.

Danach wäre das nächste Runtime-Upgrade lokal: csproj, Worker-Pakete, Pipeline, Composition Root. Die HTTP-Handler müssten nicht noch einmal lernen, was ein Blob ist.

Der Legacy-Client sitzt trotzdem noch konkret in den App Services. Blob hat jetzt ein Interface — und gibt Azure-Typen zurück. Andere Services umgehen es und machen wieder new BlobContainerClient(...). Rückgaben sind oft dynamic.

Zwei Schichten reichen, um das Hosting zu wechseln. Sie reichen nicht, um den Lieferanten zu wechseln. Hosting-Grenze und Fähigkeitsgrenze sind zwei verschiedene Schnitte.


Was ein Runtime-Upgrade wirklich kostet

Azure Functions von In-Process auf Isolated Worker. .NET 8 auf eine neuere Runtime. Neue Worker-Pakete. Andere Startup-Pipeline. Andere Bindings. Andere SDK-Oberflächen bei Table, Blob, Graph.

Die Kosten hängen nicht von der Versionsnummer ab. Sie hängen davon ab, wen der Controller kennt.

Wen injiziert der Controller?
    Storage + Graph + konkrete Mail-Orchestrierung
        → Upgrade trifft alles

    IDocumentService / ISignupService
        → Upgrade trifft Host und Adapter

Was steht in den Signaturen der App Services?
    Graph-Typen, Table Clients, SendMailPostRequestBody
        → die Abstraktion hat nicht gehalten

    Domain-Modelle und IMailSender
        → die Abstraktion hat gehalten

Was musste beim letzten Infrastrukturwechsel angefasst werden?
    Überall
        → Schichten waren Dekoration

    Data Services und Host
        → Schichten haben ihre Arbeit getan

Im übernommenen System ohne Schichten bleibt jeder SDK-Bump ein Flächenbrand — auch dann, wenn das Hosting-Modell längst isoliert ist. Die Schmerzen kommen nicht vom Worker. Sie kommen von 800-Zeilen-Services, die Graph, Tabelle, Blob und Mail in einem Atemzug kennen.

Im selbst gebauten System mit Data Services bleibt der Bump dort, wo Infrastruktur hingehört.

Im dritten System musste man die Schichten nachziehen, damit das Upgrade überhaupt kompilierbar wurde. Die Diff-Statistik war keine Feature-Arbeit. Es war die Rechnung für fehlende Grenzen, bezahlt in einem Zug.


Warum es trotzdem Services braucht

Hier liegt der zweite Fehler, den man aus der ersten Erkenntnis leicht ableitet.

Wenn der Use Case nur IMailSender kennen soll, braucht man dann überhaupt noch einen Graph-Service?

Ja. Unbedingt.

Eine schmale Port-Schnittstelle schützt die Caller. Sie schützt nicht vor der Komplexität des Lieferanten.

Microsoft Graph ist kein SendMail. Es ist Authentifizierung, Pagination, Partial Responses, Beta-Endpunkte, geänderte Modelle, geänderte Client-Konstruktoren, geänderte Fehlerflächen. Diese API ist schon einmal ziemlich heftig überarbeitet worden. Wer das SDK in zwanzig Klassen verteilt hat, muss zwanzig Klassen mitziehen. Wer die Funktionalität hinter einem Service bereitstellt, ändert den Service.

Das ist der Wert von GraphApiService.

Nicht als Abhängigkeit der Fachlogik.
Als einzige Stelle, die das Produkt Graph wirklich kennt.

Dasselbe gilt für Persistenz. Ein Data Service ist kein Repository-Theater. Er ist die Stelle, an der Table-Queries, Partition Keys und SDK-Breaking-Changes sterben dürfen, ohne die Anwendung mitzunehmen.

Ein Service, der nur injiziert wird und nie eine Methode hat, ist das Gegenteil davon. Er erzeugt die Illusion von Vorsorge. Beim Upgrade muss man ihn trotzdem mitschleppen oder feststellen, dass er nie Teil des Systems war.

Die Architektur braucht also beides:

  1. Einen Service, der die konkrete Technologie trägt.
  2. Ein schmales Interface, das die Fähigkeit nach oben gibt.

Nur 1 ist der Graph-Wrapper, von dem alle abhängen.
Nur 2 ist ein Interface ohne Ort, an dem Graph-Chaos landen kann.
Beides zusammen ist die Schicht.

SignupService
    hängt an IMailSender
        hängt an GraphMailSender
            hängt an GraphApiService
                hängt am Graph SDK

Wenn Graph sich ändert, endet die Änderung in GraphApiService.
Wenn der Transport sich ändert, endet die Änderung in der DI-Registrierung von IMailSender.
Wenn die Signup-Regel sich ändert, endet die Änderung in SignupService.

Das ist hohe Kohäsion. Jede Schicht hat ihren eigenen Änderungsgrund.


Was man konkret anders macht

Nicht alles auf einmal. Die Reihenfolge ist wichtiger als die Reinheit.

Zuerst die Fähigkeit, nicht das Produkt.
Mail bekommt IMailSender. Verzeichnis bekommt IUserDirectory. Persistenz bekommt Data Services pro Entität, nicht einen generischen GetTableClient<T>().

Dann den Service hinter das Interface stellen.
GraphApiService bleibt. Er wird nur nicht mehr von Features injiziert. Mail, Directory und Data Services dürfen ihn kennen. Controller und Use Cases nicht.

Dann die Functions dünn machen.
Request, Auth-Kontext, Delegation, Response. Kein Table-Zugriff im Trigger. Kein Graph-Lookup im Trigger. Kein new Service(...) im Handler.

Dann die DI ehrlich machen.
Registrieren, was die Konstruktoren tatsächlich brauchen. Interface auf Interface. Wenn ein Interface unvollständig ist und Caller deshalb die konkrete Klasse nehmen, ist das Interface falsch — nicht der Caller.

Nicht jedes Interface erfinden.
Interfaces gehören an volatile Grenzen: Persistenz, Mail, Verzeichnis, Dateisystem, externe APIs, Stellen, die Tests isolieren müssen. Eine interne Hilfsklasse braucht kein Interface. Ein Lieferant, der sich alle zwei Jahre neu erfindet, schon.


Die eigentliche Entscheidung

Abhängigkeit von konkreten Implementierungen ist bequem, solange sich nichts ändert. Eine Graph-API-Schicht fühlt sich an wie Vorsorge. Sie ist Vorsorge — aber nur für das SDK, nicht für die Fähigkeit.

Systeme leben länger als der aktuelle Mail-Weg. Länger als das aktuelle Functions-Hosting. Länger als die aktuelle Graph-Major-Version. Länger als die aktuelle Runtime.

Wenn die Fachlogik Graph spricht, muss sie jedes Mal mit.
Wenn sie Mail spricht, nicht.

Wenn der Controller die Datenbank kennt, wird aus einem Runtime-Upgrade ein Rewrite.
Wenn nur die Data Services die Datenbank kennen, bleibt das Upgrade dort, wo es hingehört.

Wenn man ein System ohne Nähte übernimmt, zahlt man die Architektur trotzdem — auch dann, wenn man sie nicht selbst entschieden hat. Manchmal muss man die Schichten nachziehen, nur um das Hosting wechseln zu können. Das ist kein Schönheitsrefactor. Das ist die Voraussetzung, das System weiter betreiben zu können.

Ein Wrapper um eine konkrete API ist ein Anfang.
Ein schmales Interface für die Fähigkeit ist die Grenze.
Schichten mit loser Kopplung und hoher Kohäsion sind der Grund, warum beides später noch gilt.

Mehr zu diesem Thema