I have a soft spot for Domain-Driven Design and I have spent too much of my career watching it get used badly. The pattern that hurts most teams is not the absence of DDD; it is the presence of DDD theatre. Aggregate roots that aggregate nothing. Value objects with one field. Repositories that wrap Doctrine repositories so the team can say they have repositories. Domain events fired into the void because some chapter of the blue book said domain events were good.
The point of bounded contexts, the most useful idea in DDD by a wide margin, is not to perform sophistication. It is to make the boundaries between parts of your business visible in code so that you can change one part without accidentally breaking another.
That is it. The rest is helpful when you need it and harmful when you do not.
This essay is about how to do bounded contexts well in a Symfony application. Not in a greenfield. In the actual application you have, with the Doctrine entities you already wrote, the controllers that grew over four years, and the DI container that has 1,400 services in it.
What a bounded context actually is
A bounded context is a region of your code where a word means a specific thing. The classic example: in a typical e-commerce application, “Order” means very different things to different parts of the business.
- To Sales, an Order is a thing being negotiated. It has a draft state, line items being added and removed, a customer who has not yet committed.
- To Catalog, an Order is a tally of what got sold, used to update inventory and recommendations.
- To Payments, an Order is an obligation to collect money, with a price, a tax breakdown, and a payment status.
- To Shipping, an Order is a manifest of physical goods to be packed and routed.
If your codebase has one Order class that tries to be all four, every change in any of those four areas risks breaking the other three. The class becomes the place where every team’s requirements pile up, and the test suite becomes the place where every team’s regressions surface.
A bounded context is the decision to give Sales its own Order, Payments its own Order, Shipping its own Order, with explicit, intentional ways for those Orders to talk to each other when they need to. The aim is not architectural purity. The aim is that the Sales team can change their Order without checking with the Shipping team, because the two Orders are different objects that happen to share an ID.
That is the value. Everything else is implementation detail.
The five moves that make this practical in Symfony
I have settled on a small set of moves that work reliably. None of them require you to commit to “doing DDD.” All of them are reversible. You can adopt them one at a time.
1. Carve namespaces by context, not by technical layer
The first move is the smallest. Stop organizing your src/ by technical layer (Controller/, Entity/, Service/) at the top level, and start organizing by context.
Wrong:
src/
├── Controller/
│ ├── OrderController.php
│ ├── ProductController.php
│ └── PaymentController.php
├── Entity/
│ ├── Order.php
│ ├── Product.php
│ └── Payment.php
└── Service/
├── OrderService.php
├── ProductService.php
└── PaymentService.php
Right:
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
This costs almost nothing. It is mostly file moves and namespace renames. What it gains you is that the boundaries become visible at a glance, and a developer adding a feature to Payments knows exactly where it goes without having to choose between three layers and four directories.
The Symfony DI container does not care about namespaces. Autowiring works the same. Routing works the same. Entities work the same as long as you tell Doctrine where to look (mapping configuration reference):
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
Same database. Same connection. Same EntityManager. You have changed nothing operationally. You have changed the geography of the codebase, which is the first step in being able to change it without surprises.
2. Forbid cross-context references with a lint rule
The second move is what makes the first move actually mean something. A boundary that exists only in directory structure is not a boundary; it is a suggestion.
Use Deptrac. Add a deptrac.yaml:
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: []
Run it in CI. The first run will fail loudly, because your existing code has hundreds of cross-context references. That is the point. The failures are the work.
Triage the violations into three buckets:
- Genuine sharing of a primitive concept (a
UserId, aMoneyvalue object, a country code). Move it to aSharedKernelnamespace. Be conservative; the SharedKernel is supposed to stay tiny. - A genuine collaboration between contexts (Sales asking Catalog whether a product is in stock). This needs an explicit interface, owned by the consumer side, that the producer implements. We will get to that in move 3.
- An accidental coupling that should not exist (Payments reading directly from the Sales
Orderentity). This is the most common case. Fix it case by case.
Set a baseline. Forbid new violations. Burn the existing ones down over time. The team will resist the first few weeks; this is normal. Two months later they will not be able to imagine working without it.
3. Use anti-corruption layers, but call them what they are
The classic DDD term “anti-corruption layer” puts people off, but the pattern itself is the most useful one in the toolbox. It is just an interface, owned by the consumer, that translates an external (or other-context) shape into the consumer’s vocabulary.
Concrete example. Sales needs to know whether a product is in stock to allow it onto a cart. Catalog owns the inventory data. The wrong move is to inject Catalog\InventoryRepository into Sales code. The right move is:
// src/Sales/Domain/Stock/StockChecker.php
namespace App\Sales\Domain\Stock;
interface StockChecker
{
public function isAvailable(string $productSku, int $quantity): bool;
}
Then, in Catalog, an implementation:
// 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;
}
}
Wire it in services.yaml:
services:
App\Sales\Domain\Stock\StockChecker: '@App\Catalog\Adapter\SalesStockChecker'
Notice what just happened:
- The
StockCheckerinterface lives in Sales, in Sales’s vocabulary. Sales asks “is this SKU available?” because that is what Sales cares about. - The implementation lives in Catalog, because Catalog owns the data and the algorithm.
- Sales has zero references to Catalog. Deptrac is happy.
- Catalog has one reference to Sales (the interface), which is allowed because the interface is the contract Sales is publishing.
The cost is a tiny amount of indirection. The benefit is that Sales can change what it means by “available” (add reservations, add per-customer limits, add holds) without touching Catalog, and Catalog can change how inventory is stored (move from Doctrine to an event-sourced read model) without touching Sales.
This pattern, applied consistently, is 80% of what bounded contexts give you in practice.
4. Use messages for things that should not block
Some cross-context interactions are genuinely synchronous: Sales needs to know stock availability now to decide whether to accept an order. Most cross-context interactions are not: when an order is placed, Catalog needs to update inventory, Payments needs to start a charge, Shipping needs to create a manifest. None of those need to happen on the request thread.
Use Symfony Messenger. Define a 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 dispatches the event when an order is placed. Catalog, Payments, and Shipping each implement a handler that reacts to it.
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);
}
}
}
Two things about this pattern that catch teams:
The event lives in the source context. The OrderPlaced event is owned by Sales, because Sales is the thing that publishes it. Catalog, Payments, and Shipping all reference App\Sales\Domain\Event\OrderPlaced. Deptrac should allow this: subscribers can read the events of the contexts they listen to. Update the ruleset:
ruleset:
Catalog: [SharedKernel, 'Sales/Domain/Event']
Payments: [SharedKernel, 'Sales/Domain/Event']
Shipping: [SharedKernel, 'Sales/Domain/Event']
The event is the contract. Treat it like a public API: change it carefully, version it when needed, document it.
Start with the sync transport. You do not need RabbitMQ on day one. Use 'sync://' and dispatch events synchronously in the same request. The point is that the shape of the communication is message-based, so when you do move to a real broker the application code does not change. Just the routing config.
5. Give each context its own entity manager (when, and only when, it pays back)
The most aggressive move on this list, and the one to be most careful about: give each context its own Doctrine entity manager (multiple entity managers and 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: ~
Same database, same connection, multiple entity managers. Each one only sees the entities of its context. Doctrine relations across managers do not work, which is exactly what we want: it makes the boundary enforced by the framework, not just by lint.
The reason this is the most aggressive move is that it changes what EntityManagerInterface means in your code. You can no longer just inject EntityManagerInterface and have it work; you have to inject the right manager for the right context. The fix is named DI:
services:
App\Sales\:
bind:
Doctrine\ORM\EntityManagerInterface: '@doctrine.orm.sales_entity_manager'
App\Catalog\:
bind:
Doctrine\ORM\EntityManagerInterface: '@doctrine.orm.catalog_entity_manager'
I do this only when the contexts have demonstrably started to interfere with each other at the persistence level: shared transactions that should not be shared, listeners firing across boundaries unintentionally, schema migrations that touch tables they should not. If the namespacing and the lint rules are working, the entity manager split may not pay back the cost of the change. Do it when the symptoms appear, not pre-emptively.
What to skip
The DDD canon is large. Most of it is not needed. Specifically, in a typical Symfony application I would skip these patterns until you have a concrete reason to need them, and even then I would push back hard:
Aggregate roots. Useful in genuinely complex transactional invariants. If your “aggregate root” does nothing more than a normal entity would, it is decoration.
The full repository pattern wrapping Doctrine. Doctrine repositories are already a perfectly good repository pattern. Wrapping them in your own “repository” interface is double-bookkeeping unless you are deliberately preparing to swap the persistence layer.
Domain services as a category. A “domain service” is a class. Just call it a class. Naming it OrderPricingDomainService does not give it special powers.
Application services for everything. A controller calling a handler is fine. You do not need a third layer between them named “application service” unless you have multiple entry points (HTTP and CLI and queue) hitting the same logic, in which case you have a use case for one, not for thirty.
Read models everywhere. CQRS-style read model separation is excellent when your read patterns and write patterns are genuinely incompatible. It is friction otherwise. Add a read model when querying through your entities becomes painful, not before.
The test for whether to add one of these patterns is simple. Can you describe, in one sentence, the concrete problem the pattern solves in your application? If you cannot, leave it out. You can always add it later.
A worked example, end to end
Let me walk through what placing an order looks like across four contexts, with all five moves applied.
The user submits a checkout form. The Sales context handles the 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);
}
}
The 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 has a SalesStockChecker (the anti-corruption adapter from move 3) and a listener that decrements inventory on OrderPlaced. Payments has a listener that creates a charge. Shipping has a listener that creates a manifest. None of them know about each other. None of them know how Sales stores its orders. All four can be modified independently.
The whole picture, from a Sales perspective, is: “I asked for stock, I saved an order, I published an event.” From a Catalog perspective: “I implemented the stock contract Sales asked for, I subscribed to the event Sales published.” From a Payments and Shipping perspective: “I subscribed to the event Sales published.”
That is bounded contexts in Symfony. No aggregate roots. No DSL. No 600-page book.
Common failure modes
I have seen this approach go wrong in three specific ways:
Treating the SharedKernel as a junk drawer. The SharedKernel is supposed to be tiny: identifiers, money, primitive value objects that genuinely have one meaning everywhere. The first time someone puts a “shared service” in there because “every context needs it,” the boundary starts to dissolve. Push back hard. If two contexts both need a thing, ask whether they need the same thing or whether each needs its own version.
Letting events drive everything synchronously, forever. Starting with sync:// is right. Staying on sync:// for everything as the system grows is wrong. Once the order placement chain is doing twelve things synchronously, every single one of those things is on the request path, and the user is waiting for all of them. Move to async at the points where the user does not need the result before responding.
Refactoring the entire monolith at once. You do not need to draw context boundaries everywhere on day one. Pick one boundary that hurts (the one where two teams collide most often), apply the five moves to that boundary, run with it for a quarter, then pick the next one. The big-bang refactor is a project that gets cancelled in month four.
When this approach does not apply
Bounded contexts are overhead. The overhead pays back when the application has multiple teams, multiple business areas, or genuinely conflicting vocabulary. It does not pay back when:
- The application is small (under 30k lines, one team). Just write clean Symfony. Avoid premature structure. You will know when you need contexts; the symptom is that two features collide on the same class for the third time.
- The business has not yet found its shape. Bounded contexts are a way to encode a stable understanding of the business. If the business model is changing every six weeks, encoding the previous version into the architecture will slow you down.
- The team does not have the appetite for the upfront cost. This is real. Deptrac violations are a budget item. Naming arguments are a budget item. If the team needs to ship fast and the existing structure works, do not add contexts because a blog post said they were good. Add them when the alternative is worse.
For everyone else, this is the leanest version of bounded contexts I have found that survives contact with a real Symfony codebase.
If you have a Symfony application where two teams keep stepping on each other and the same handful of classes show up in every PR, my business alignment engagement is built around exactly this work. A four-week mapping of the contexts you actually have versus the ones you need, then a sequenced extraction at the cadence the team can sustain.
References
- DomainDrivenDesign by Martin Fowler : Fowler’s overview of Eric Evans’ approach and why the strategic patterns matter more than the tactical ones.
- BoundedContext by Martin Fowler : the canonical short explanation of why one word can mean different things in different contexts.
- Anti-corruption Layer (Microsoft Azure Architecture Center) : the pattern catalogue entry, attributed to Evans’ original definition.
- Symfony Messenger Component : official docs for the message bus used in move 4.
- Doctrine: Multiple Entity Managers and Connections : Symfony docs covering the configuration used in move 5.
- Deptrac : the static analyser used to enforce cross-namespace rules in move 2.
- Doctrine mapping configuration (Symfony reference) : YAML reference used for the per-context mapping setup in move 1.