
Clean Architecture und Domain-Driven Design werden oft in einem Atemzug genannt. Das bekannte Ringbild von Robert C. Martin zeigt aber nur eine Domain mit einem Modell im Zentrum. Sobald ein System mehrere Subdomänen und Bounded Contexts hat, reicht dieses Bild nicht mehr aus. Dieser Artikel zeigt, wie aus den Ringen eine Knoblauchknolle wird, was das für die Abhängigkeitsregel bedeutet und wie Kontexte über ihre Grenzen hinweg sauber miteinander sprechen.
Kaum ein Architekturbild hängt in so vielen Teamräumen wie das von Uncle Bob. Es hat einer ganzen Generation von .NET-Entwicklerinnen und -Entwicklern gezeigt, dass die Datenbank ein Detail ist und die Geschäftsregeln in die Mitte gehören.
Und doch stolpern Teams immer wieder an derselben Stelle. Die Solution hat die vier Projekte Domain, Application, Infrastructure und Api, die Abhängigkeiten zeigen brav nach innen, und trotzdem wächst nach zwei Jahren ein Modell, das niemand mehr versteht. Die Klasse Customer hat achtzig Eigenschaften, und jede Änderung braucht die Abstimmung dreier Abteilungen. Der Grund liegt nicht in den Ringen, sondern in dem, was das Bild verschweigt: Es kennt genau ein Zentrum.
Der Artikel formt aus der Zwiebel eine Knolle und widmet sich dann dem, was in der Praxis den meisten Ärger macht: der Kommunikation über die Grenzen zwischen den Kontexten. Als Beispiel dient die Kontaktverwaltung aus unserem Seminar mit den Kontexten Kontakte, Newsletter und Vertrieb.
Robert C. Martin hat „The Clean Architecture“ im August 2012 in seinem Blog veröffentlicht, das Buch folgte 2017. Er sagt selbst, dass die Idee nicht neu ist: Hexagonal Architecture, Onion Architecture und verwandte Ansätze verfolgen dasselbe Ziel. Die Fachlichkeit soll unabhängig sein von Framework, Oberfläche, Datenbank und externen Diensten.

Abbildung 1 liest man von innen nach außen. Im Kern liegen die Entities mit den Geschäftsregeln, die auch ohne Software gälten, etwa Contact und EmailAddress. Darum liegen die Use Cases, in .NET meist als Commands und Queries umgesetzt. Die Interface Adapters übersetzen zwischen den Use Cases und der Außenwelt: Repository-Implementierungen, Endpunkte, DTOs. Ganz außen stehen Frameworks und Treiber wie ASP.NET Core, EF Core und die Datenbank.
Zusammengehalten wird das Bild von einer einzigen Regel, der Dependency Rule: Quellcode-Abhängigkeiten zeigen nur nach innen. Nichts in einem inneren Ring kennt etwas aus einem äußeren. Braucht ein Use Case die Datenbank, definiert er innen ein Interface wie IContactRepository, und außen implementiert es eine Klasse mit EF Core. Martin betont, dass die Zahl der Ringe schematisch ist. Entscheidend ist die Richtung.
Was Martin nicht ausdrücklich sagt, was aber jeder Betrachter mitliest: Es gibt genau einen Kern. Das Bild hat ein Zentrum, und in diesem Zentrum liegt ein Modell. Für eine überschaubare Anwendung stimmt das. Für ein Unternehmen mit Vertrieb, Marketing und Buchhaltung stimmt es nicht, und genau hier setzt Domain-Driven Design an.
In der Kontaktverwaltung taucht dieselbe Person an drei Stellen auf. Für die Stammdatenpflege ist sie ein Kontakt mit Name, E-Mail-Adresse, Postanschrift und Status. Für das Newsletter-Marketing ist sie ein Empfänger mit E-Mail-Adresse und Einwilligung, die Postanschrift interessiert dort niemanden. Für den Vertrieb ist sie ein Ansprechpartner mit Firma, Rolle und offenen Verkaufschancen. Drei Begriffe, drei Datensätze, drei Regelwerke.
Der klassische Reflex ist, alles in einer Klasse zu vereinen. Das Ergebnis kennt jeder: ein Modell, das allen gehört und das sich keiner mehr zu ändern traut. DDD schlägt den umgekehrten Weg vor und unterscheidet dafür zwei Begriffe, die oft verwechselt werden.
Tabelle 1: Subdomäne und Bounded Context
Eine Subdomäne gibt es auch ohne Software. Man unterscheidet die Core Domain, die vom Wettbewerb abhebt, Supporting Subdomains, die nötig sind, aber keinen Unterschied am Markt machen, und Generic Subdomains, die man einkauft. In unserem Beispiel ist der Vertrieb Core, Kontakte und Newsletter sind Supporting, der Mailversand ist Generic.
Ein Bounded Context ist dagegen eine Entwurfsentscheidung. Er ist die Grenze, innerhalb der ein Modell eindeutig gilt, mit eigener Sprache, eigenen Regeln und meist eigenen Daten. Im Idealfall entspricht ein Bounded Context genau einer Subdomäne. Über die Grenzen hinweg bleibt nur die Identität erhalten: Kontakt, Empfänger und Ansprechpartner teilen dieselbe ContactId, aber kein Modell.
Wer drei Modelle hat, kann sie nicht alle in denselben Kern legen, ohne wieder bei der Klasse mit achtzig Eigenschaften zu landen. Die Lösung ist einfacher, als sie klingt: Man behält das Ringbild, zeichnet es aber einmal je Bounded Context. Rückt man diese Zwiebeln zusammen, entsteht eine Knoblauchknolle.

