Ich habe eine Schwäche für Domain-Driven Design und ich habe zu viel Zeit meiner Karriere damit verbracht, zu beobachten, wie es schlecht eingesetzt wird. Das Muster, das den meisten Teams am meisten schadet, ist nicht die Abwesenheit von DDD, sondern die Präsenz von DDD-Theater. Aggregate Roots, die nichts aggregieren. Value Objects mit einem einzigen Feld. Repositories, die Doctrine-Repositories kapseln, damit das Team sagen kann, es habe Repositories. Domain Events, die ins Leere gefeuert werden, weil irgendein Kapitel des Blue Book behauptet hat, Domain Events seien gut.
Der Sinn von Bounded Contexts, der mit weitem Abstand nützlichsten Idee aus DDD, ist nicht das Zurschaustellen von Raffinesse. Es geht darum, die Grenzen zwischen den Teilen Ihres Geschäfts im Code sichtbar zu machen, damit Sie einen Teil ändern können, ohne versehentlich einen anderen kaputt zu machen.
Das war es. Der Rest ist hilfreich, wenn Sie ihn brauchen, und schädlich, wenn nicht.
In diesem Essay geht es darum, wie Sie Bounded Contexts in einer Symfony-Anwendung gut umsetzen. Nicht auf der grünen Wiese. In der Anwendung, die Sie tatsächlich haben, mit den Doctrine-Entities, die Sie bereits geschrieben haben, den Controllern, die über vier Jahre gewachsen sind, und dem DI-Container mit 1.400 Services darin.
Was ein Bounded Context tatsächlich ist
Ein Bounded Context ist ein Bereich Ihres Codes, in dem ein Wort eine bestimmte Bedeutung hat. Das klassische Beispiel: In einer typischen E-Commerce-Anwendung bedeutet “Order” in den verschiedenen Teilen des Geschäfts sehr unterschiedliche Dinge.
- Für Sales ist eine Order etwas, das verhandelt wird. Sie hat einen Entwurfsstatus, Line Items, die hinzugefügt und entfernt werden, einen Kunden, der sich noch nicht festgelegt hat.
- Für Catalog ist eine Order eine Auflistung dessen, was verkauft wurde, um Bestände und Empfehlungen zu aktualisieren.
- Für Payments ist eine Order eine Verpflichtung, Geld einzuziehen, mit einem Preis, einer Steueraufschlüsselung und einem Zahlungsstatus.
- Für Shipping ist eine Order eine Versandliste physischer Güter, die verpackt und geroutet werden müssen.
Wenn Ihre Codebasis eine einzige Order-Klasse hat, die alle vier Rollen gleichzeitig erfüllen will, riskiert jede Änderung in einem dieser vier Bereiche, die anderen drei kaputt zu machen. Die Klasse wird zu dem Ort, an dem sich die Anforderungen jedes Teams stapeln, und die Test-Suite wird zu dem Ort, an dem die Regressionen jedes Teams auftauchen.
Ein Bounded Context ist die Entscheidung, Sales eine eigene Order zu geben, Payments eine eigene Order, Shipping eine eigene Order, mit expliziten, bewussten Wegen, wie diese Orders miteinander sprechen, wenn es nötig ist. Das Ziel ist nicht architektonische Reinheit. Das Ziel ist, dass das Sales-Team seine Order ändern kann, ohne sich mit dem Shipping-Team abzustimmen, weil die beiden Orders unterschiedliche Objekte sind, die zufällig eine ID teilen.
Das ist der Wert. Alles andere ist Implementierungsdetail.
Die fünf Schritte, die das in Symfony praktikabel machen
Ich habe mich auf eine kleine Menge von Schritten festgelegt, die zuverlässig funktionieren. Keiner davon verlangt, dass Sie sich dazu bekennen, “DDD zu machen”. Alle sind umkehrbar. Sie können sie einzeln übernehmen.
1. Namespaces nach Kontext schneiden, nicht nach technischer Schicht
Der erste Schritt ist der kleinste. Hören Sie auf, Ihr src/-Verzeichnis auf oberster Ebene nach technischer Schicht zu organisieren (Controller/, Entity/, Service/), und fangen Sie an, es nach Kontext zu organisieren.
Falsch:
src/
├── Controller/
│ ├── OrderController.php
│ ├── ProductController.php
│ └── PaymentController.php
├── Entity/
│ ├── Order.php
│ ├── Product.php
│ └── Payment.php
└── Service/
├── OrderService.php
├── ProductService.php
└── PaymentService.php
Richtig:
src/
├── Sales/
│ ├── Order.php
│ ├── Cart.php
│ ├── PlaceOrderController.php
│ └── PlaceOrderHandler.php
├── Catalog/
│ ├── Product.php
│ ├── Inventory.php
│ └── ProductController.php
├── Payments/
│ ├── PaymentRequest.php
│ ├── StripeBridge.php
│ └── ChargeController.php
└── Shipping/
├── Shipment.php
├── Manifest.php
└── DispatchController.php
Das kostet so gut wie nichts. Es sind im Wesentlichen Dateiumzüge und Namespace-Umbenennungen. Was Sie gewinnen: Die Grenzen werden auf einen Blick sichtbar, und ein Entwickler, der ein Feature zu Payments hinzufügt, weiß genau, wohin es gehört, ohne zwischen drei Schichten und vier Verzeichnissen wählen zu müssen.
Dem Symfony-DI-Container sind die Namespaces egal. Autowiring funktioniert gleich. Routing funktioniert gleich. Entities funktionieren gleich, solange Sie Doctrine sagen, wo es suchen soll (Mapping-Konfigurationsreferenz):
doctrine:
orm:
mappings:
Sales:
type: attribute
dir: '%kernel.project_dir%/src/Sales'
prefix: 'App\Sales'
is_bundle: false
Catalog:
type: attribute
dir: '%kernel.project_dir%/src/Catalog'
prefix: 'App\Catalog'
is_bundle: false
Payments:
type: attribute
dir: '%kernel.project_dir%/src/Payments'
prefix: 'App\Payments'
is_bundle: false
Shipping:
type: attribute
dir: '%kernel.project_dir%/src/Shipping'
prefix: 'App\Shipping'
is_bundle: false
Gleiche Datenbank. Gleiche Connection. Gleicher EntityManager. Operativ haben Sie nichts geändert. Sie haben die Geografie der Codebasis verändert, und das ist der erste Schritt, sie ohne Überraschungen ändern zu können.
2. Kontextübergreifende Referenzen per Lint-Regel verbieten
Der zweite Schritt ist das, was dem ersten Schritt überhaupt erst Bedeutung gibt. Eine Grenze, die nur in der Verzeichnisstruktur existiert, ist keine Grenze, sondern eine Empfehlung.
Nutzen Sie Deptrac. Legen Sie eine deptrac.yaml an:
deptrac:
paths:
- ./src
layers:
- name: Sales
collectors:
- type: classLike
value: ^App\\Sales\\
- name: Catalog
collectors:
- type: classLike
value: ^App\\Catalog\\
- name: Payments
collectors:
- type: classLike
value: ^App\\Payments\\
- name: Shipping
collectors:
- type: classLike
value: ^App\\Shipping\\
- name: SharedKernel
collectors:
- type: classLike
value: ^App\\SharedKernel\\
ruleset:
Sales: [SharedKernel]
Catalog: [SharedKernel]
Payments: [SharedKernel]
Shipping: [SharedKernel]
SharedKernel: []
Lassen Sie das in CI laufen. Der erste Durchlauf wird laut scheitern, weil Ihr bestehender Code Hunderte kontextübergreifender Referenzen enthält. Genau das ist der Punkt. Die Fehlschläge sind die Arbeit.
Sortieren Sie die Verstöße in drei Kategorien:
- Echte gemeinsame Nutzung eines primitiven Konzepts (eine
UserId, einMoney-Value-Object, ein Ländercode). Verschieben Sie es in einenSharedKernel-Namespace. Seien Sie konservativ: Der SharedKernel soll klein bleiben. - Eine echte Zusammenarbeit zwischen Kontexten (Sales fragt Catalog, ob ein Produkt auf Lager ist). Das braucht ein explizites Interface, das auf Seite des Konsumenten liegt und vom Produzenten implementiert wird. Dazu kommen wir in Schritt 3.
- Eine zufällige Kopplung, die nicht existieren sollte (Payments liest direkt aus der Sales-
Order-Entity). Das ist der häufigste Fall. Fall für Fall beheben.
Legen Sie eine Baseline fest. Verbieten Sie neue Verstöße. Arbeiten Sie die bestehenden über die Zeit ab. Das Team wird sich die ersten Wochen sträuben, das ist normal. Zwei Monate später werden sie sich nicht mehr vorstellen können, ohne es zu arbeiten.
3. Anti-Corruption Layer nutzen, aber beim Namen nennen
Der klassische DDD-Begriff “Anti-Corruption Layer” schreckt Leute ab, aber das Muster selbst ist das nützlichste im Werkzeugkasten. Es ist einfach ein Interface, das dem Konsumenten gehört und eine externe (oder aus einem anderen Kontext stammende) Form in das Vokabular des Konsumenten übersetzt.
Konkretes Beispiel. Sales muss wissen, ob ein Produkt auf Lager ist, um es in einen Warenkorb aufzunehmen. Catalog besitzt die Bestandsdaten. Der falsche Schritt ist, Catalog\InventoryRepository in Sales-Code zu injizieren. Der richtige Schritt:
// src/Sales/Domain/Stock/StockChecker.php
namespace App\Sales\Domain\Stock;
interface StockChecker
{
public function isAvailable(string $productSku, int $quantity): bool;
}
Dann, in Catalog, eine Implementierung:
// src/Catalog/Adapter/SalesStockChecker.php
namespace App\Catalog\Adapter;
use App\Sales\Domain\Stock\StockChecker;
use App\Catalog\InventoryRepository;
final readonly class SalesStockChecker implements StockChecker
{
public function __construct(
private InventoryRepository $inventory,
) {
}
public function isAvailable(string $productSku, int $quantity): bool
{
return $this->inventory->getBySku($productSku)->availableUnits() >= $quantity;
}
}
Verdrahten Sie es in der services.yaml:
services:
App\Sales\Domain\Stock\StockChecker: '@App\Catalog\Adapter\SalesStockChecker'
Beachten Sie, was gerade passiert ist:
- Das
StockChecker-Interface liegt in Sales, im Vokabular von Sales. Sales fragt “ist diese SKU verfügbar?”, weil das ist, was Sales interessiert. - Die Implementierung liegt in Catalog, weil Catalog die Daten und den Algorithmus besitzt.
- Sales hat null Referenzen auf Catalog. Deptrac ist zufrieden.
- Catalog hat eine Referenz auf Sales (das Interface), was erlaubt ist, weil das Interface der Vertrag ist, den Sales veröffentlicht.
Die Kosten sind ein winziges Stück Indirektion. Der Nutzen: Sales kann ändern, was es unter “verfügbar” versteht (Reservierungen hinzufügen, kundenspezifische Limits, Holds), ohne Catalog anzufassen, und Catalog kann ändern, wie der Bestand gespeichert wird (Wechsel von Doctrine zu einem event-sourced Read Model), ohne Sales anzufassen.
Dieses Muster, konsequent angewandt, macht 80% dessen aus, was Bounded Contexts Ihnen in der Praxis bringen.
4. Messages für alles, was nicht blockieren muss
Manche kontextübergreifenden Interaktionen sind tatsächlich synchron: Sales muss jetzt die Verfügbarkeit wissen, um zu entscheiden, ob eine Order angenommen wird. Die meisten kontextübergreifenden Interaktionen sind es nicht: Wenn eine Order platziert wird, muss Catalog den Bestand aktualisieren, Payments muss eine Abbuchung starten, Shipping muss eine Versandliste erstellen. Nichts davon muss im Request-Thread passieren.
Nutzen Sie Symfony Messenger. Definieren Sie ein Domain Event in Sales:
namespace App\Sales\Domain\Event;
use App\SharedKernel\OrderId;
use App\SharedKernel\Money;
final readonly class OrderPlaced
{
/**
* @param list<OrderLine> $lines
*/
public function __construct(
public OrderId $orderId,
public string $customerEmail,
public array $lines,
public Money $total,
) {
}
}
Sales dispatcht das Event, wenn eine Order platziert wird. Catalog, Payments und Shipping implementieren jeweils einen Handler, der darauf reagiert.
namespace App\Catalog\Listener;
use App\Sales\Domain\Event\OrderPlaced;
use App\Catalog\InventoryRepository;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler]
final readonly class DecrementInventoryOnOrderPlaced
{
public function __construct(
private InventoryRepository $inventory,
) {
}
public function __invoke(OrderPlaced $event): void
{
foreach ($event->lines as $line) {
$this->inventory->decrementBySku($line->sku, $line->quantity);
}
}
}
Zwei Dinge an diesem Muster, über die Teams stolpern:
Das Event lebt im Quellkontext. Das OrderPlaced-Event gehört Sales, weil Sales es veröffentlicht. Catalog, Payments und Shipping referenzieren alle App\Sales\Domain\Event\OrderPlaced. Deptrac sollte das erlauben: Subscriber dürfen die Events der Kontexte lesen, die sie abonnieren. Passen Sie das Ruleset an:
ruleset:
Catalog: [SharedKernel, 'Sales/Domain/Event']
Payments: [SharedKernel, 'Sales/Domain/Event']
Shipping: [SharedKernel, 'Sales/Domain/Event']
Das Event ist der Vertrag. Behandeln Sie es wie eine öffentliche API: vorsichtig ändern, bei Bedarf versionieren, dokumentieren.
Starten Sie mit dem Sync-Transport. Sie brauchen am ersten Tag kein RabbitMQ. Nutzen Sie 'sync://' und dispatchen Sie Events synchron im selben Request. Der Punkt ist, dass die Form der Kommunikation nachrichtenbasiert ist, sodass sich beim späteren Umzug auf einen echten Broker der Anwendungscode nicht ändert. Nur die Routing-Konfiguration.
5. Jeder Kontext bekommt seinen eigenen Entity Manager (dann, und nur dann, wenn es sich auszahlt)
Der aggressivste Schritt dieser Liste und der, bei dem man am vorsichtigsten sein sollte: Geben Sie jedem Kontext seinen eigenen Doctrine Entity Manager (mehrere Entity Manager und Connections).
doctrine:
orm:
default_entity_manager: sales
entity_managers:
sales:
connection: default
mappings:
Sales: ~
catalog:
connection: default
mappings:
Catalog: ~
payments:
connection: default
mappings:
Payments: ~
shipping:
connection: default
mappings:
Shipping: ~
Gleiche Datenbank, gleiche Connection, mehrere Entity Manager. Jeder sieht nur die Entities seines Kontexts. Doctrine-Relationen über Manager hinweg funktionieren nicht, und genau das wollen wir: Die Grenze wird vom Framework erzwungen, nicht nur vom Lint.
Der Grund, warum das der aggressivste Schritt ist: Er ändert die Bedeutung von EntityManagerInterface in Ihrem Code. Sie können nicht mehr einfach EntityManagerInterface injizieren und erwarten, dass es funktioniert; Sie müssen den richtigen Manager für den richtigen Kontext injizieren. Die Lösung ist benanntes DI:
services:
App\Sales\:
bind:
Doctrine\ORM\EntityManagerInterface: '@doctrine.orm.sales_entity_manager'
App\Catalog\:
bind:
Doctrine\ORM\EntityManagerInterface: '@doctrine.orm.catalog_entity_manager'
Ich mache das nur dann, wenn die Kontexte nachweislich angefangen haben, sich auf der Persistenzebene gegenseitig zu stören: geteilte Transaktionen, die nicht geteilt sein sollten, Listener, die unbeabsichtigt über Grenzen hinweg feuern, Schema-Migrationen, die Tabellen anfassen, die sie nicht sollten. Wenn Namespacing und Lint-Regeln funktionieren, rechnet sich die Entity-Manager-Aufteilung möglicherweise nicht. Machen Sie es, wenn die Symptome auftreten, nicht prophylaktisch.
Was Sie weglassen sollten
Der DDD-Kanon ist groß. Das meiste davon braucht man nicht. Konkret würde ich in einer typischen Symfony-Anwendung diese Muster weglassen, bis Sie einen konkreten Grund haben, sie zu brauchen, und selbst dann würde ich hart dagegenhalten:
Aggregate Roots. Nützlich bei wirklich komplexen transaktionalen Invarianten. Wenn Ihr “Aggregate Root” nichts tut, was eine normale Entity nicht auch täte, ist es Dekoration.
Das vollständige Repository Pattern als Doctrine-Wrapper. Doctrine-Repositories sind bereits ein vollkommen gutes Repository Pattern. Es in ein eigenes “Repository”-Interface zu wickeln, ist doppelte Buchführung, es sei denn, Sie bereiten bewusst einen Austausch der Persistenzschicht vor.
Domain Services als Kategorie. Ein “Domain Service” ist eine Klasse. Nennen Sie sie einfach Klasse. Sie OrderPricingDomainService zu nennen, verleiht ihr keine besonderen Kräfte.
Application Services für alles. Ein Controller, der einen Handler aufruft, reicht. Sie brauchen keine dritte Schicht dazwischen namens “Application Service”, es sei denn, Sie haben mehrere Eintrittspunkte (HTTP, CLI, Queue), die auf dieselbe Logik treffen, und selbst dann haben Sie einen Use Case für einen, nicht für dreißig.
Read Models überall. CQRS-artige Trennung von Read Models ist exzellent, wenn Ihre Lese- und Schreibmuster wirklich inkompatibel sind. Ansonsten ist es Reibung. Fügen Sie ein Read Model hinzu, wenn das Abfragen über Ihre Entities schmerzhaft wird, nicht vorher.
Der Test, ob ein solches Muster dazukommt, ist einfach. Können Sie in einem Satz beschreiben, welches konkrete Problem das Muster in Ihrer Anwendung löst? Wenn nicht, weglassen. Sie können es später jederzeit hinzufügen.
Ein durchgearbeitetes Beispiel, von Anfang bis Ende
Ich gehe einmal durch, wie das Platzieren einer Order über vier Kontexte hinweg aussieht, mit allen fünf Schritten angewandt.
Der Nutzer schickt ein Checkout-Formular ab. Der Sales-Kontext verarbeitet den Request:
namespace App\Sales\Controller;
use App\Sales\Application\PlaceOrder;
use App\Sales\Domain\Stock\StockChecker;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/checkout', name: 'sales_checkout', methods: ['POST'])]
final readonly class PlaceOrderController
{
public function __construct(
private MessageBusInterface $bus,
) {
}
public function __invoke(Request $request): Response
{
$command = PlaceOrder::fromRequest($request);
$this->bus->dispatch($command);
return new Response('', Response::HTTP_ACCEPTED);
}
}
Der Handler in Sales:
namespace App\Sales\Application;
use App\Sales\Domain\Order;
use App\Sales\Domain\OrderRepository;
use App\Sales\Domain\Stock\StockChecker;
use App\Sales\Domain\Event\OrderPlaced;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Messenger\MessageBusInterface;
#[AsMessageHandler]
final readonly class PlaceOrderHandler
{
public function __construct(
private OrderRepository $orders,
private StockChecker $stock,
private MessageBusInterface $bus,
) {
}
public function __invoke(PlaceOrder $command): void
{
foreach ($command->lines as $line) {
if (!$this->stock->isAvailable($line->sku, $line->quantity)) {
throw new OutOfStockException($line->sku);
}
}
$order = Order::place($command);
$this->orders->save($order);
$this->bus->dispatch(new OrderPlaced(
orderId: $order->id(),
customerEmail: $command->customerEmail,
lines: $command->lines,
total: $order->total(),
));
}
}
Catalog hat einen SalesStockChecker (den Anti-Corruption-Adapter aus Schritt 3) und einen Listener, der den Bestand bei OrderPlaced verringert. Payments hat einen Listener, der eine Abbuchung erstellt. Shipping hat einen Listener, der eine Versandliste erstellt. Keiner von ihnen weiß voneinander. Keiner von ihnen weiß, wie Sales seine Orders speichert. Alle vier können unabhängig verändert werden.
Das ganze Bild aus Sales-Sicht: “Ich habe nach Bestand gefragt, ich habe eine Order gespeichert, ich habe ein Event veröffentlicht.” Aus Catalog-Sicht: “Ich habe den Stock-Vertrag implementiert, den Sales verlangt hat, ich habe das Event abonniert, das Sales veröffentlicht hat.” Aus Payments- und Shipping-Sicht: “Ich habe das Event abonniert, das Sales veröffentlicht hat.”
Das sind Bounded Contexts in Symfony. Keine Aggregate Roots. Keine DSL. Kein 600-Seiten-Buch.
Typische Fehlermuster
Ich habe diesen Ansatz auf drei konkrete Arten scheitern sehen:
Den SharedKernel als Abstellkammer behandeln. Der SharedKernel soll klein sein: Identifiers, Money, primitive Value Objects, die überall wirklich dieselbe Bedeutung haben. Das erste Mal, dass jemand einen “gemeinsamen Service” dort hineinlegt, weil “jeder Kontext ihn braucht”, beginnt die Grenze zu zerfließen. Halten Sie dagegen. Wenn zwei Kontexte dasselbe Ding brauchen, fragen Sie, ob sie wirklich dasselbe brauchen oder ob jeder seine eigene Version braucht.
Events für immer alles synchron antreiben lassen. Mit sync:// zu starten, ist richtig. Auf sync:// für alles zu bleiben, während das System wächst, ist falsch. Sobald die Order-Placement-Kette synchron zwölf Dinge erledigt, liegen alle zwölf auf dem Request-Pfad, und der Nutzer wartet auf sie alle. Wechseln Sie an den Stellen auf async, an denen der Nutzer das Ergebnis vor der Antwort nicht braucht.
Den ganzen Monolithen auf einmal refaktorieren. Sie müssen nicht am ersten Tag überall Kontextgrenzen ziehen. Nehmen Sie eine Grenze, die schmerzt (die, an der sich zwei Teams am häufigsten in die Quere kommen), wenden Sie die fünf Schritte darauf an, leben Sie ein Quartal damit, nehmen Sie dann die nächste. Das Big-Bang-Refactoring ist ein Projekt, das im vierten Monat gecancelt wird.
Wann dieser Ansatz nicht passt
Bounded Contexts sind Overhead. Der Overhead zahlt sich aus, wenn die Anwendung mehrere Teams, mehrere Geschäftsbereiche oder wirklich widersprüchliches Vokabular hat. Er zahlt sich nicht aus, wenn:
- Die Anwendung klein ist (unter 30.000 Zeilen, ein Team). Schreiben Sie einfach sauberes Symfony. Vermeiden Sie verfrühte Struktur. Sie werden merken, wann Sie Kontexte brauchen; das Symptom ist, dass zwei Features zum dritten Mal auf derselben Klasse kollidieren.
- Das Geschäft seine Form noch nicht gefunden hat. Bounded Contexts kodieren ein stabiles Verständnis des Geschäfts. Wenn sich das Geschäftsmodell alle sechs Wochen ändert, wird Sie die Kodierung der vorherigen Version in der Architektur ausbremsen.
- Das Team die Vorleistung nicht stemmen will. Das ist real. Deptrac-Verstöße sind ein Budget-Posten. Diskussionen über Benennungen sind ein Budget-Posten. Wenn das Team schnell liefern muss und die bestehende Struktur funktioniert, fügen Sie keine Kontexte hinzu, nur weil ein Blog-Post meinte, sie seien gut. Fügen Sie sie hinzu, wenn die Alternative schlimmer ist.
Für alle anderen ist das die schlankste Version von Bounded Contexts, die ich gefunden habe und die den Kontakt mit einer echten Symfony-Codebasis überlebt.
Wenn Sie eine Symfony-Anwendung haben, in der sich zwei Teams ständig gegenseitig auf die Füße treten und dieselbe Handvoll Klassen in jedem PR auftaucht, ist mein Business-Alignment-Engagement genau für diese Arbeit gebaut. Eine vierwöchige Kartierung der Kontexte, die Sie tatsächlich haben, gegen die, die Sie brauchen, dann eine sequenzierte Extraktion in dem Tempo, das das Team durchhält.
Referenzen
- DomainDrivenDesign von Martin Fowler: Fowlers Überblick zu Eric Evans’ Ansatz und warum die strategischen Muster mehr zählen als die taktischen.
- BoundedContext von Martin Fowler: die kanonische Kurzerklärung, warum ein Wort in unterschiedlichen Kontexten unterschiedliche Bedeutungen haben kann.
- Anti-corruption Layer (Microsoft Azure Architecture Center): der Eintrag aus dem Pattern-Katalog, zurückgehend auf Evans’ ursprüngliche Definition.
- Symfony Messenger Component: offizielle Doku zum Message Bus aus Schritt 4.
- Doctrine: Multiple Entity Managers and Connections: Symfony-Doku zur Konfiguration aus Schritt 5.
- Deptrac: der statische Analyzer, mit dem die Regeln zwischen den Namespaces aus Schritt 2 durchgesetzt werden.
- Doctrine Mapping-Konfiguration (Symfony-Referenz): YAML-Referenz für das kontextspezifische Mapping-Setup aus Schritt 1.