DDD · Integration

Talking Across
Bounded Contexts

Once a Bounded Context boundary is real, the next question is unavoidable: how does one BC ask another for something, or tell it that something happened? There are two honest answers, and picking the wrong one for the situation is how a distributed system becomes a distributed monolith.

When one Bounded Context needs another, within the same process, there are two approaches: synchronous, through an Adapter, or asynchronous, through an Integration Event. Everything else (message brokers, sagas, choreography versus orchestration) is a variation on that choice. Getting it right per use case is what keeps BCs independently deployable instead of secretly coupled through a shared transaction.

The Decision, as Four Questions

Does the current request's response need data from the external BC right now? If yes, you need a synchronous call. Does the called BC change state, or just get read? A state change through a synchronous call means you're one step away from wrapping two BCs in a single transaction, usually a sign to reconsider. Must the current transaction roll back if the external call fails? If not, and eventual consistency is acceptable, that's a strong signal toward async. And is the call direction fundamentally one-way, like "notify whoever's listening"? That's an event, not a request.

If a state change (not a read) is needed in an external BC, don't wrap the two BCs in one transaction via a synchronous call. Let each BC process it independently, through an Integration Event.

Synchronous: The Adapter Pattern

Used when you need to look something up immediately, within the current request, from an external BC's service. An order-detail response that needs to include the user's name, or a balance check before processing a payment; both need an answer before the current request can finish.

[Order BC Application] → UserAdapter (interface) → UserAdapterImpl → [User BC Service]
                         (my application/adapter/)  (my infrastructure/)

The Adapter acts as an Anticorruption Layer. Even if the external BC's model or interface changes shape, the internal domain model on this side is unaffected; the Adapter is where that translation happens, once, instead of scattered across every call site. Two things to watch for. Never inject an external BC's Repository or Service directly into the Application layer (always go through the Adapter interface), and never call an external BC's write methods through an Adapter. If a write is needed, that's the signal to switch to an Integration Event instead.

This is precise enough to check mechanically. My example project implements the same backend design in five languages side by side, and each language's architecture checker has a no-cross-bc-repository-in-application rule that flags any Application-layer file that directly imports another BC's Repository. Importing a Repository within the same domain, the normal pattern, isn't a target.

Asynchronous: Integration Events

Used when, after this BC's own domain work completes, an external BC needs to react and change its own state. After an order is cancelled, the Payment BC needs to process a refund; after an order completes, the Notification BC needs to send an email. Neither of those needs to block the original request.

[Order BC] → Domain Event → Application EventHandler → Integration Event → Outbox → message queue
                                                                                      ↓
                                                              [Payment BC] ← IntegrationEventController

An Integration Event never exposes an internal Domain Event to the outside as-is. The Application EventHandler is the conversion point, the same anticorruption idea as the Adapter but running in the opposite direction. And because the receiving side must assume at-least-once delivery, it implements handling idempotently, the same discipline any reliable event-driven design requires.

A Compensating Action

The Payment BC checks the account's active status and balance via a synchronous Adapter, then marks the payment complete (publishing payment.completed.v1). The Account BC subscribes to that event and performs the deduction. There's a brief eventual-consistency window between the synchronous check and the asynchronous deduction, and that gap is an accepted, explicit design decision, not an oversight.

If the payment is later cancelled (payment.cancelled.v1), the Account BC subscribes the same way and runs a compensating credit that reverses the amount already deducted. It's not a transaction rollback but a classic cross-BC compensating transaction: a new asynchronous event that offsets an earlier state change instead of undoing it in place. Refund approval (refund.approved.v1) reuses the same reaction. In the Go implementation the two halves sit in different places. internal/application/event/payment_cancelled_event_handler.go is the Payment side: it turns the domain event into the PaymentCancelledV1 Integration Event defined in internal/application/integration-event/ and publishes it as payment.cancelled.v1. The Account side's reaction is the subscription in cmd/server/main.go, which maps that event to a deposit command.

Mixing Both in One Use Case

A single command handler routinely uses both patterns for different parts of its work. A synchronous lookup covers whatever the response needs right now, and an asynchronous follow-up covers whatever downstream reaction doesn't:

func (h *CancelOrderHandler) Handle(ctx context.Context, cmd CancelOrderCommand) error {
	// 1. A synchronous cross-BC lookup via an Adapter (needed for the response)
	user, err := h.userAdapter.FindUser(ctx, cmd.UserID)
	if err != nil {
		return fmt.Errorf("cancel order: %w", err)
	}
	if user == nil {
		return order.ErrUserNotFound
	}

	o, err := order.FindOne(ctx, h.orderRepository, cmd.OrderID, cmd.UserID)
	if err != nil {
		return fmt.Errorf("cancel order: %w", err)
	}

	if err := o.Cancel(cmd.Reason); err != nil {
		return err
	}

	// 2. save → Domain Event → Integration Event (requesting a refund from the Payment BC is asynchronous)
	return h.orderRepository.SaveOrder(ctx, o)
}

Mapping to Classic Context Map Patterns

If you already know Context Mapping vocabulary, both patterns above are specific implementations of it. An Anticorruption Layer is the Adapter, preventing contamination from an external model. Open Host Service with a Published Language is publishing an Integration Event with an explicit version, like order.cancelled.v1. Conformist is using an external BC's model directly with no Adapter at all. It's not recommended, and usually a sign the boundary was drawn in a hurry. Customer-Supplier tends to show up as a combination of both patterns together, which is what the compensating-action example above is.

None of this requires a message broker to start. Even inside a single deployable, keeping the same discipline (an Adapter interface for lookups, an event contract for reactions) is what makes splitting a BC out into its own service later a refactor instead of a rewrite.

Further reading

docs/architecture/cross-domain-communication.md (the full decision table and Context Map mapping, in the example project) · payment_cancelled_event_handler.go (where the Payment side publishes payment.cancelled.v1) · cmd/server/main.go (the Account side's subscription that runs the compensating credit)