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 exactly two honest answers, and picking the wrong one for the situation is how a distributed system quietly 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 actually 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 — if 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 genuinely needed, that's the signal to switch to an Integration Event instead.

This is exactly precise enough to check mechanically: every language's harness 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, exactly the same discipline covered in reliable event-driven design generally.

A Real 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 actual 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 — 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 exact same reaction. The real implementation lives at implementations/go/examples/internal/application/event/payment_cancelled_event_handler.go, reacting to the PaymentCancelledV1 Integration Event defined in internal/application/integration-event/.

Mixing Both in One Use Case

A single command handler routinely uses both patterns for different parts of its work — a synchronous lookup for whatever the response needs right now, and an asynchronous follow-up for 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 — 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 exactly 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 — a real Adapter interface for lookups, a real event contract for reactions — is what makes splitting a BC out into its own service later a refactor instead of a rewrite.

Further reading in the repo

docs/architecture/cross-domain-communication.md — the full decision table and Context Map mapping · payment_cancelled_event_handler.go — the real compensating-credit reaction