Bridges Ask, Events Tell
The first version of this backend had one project that was allowed to know about every module. All the translation between modules lived there, which sounded tidy: one seam, one place to look. It ended up seeing every project and referencing everything, and it was hard to maintain and easy to get wrong. About three months in, we removed it.
What replaced it is much stricter about what each project can see, and it comes down to two moves:
When a module needs a fact from another module right now, it asks through a bridge. When something happened that others might care about, it tells them with an integration event.
Both build on the previous post: to a module, another module is just another adapter.
Contracts are the only door
Every module has a Contracts project. It’s the module’s public surface: the interfaces other modules may call, and the integration events it publishes. Contracts reference nothing but the shared kernel, so they can’t leak a module’s domain types. They speak in Guids, strings and numbers.
Only Infrastructure may reference another module’s Contracts. A module’s Core and Application never see another module at all, and an architecture test fails the build if they do. A module’s Infrastructure sees its own projects and the Contracts of the modules it actually talks to, and nothing else.
Bridges ask
When a customer submits a ticket, Tickets needs details about the topic, which
Catalog owns. It doesn’t go looking for Catalog. It declares what it needs as
its own driven port, IForFetchingTopicDetails, which returns its own
TopicSnapshot with its own TopicId. As far as the use case knows, topics
could come from anywhere.
A bridge adapter in Tickets’ Infrastructure implements that port by calling Catalog’s contract and translating the answer:
internal sealed class FetchTopicDetailsAdapter(IContractForFetchingTopic catalog)
: IForFetchingTopicDetails
{
public async Task<TopicSnapshot?> GetByIdAsync(TopicId id, CancellationToken ct)
{
var topic = await catalog.GetByIdAsync(id.ToPrimitive(), ct);
return topic is null
? null
: new TopicSnapshot(
TopicId.From(topic.Id),
topic.ResponseTime,
topic.IsArchived);
}
}In DDD terms this is an anti-corruption layer. The translation happens at the edge, so Catalog’s model never leaks into Tickets, and if Catalog’s contract changes, only the bridge has to follow.
On Catalog’s side, the contract is implemented by a contract adapter, which is a driving adapter like an endpoint. It turns the call into a command or a query and calls one of Catalog’s own driving ports. So the whole call goes from one module’s driven adapter to another module’s driving adapter:
Tickets use case
→ IForFetchingTopicDetails driven port, owned by Tickets
→ FetchTopicDetailsAdapter bridge, in Tickets.Infrastructure
→ IContractForFetchingTopic in Catalog.Contracts
→ contract adapter in Catalog.Infrastructure
→ Catalog's driving port and use caseIt’s a lot of hops for a method call. In return, both modules can change their insides freely, as long as the contract in the middle stays the same.
We’ve had to rely on that. We merged two modules into one, with a new name and
a new data structure, and the module on the other end of the bridge never
knew. Its use cases, ports and tests stayed the same, and so did its bridge,
apart from one using. The contract kept its name and shape, and only its
namespace changed with the module’s new name. All the work happened on the
other side of the contract.
That only worked because the contract described what the consumer needed, not how the provider stored it.
If it had exposed the old data structure, the merge would have leaked into every module that used it.
Events tell
Bridges are for asking another module for facts right now. When a module wants to tell the others that something happened, it uses an integration event instead.
Integration events aren’t the same as the domain events from the first post. Domain events are private to a module and use its domain types. Integration events are the public messages between modules, published from Contracts, and they only carry primitives.
The translation happens when a use case commits. The unit of work behind the
driven port IForCommittingChanges collects the domain events from every
aggregate that changed, and maps the ones other modules care about to
integration events:
private static IntegrationEvent? ToIntegrationEvent(DomainEvent domainEvent) =>
domainEvent switch
{
TicketSubmitted e => new TicketSubmittedIntegrationEvent(
e.TicketId.ToPrimitive(),
e.TopicId.ToPrimitive(),
e.DueAt),
TicketReplied e => new TicketRepliedIntegrationEvent(…),
_ => null,
};The outbox
The integration events aren’t sent right away. They’re written as rows in an outbox table, in the module’s own database, in the same transaction as the state change:
public async Task CommitAsync(CancellationToken ct)
{
var domainEvents = CollectDomainEventsFromChangedAggregates();
var integrationEvents = domainEvents
.Select(ToIntegrationEvent)
.OfType<IntegrationEvent>();
database.AddToOutbox(integrationEvents);
// the state change and the outbox rows commit together
await database.SaveChangesAsync(ct);
// then handlers inside this module react to the domain events
await dispatcher.DispatchAsync(domainEvents, ct);
}Either both the ticket and its event are saved, or neither is. A saved ticket can’t lose its event, and no event goes out for a ticket that was rolled back.
A background service polls the outbox and publishes the rows that haven’t been processed yet. The publisher is another driving adapter: a background service that wakes up at short intervals and calls a use case.
An outbox gives at-least-once delivery, not exactly-once. If the publisher crashes after an event has gone out but before its row is marked as processed, the event goes out again on the next round. Every handler has to cope with seeing the same event twice.
For more depth, Oskar Dudycz’s Outbox, Inbox patterns and delivery guarantees explained covers the delivery guarantees properly, and Kamil Grzybek’s modular monolith sample is a complete .NET application with modules, integration events and an outbox.
The receiving side
In the receiving module, an integration event handler is one more driving adapter. It turns the event into a command and calls its own module’s port:
internal sealed class OnTicketSubmittedCreateAssignment(ICreateTicketAssignmentPort port)
: IIntegrationEventHandler<TicketSubmittedIntegrationEvent>
{
public Task Handle(TicketSubmittedIntegrationEvent e, CancellationToken ct) =>
port.HandleAsync(
new CreateTicketAssignmentCommand(e.TicketId, e.TopicId, e.DueAt),
ct);
}Handler names start with the event and end with the reaction, like
OnTicketSubmittedCreateAssignment, so a folder of handlers reads like a list
of everything the module reacts to.
The use case handles duplicates. Creating an assignment first checks whether the ticket already has one, and does nothing if it does:
var existing = await assignments.ReadAsync(ticketId, ct);
if (existing is not null)
return;On its own, that check could race: two copies of the same event, handled at the same moment, would both find nothing and both create an assignment. Two things rule that out. Only one instance publishes from the outbox, so duplicates arrive one after another. And the assignments table has a unique constraint on the ticket id, so a duplicate that got past the check would fail instead of creating a second assignment.
Neither module’s Application or Core knows the other exists. Tickets only knows that it published an event, and Assignments only knows that it received one.
How we got here
This part of the design changed the most. Removing the central project meant
moving each bridge into the Infrastructure of the module that needs it, each
integration event handler into the module that reacts to it, and each
module’s contracts into its own Contracts project. A bridge implements the
consumer’s port, so it belongs with the consumer’s other adapters. Contract
interfaces also got the IContractFor… prefix, so they can’t be mistaken for
ports.
Integration events started out in memory, dispatched right after the commit. That was enough to get modules talking to each other, but an event dispatched in memory is lost if the process dies at the wrong moment. The outbox arrived about seven weeks later.
The outbox only covers integration events, though. Handlers inside a module still run in memory after the commit, so they have the same problem: if the process dies between the commit and the dispatch, the change is saved but nothing inside the module reacts to it. That gap is still open.
Asking or telling?
Ask when you need a fact to make a decision right now, and tell when something happened that others might care about. Submitting a ticket needs the topic’s response time to set a deadline before the ticket can exist, so Tickets asks. Assignments doesn’t need to be part of that decision, it just needs to hear about the result, so Tickets tells.
The next and last post goes back inside the use case, to the moment where these primitives turn into domain types.