In Abbildung 2 ist jede Zehe ein Bounded Context. Jede hat ihre eigene Domain mit eigenen Aggregates: Contact im Kontext Kontakte, DistributionList im Newsletter, Opportunity im Vertrieb. Jede hat eigene Use Cases, eigene Infrastruktur und eine eigene Präsentationsschicht. Die Ringnamen folgen der Projektstruktur, die in .NET-Lösungen üblich ist: Domain, Application, Infrastructure und Presentation. Sie entsprechen Martins Entities, Use Cases, Interface Adapters und Frameworks.
Die Dependency Rule gilt unverändert, aber nur innerhalb einer Zehe. In jeder Zehe zeigen die Abhängigkeiten nach innen, in Abbildung 2 durch einen Pfeil je Kontext angedeutet. Hinzu kommt eine zweite Regel, die das klassische Bild nicht braucht, weil es nur einen Kern hat: Kein Kontext greift in die Ringe eines anderen. Der Newsletter verweist nicht auf Contacts.Domain, der Vertrieb liest nicht in der Tabelle Contacts, und kein Handler im Vertrieb ruft direkt einen Command aus dem Kontext Kontakte auf.
Kurz gesagt: Innen zeigen Abhängigkeiten zur eigenen Domain, quer gibt es keine. Was dennoch über die Grenze muss, läuft durch die Fugen.
In der Mitte der Knolle sitzt der Shared Kernel, ein kleiner, gemeinsam gepflegter Teil des Modells. Typischer Inhalt sind stark typisierte Ids wie ContactId, wenige allgemeine Value Objects und die Basistypen für Entities und Domain Events. Dass er im Zentrum liegt, passt zur Dependency Rule: Alle Zehen dürfen von ihm abhängen, er selbst kennt keine von ihnen.
Zugleich ist er der heikelste Teil der Knolle, denn jede Änderung betrifft alle Kontexte. Die Faustregel: Was hinein soll, muss überall dieselbe Bedeutung haben. EmailAddress besteht diese Probe, Customer nie.
Weil jede Zehe für sich steht, kann jede ihre eigene Tiefe haben. Die Core Domain Vertrieb bekommt ein reiches Modell, CQRS und viele Tests. Im Supporting-Kontext Newsletter genügt oft einfaches CRUD hinter einer sauberen Schnittstelle. Generic Subdomains wie der Mailversand werden eingekauft und erscheinen nur als Adapter im Infrastructure-Ring. DDD ist damit keine Entscheidung für das ganze System, sondern für jede Zehe einzeln.
Bounded Contexts sind nie völlig unabhängig. Der Newsletter muss von neuen Kontakten erfahren, der Vertrieb braucht Namen und Firma seiner Ansprechpartner. Diese Abhängigkeiten gestaltet man bewusst, statt sie über geteilte Tabellen entstehen zu lassen. In der Knolle ist dafür ein fester Ort vorgesehen: die Fuge.
Jede Beziehung zwischen zwei Kontexten hat eine Richtung. Der Upstream liefert Daten oder ein Modell, der Downstream nutzt sie. Ändert der Upstream sein Modell, spürt es der Downstream, nicht umgekehrt. Eric Evans benennt in der DDD Reference die Muster dafür: Partnership, Shared Kernel, Customer/Supplier, Conformist, Anticorruption Layer, Open Host Service und Separate Ways. Zusammen ergeben sie die Context Map, und in Abbildung 2 steht sie dort, wo sie hingehört: in den Fugen.
Zwei Fragen helfen bei der Wahl: Wie viel Einfluss haben Sie auf den Upstream, und wie gut passt sein Modell zu Ihrem? In Abbildung 2 bietet Kontakte seine Daten als Open Host Service an und veröffentlicht Integration Events, die der Newsletter als Kunde im Sinne von Customer/Supplier empfängt. Der Vertrieb hat ein eigenes, älteres Modell und schützt es mit einem Anticorruption Layer. Zwischen Newsletter und Vertrieb gibt es bewusst keine Integration: Separate Ways.
Die Knolle macht eine Regel sichtbar, die oft untergeht: Kommunikation zwischen Kontexten beginnt und endet in den äußeren Ringen. Keine Domain spricht direkt mit einer anderen. Ein Ereignis entsteht zwar in der Domain, wird aber in Application oder Infrastructure in ein Integration Event übersetzt. Auf der anderen Seite nimmt die Infrastructure es entgegen und ruft einen eigenen Use Case auf. So bleibt jeder Kern frei von fremden Modellen, und die Dependency Rule gilt auch über die Fuge hinweg.
Für die asynchrone Kommunikation lohnt eine Unterscheidung, die auch Microsofts Leitfaden zu .NET-Microservices trifft:
Tabelle 2: Domain Events bleiben in der Zehe, Integration Events überqueren die Fuge
Integration Events sind damit ein Vertrag mit anderen Teams. Deshalb übersetzt man an der Grenze: Ein Handler im Kontext Kontakte reagiert auf das Domain Event ContactCreated und veröffentlicht daraus ein Integration Event mit eigenem, versioniertem Format.
// Contacts.Contracts: die Published Language des Kontexts Kontakte
public sealed record ContactRegisteredV1(Guid ContactId, string Email, DateTime OccurredOnUtc);// Contacts.Application: übersetzt das Domain Event in den Vertrag
public sealed class PublishContactRegistered(IIntegrationEventPublisher publisher)
: INotificationHandler<DomainEventNotification<ContactCreated>>
{
public Task Handle(DomainEventNotification<ContactCreated> notification, CancellationToken ct)
{
var e = notification.DomainEvent;
return publisher.PublishAsync(
new ContactRegisteredV1(e.ContactId.Value, e.Email.Value, e.OccurredOnUtc), ct);
}
}Der Vertrag liegt in einem eigenen kleinen Projekt Contacts.Contracts. Nur dieses darf der Newsletter referenzieren, nie Contacts.Domain. So bleibt das Domänenmodell frei änderbar. Ändert sich der Vertrag, entsteht ContactRegisteredV2 neben der ersten Version.
Sobald Ereignisse den Prozess verlassen, schreibt der Use Case in zwei Systeme ohne gemeinsame Transaktion: Datenbank und Message Broker. Stürzt der Prozess dazwischen ab, ist der Kontakt gespeichert, aber der Newsletter erfährt nie davon. Geht so ein widerrufenes Einverständnis verloren, wird aus einem technischen Fehler ein Datenschutzproblem.
Die Lösung heißt Transactional Outbox. Das Integration Event wird nicht direkt gesendet, sondern als Zeile in einer Tabelle OutboxMessages gespeichert, in derselben Transaktion wie die fachliche Änderung. Ein Hintergrunddienst liest die ungesendeten Zeilen, veröffentlicht sie über den Broker und markiert sie als verarbeitet. Weil Nachrichten dabei mehr als einmal ankommen können, müssen die Empfänger idempotent sein: Eine doppelt zugestellte Nachricht darf keinen zweiten Empfänger anlegen. Bibliotheken wie MassTransit, NServiceBus oder Wolverine bringen Outbox und Inbox fertig mit; prüfen Sie vor dem Einsatz die aktuellen Lizenzbedingungen.
Nicht alles lässt sich über Ereignisse lösen. Braucht der Vertrieb beim Anlegen eines Angebots den aktuellen Namen eines Ansprechpartners, fragt er beim Kontext Kontakte nach. Damit das fremde Modell nicht in die eigene Zehe sickert, beschreibt der Vertrieb in seiner eigenen Sprache, was er braucht. Ein Übersetzer im eigenen Infrastructure-Ring erledigt den Rest:
// Sales.Application: der Vertrieb sagt in seiner Sprache, was er braucht
public interface IContactPersonDirectory
{
Task<ContactPerson?> FindAsync(ContactId id, CancellationToken ct);
}// Sales.Infrastructure: der Anticorruption Layer übersetzt das fremde Modell
public sealed class ContactsApiTranslator(ContactsApiClient client) : IContactPersonDirectory
{
public async Task<ContactPerson?> FindAsync(ContactId id, CancellationToken ct)
{
var dto = await client.GetContactAsync(id.Value, ct);
return dto is null ? null : new ContactPerson(id, dto.DisplayName, dto.Company);
}
}Der Vertrieb kennt nur ContactPerson und IContactPersonDirectory. Ändert der Kontext Kontakte seine API, ist genau eine Klasse anzupassen. Das ist dieselbe Dependency Inversion, die innerhalb einer Zehe die Datenbank auf Abstand hält, nur angewendet auf einen Nachbarkontext.
Im Projektmappen-Explorer wird zuerst nach Bounded Context geschnitten und erst danach nach Schichten. Jede Zehe bekommt einen Ordner mit ihren Projekten, etwa Contacts.Domain, Contacts.Application, Contacts.Infrastructure, Contacts.Api und Contacts.Contracts. Daneben liegen Newsletter, Sales, ein kleines SharedKernel und ein Host als Composition Root.
Innerhalb jeder Zehe erzwingt der Compiler die Richtung über Projektverweise, die Querregel bewacht ein Architekturtest, etwa mit NetArchTest. Für den Start empfehlen wir einen modularen Monolithen: alle Zehen in einer Solution, ein Deployment, saubere Grenzen. Muss ein Kontext später unabhängig skalieren, lässt er sich als Microservice herauslösen, weil die Fugen schon stimmen.
Uncle Bobs Ringbild ist nicht falsch. Es beschreibt eine Zehe. Wer DDD ernst nimmt, entdeckt mehrere Subdomänen, entwirft dafür mehrere Bounded Contexts und bekommt damit mehrere Ringsysteme. Zusammen bilden sie eine Knolle, und für sie gelten vier Leitsätze:
Der wichtigste Schritt ist dabei nicht technisch: Die Fugen findet man nur gemeinsam mit den Fachleuten, etwa in einem Event Storming. Wie es danach Schritt für Schritt weitergeht, bis zur Outbox, zeigen wir in unserem viertägigen Seminar „Clean Architecture und DDD mit C#“.
Robert C. Martin: The Clean Architecture. Blog, 13.08.2012. https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html
Eric Evans: Domain-Driven Design Reference. https://www.domainlanguage.com/ddd/reference/
Vaughn Vernon: Implementing Domain-Driven Design. Addison-Wesley, 2013
Martin Fowler: Bounded Context. https://martinfowler.com/bliki/BoundedContext.html
Microsoft: .NET Microservices: Architecture for Containerized .NET Applications. https://learn.microsoft.com/dotnet/architecture/microservices/
Chris Richardson: Pattern: Transactional Outbox. https://microservices.io/patterns/data/transactional-outbox.html

Praxiswissen, Tipps und Neuigkeiten für den Mittelstand: kompakt und ohne Spam. Abmelden jederzeit möglich.
Praxiswissen, Tipps und Neuigkeiten für den Mittelstand: kompakt und ohne Spam. Abmelden jederzeit möglich.