Ein Interface, das TableClient zurückgibt, ist Table

Das Interface existiert. Es ist injiziert. Der Reviewer sieht ein I und geht weiter.
public interface IStorageService
{
TypedTableClient<T> GetTableClient<T>();
BlobContainerClient GetBlobContainer(string containerName);
Task ResetDatabase(IFunctionRunContext frc);
}
Das kompiliert. Es lässt sich mocken. In der Architekturfolie steht Persistence hinter Interface.
Der Caller schreibt trotzdem:
await _storage.GetTableClient<SignupEntity>().InsertOrReplaceAsync(userId, eventId, entity);
await _storage.GetTableClient<WaitingListEntity>().GetEntitiesAsync(eventId);
Er kennt Partition Keys. Er kennt Entities. Er kennt die Table-API. Er kennt Blob-Container. Das I hat die Abhängigkeit nicht entfernt. Es hat sie unterschrieben.
Ein Interface, das TableClient zurückgibt, ist Table. Nur mit mehr Dateien.
Abstraktion ist die Sprache des Callers
Ein Interface ist kein Ordner und kein Präfix. Es ist die Frage, in welcher Sprache der Caller denken muss.
Wenn der Caller Table denken muss, ist das Interface Table. Egal wie es heisst.
| Vertrag | Sprache des Callers | Austauschbar gegen |
|---|---|---|
TypedTableClient<T> GetTableClient<T>() | Azure Table | Nichts, das nicht Table spricht |
BlobContainerClient GetBlobContainer(...) | Azure Blob | Nichts, das nicht Blob spricht |
Task<BlockBlobClient> UploadFileAsync(...) | Azure Blob | Dieselbe Familie, anderer Client |
Task<User> GetUserById(...) | Microsoft Graph | Nichts ohne Graph-Modell |
Task SendMail(SendMailPostRequestBody, ...) | Graph Mail | SMTP nur, wenn SMTP Graph-DTOs frisst |
Task<Signup> GetById(...) / Task Save(Signup) | Anmeldung | Jede Persistenz, die Signups speichert |
Die ersten fünf sind Ports mit dem Gesicht des Lieferanten. Der letzte ist ein Port mit dem Gesicht der Fähigkeit.
Nur der letzte überlebt einen Wechsel.
Drei Varianten derselben Lüge
Die erste ist der generische Storage-Port. Ein Interface für alles, das in Azure liegt. Tabelle, Blob, Reset. Bequem. Deshalb dauert es Jahre, bis jemand merkt, dass jeder Service in der Codebasis Table-Fluent-API spricht. Zweihundert GetTableClient-Aufrufe sind kein Zufall. Sie sind die Folge eines Vertrags, der den Client rausreicht, statt ihn zu besitzen.
Die zweite kommt nach dem Refactor, wenn man „endlich ein Blob-Interface“ hat:
public interface IBlobStorageService
{
Task<BlockBlobClient> UploadFileAsync(string containerName, string blobName, Stream content);
Uri GenerateSasUri(BlockBlobClient blobClient, BlobSasPermissions permissions, DateTimeOffset expiresOn);
}
Der Upload ist jetzt zentral. Gut. Die Rückgabe ist trotzdem Azure. GenerateSasUri nimmt einen BlockBlobClient und BlobSasPermissions. Der Caller muss das SDK importieren, um die Methode aufzurufen. Hat er den Client erst einmal, kann er ihn auch direkt benutzen. Manche tun das. Dann gibt es das Interface und new BlobContainerClient(...) drei Ordner weiter.
Die dritte sitzt auf Graph und Mail:
public interface IGraphApiService
{
Task<User> GetUserById(string id);
Task SendMail(SendMailPostRequestBody body, string senderId);
}
public interface IMailSender
{
Task<SendMailResult> SendMail(SendMailPostRequestBody requestBody, string senderId);
}
IMailSender ist der richtige Name. Der Parameter ist der falsche Typ. SMTP kann diese Methode nur implementieren, indem es ein Graph-DTO zerlegt. Der Wechsel des Transports ändert dann nicht nur die Klasse hinter dem Interface. Er ändert jedes Payload, jede Queue-Message, jeden Test, der SendMailPostRequestBody gebaut hat.
Der Name war die Fähigkeit. Die Signatur ist das Produkt.
Der Mock, der Azurite braucht
Die Lüge wird im Test sichtbar.
Ein ehrliches Interface lässt sich mit drei Zeilen faken:
public sealed class FakeSignupRepository : ISignupRepository
{
private readonly Dictionary<string, Signup> _items = new();
public Task<Signup?> GetByIdAsync(string id, CancellationToken cancellationToken)
=> Task.FromResult(_items.GetValueOrDefault(id));
public Task SaveAsync(Signup signup, CancellationToken cancellationToken)
{
_items[signup.Id] = signup;
return Task.CompletedTask;
}
}
Kein Storage-Emulator. Kein Connection String. Kein SDK.
Der leaky Port erzwingt das Gegenteil. Der „Mock“ konstruiert denselben Table-Client wie die Produktion, nur gegen 127.0.0.1:
public class MockStorageService : IStorageService
{
public MockStorageService()
{
_tables = new TableClientService(AzuriteConnectionString);
_blobs = new BlobServiceClient(AzuriteConnectionString);
}
public TypedTableClient<T> GetTableClient<T>()
=> _tables.GetTableClient<T>();
}
Das ist kein Mock. Das ist ein zweiter Adapter. Wer IStorageService mocken will, muss Table sprechen. Deshalb tun es die Tests oft gar nicht. Sie legen Mock<IStorageService> an, setzen einen Blob-Container auf — und asserten dann selbst gebaute Testdaten, ohne den Service zu rufen.
Das Interface existiert für die Pipeline. Nicht für die Regel.
Was ein Persistenz-Vertrag stattdessen hergibt
Der Caller braucht keine Tabelle. Er braucht die Entität.
public interface IDataService<T>
{
Task<T> GetById(string id, string partitionKey);
Task<List<T>> GetAll();
Task Save(T dataToSave, string userId, bool isNew);
Task Delete(T dataToDelete);
}
Kein TableClient. Kein TableQuery. Kein OData-Filter in der Fachklasse. Partition Keys können noch durchscheinen — das ist eine Restschuld, keine Katastrophe. Entscheidend ist: der App Service importiert Azure Storage nicht. Ein Runtime-Upgrade der Table-Library endet in der Data-Schicht. Die Consumer merken es nicht.
Dasselbe für Blob, wenn man es zu Ende denkt:
public interface IFileStore
{
Task<FileHandle> PutAsync(string folder, string name, Stream content, CancellationToken cancellationToken);
Uri CreateDownloadLink(FileHandle handle, TimeSpan lifetime);
}
public sealed record FileHandle(string Folder, string Name);
Kein BlockBlobClient. Kein BlobSasPermissions. SAS ist ein Azure-Wort. Download-Link ist ein Anwendungs-Wort.
Dasselbe für Mail, wenn man es zu Ende denkt:
public interface IMailSender
{
Task SendAsync(MailMessage message, CancellationToken cancellationToken);
}
public sealed record MailMessage(
string Subject,
string Body,
IReadOnlyList<string> Recipients);
Kein SendMailPostRequestBody. Der Graph-Wrapper darf intern genau dieses DTO bauen. Er darf es nicht nach oben reichen.
Warum der leaky Port so gut überlebt
Weil er sich produktiv anfühlt.
Ein GetTableClient<T>() spart zwanzig Repositories. Jede neue Entity ist eine Generic. Jeder neue Zugriff ist eine Zeile. Das Team bewegt sich schnell. Die Folie zeigt ein Interface. SOLID ist abgehakt.
Die Kosten kommen später, und sie kommen gebündelt.
Der Lieferant ist der Vertrag. Ein Breaking Change in Azure.Data.Tables oder im Graph-SDK trifft nicht einen Adapter. Er trifft jede Klasse, die den Client aus dem Interface geholt hat.
Die Fachregel wohnt in Query-Ketten. GetEntitiesAsync, Filter in Memory, InsertOrReplace. Es gibt keinen Ort namens Anmeldung speichern. Es gibt zweihundert Stellen, die Table können.
Tests brauchen Infrastruktur. Oder sie testen nichts.
Ein zweites Backend ist unmöglich. Cosmos statt Table, Dateisystem statt Blob, SMTP statt Graph — alles würde den öffentlichen Vertrag der Anwendung ändern. Also bleibt man. Nicht aus Überzeugung. Weil das Interface den Ausweg zugemauert hat.
Im vorigen Stück war der God Service die Klasse, die sieben Lieferanten kennt. Hier ist der leaky Port der Grund, warum sie sie kennen muss. Wer nur GetTableClient<T>() bekommt, kann Persistenz nicht hinter eine Fähigkeit legen. Er kann sie nur durchreichen.
Die Signatur ist die Entscheidung
Man muss die Implementierung nicht öffnen. Die erste Zeile des Interfaces reicht.
Gibt sie einen SDK-Typ zurück, ist das Produkt die Abstraktion.
Gibt sie ein Domain-Modell zurück, ist die Fähigkeit die Abstraktion.
IStorageService mit GetTableClient<T>() ist ehrlich, wenn man es so liest: Ich bin Azure Table, und ich leihe dir den Client. Dann soll es IAzureTableFactory heissen und in der Infrastructure-Schicht bleiben. Nicht in jedem App Service. Nicht in der Function.
Was die Anwendung braucht, ist kleiner und strenger:
public interface ISignupRepository
{
Task<Signup?> GetAsync(string eventId, string userId, CancellationToken cancellationToken);
Task SaveAsync(Signup signup, CancellationToken cancellationToken);
}
Ein Typ. Zwei Methoden. Kein Client.
Wer das für umständlich hält, zahlt die Umständlichkeit später — im Upgrade, im Test, im Moment, in dem SMTP oder ein anderer Store keine Option mehr ist, sondern eine Anforderung.
Das I am Namen ändert daran nichts. Die Rückgabe schon.