Skip to content

Commands vs Events Across Bounded Contexts: Who Owns the Message

Decide which message may cross a bounded context: receivers own command schemas, publishers own event schemas. Default to integration events and name every command.

Ayhan Sipahi Ayhan Sipahi

Teams that publish events usually agree on what a command and an event are. What they often lack is a rule for which of the two may cross a bounded context boundary. Without one, the choice is made ticket by ticket. Each choice looks local, yet each one fixes who owns a schema and which team depends on which.

The rule is that message ownership follows the boundary. The receiver owns a command’s contract, and the publisher owns an event’s contract. Inside one bounded context either works, because one team owns both ends. Across contexts, default to events, and treat every cross-context command as a deliberate, named dependency.

The definitions are covered in the beginner guide to thinking in events and are assumed from here on.

Commands Inside a Context and Across One#

Inside one bounded context, one team owns the sender and the handler. A ReserveStock command and an OrderPlaced domain event are both internal design choices there. Microsoft’s .NET microservices guide treats domain events this way. They stay within the same domain, they may be synchronous or asynchronous, and the guide’s reference application dispatches them in-process. If the choice turns out wrong, one team changes both ends in one pull request.

Across a boundary the same command costs more. The sender must know the receiver’s name, its language, and its schema. It also needs the receiver to act, so the sender’s flow stalls when the receiver is down or refuses. The litmus test in the beginner guide says that a message one specific service must handle is a command. At a boundary, that sentence also describes a design-time and runtime dependency on another team.

Schema Ownership and the Direction of Dependency#

A command has a receiver-owned contract. The handler defines what it accepts, and every sender conforms. There can be many senders, and there is one owner. An event has a publisher-owned contract. The publisher defines the fact, and every subscriber conforms. The NServiceBus documentation states the same split in routing terms: a command is sent to its logical owner, and an event is published by its logical owner. Lutz Hühnken offers it as a suggested criterion: “The schema of an event is defined by the sending service, the schema of a command by the receiving service.”

Ownership sets the direction of the dependency. For a command, that direction is the same as the message flow. For an event, it is the opposite.

command

depends on

event

depends on

Sender context

Receiver context, owns the schema

Publisher context, owns the schema

Subscriber context

With an event, the subscriber depends on the publisher, which is the normal downstream to upstream direction. The publisher does not know its subscribers and keeps working when they fail. The coupling is still there. The subscriber is bound to the publisher’s schema and to what each field means, so an event changes the direction of the dependency and leaves it in place. With a cross-context command, the sender depends on the receiver’s model and on its availability. Oskar Dudycz names the practical difference in his article on passive-aggressive events: a handler can reject a command, while an event can only be ignored. Therefore a command sender needs a rejection path, and a publisher does not.

In design review, two questions settle most arguments about message type: who owns the schema, and which way the dependency points. A message whose schema the receiver defines is a command, whatever its name says.

Owning the schema is different from deciding alone. With consumer-driven contracts, subscribers state which fields they rely on, and the publisher checks each change against those statements. The publisher still owns and versions the schema.

Domain Events and the Public Contract#

A domain event is internal. It is shaped by the aggregate, dispatched in-process, and free to change with every refactor. Microsoft’s guide describes it as something that happened in the domain that other parts of the same domain should know about. An integration event is the context’s public API. The same guide says it propagates committed changes to other microservices, bounded contexts, or external applications. It adds two constraints: publish only after the entity is persisted, and communicate asynchronously.

Because the two kinds serve different audiences, the default at the boundary is a translation step. A domain event handler maps the internal event to a versioned integration event and writes it to an outbox.

Translation at the Boundary#

The sketch below uses only type annotations that Node’s built-in type stripping removes. Saved as translate.ts, it runs on Node 22.18 or later with no build step and no external service.

// translate.ts: domain event (internal) -> integration event (public, versioned)
type OrderPlacedDomainEvent = {
  orderId: string;
  customerId: string;
  lines: { sku: string; qty: number; unitPriceCents: number }[];
  paymentToken: string; // internal detail, must never leave the context
  placedAt: Date;
};

type OrderPlacedV1 = {
  type: "sales.order.placed.v1";
  id: string;
  occurredAt: string;
  data: { orderId: string; customerId: string; totalCents: number };
};

function toIntegrationEvent(e: OrderPlacedDomainEvent, id: string): OrderPlacedV1 {
  const totalCents = e.lines.reduce((sum, l) => sum + l.qty * l.unitPriceCents, 0);
  return {
    type: "sales.order.placed.v1",
    id,
    occurredAt: e.placedAt.toISOString(),
    data: { orderId: e.orderId, customerId: e.customerId, totalCents },
  };
}

const outbox: OrderPlacedV1[] = [];

const domainEvent: OrderPlacedDomainEvent = {
  orderId: "order-42",
  customerId: "cust-7",
  lines: [{ sku: "BOOK-1", qty: 2, unitPriceCents: 1500 }],
  paymentToken: "tok_internal",
  placedAt: new Date("2026-01-01T10:00:00Z"),
};

outbox.push(toIntegrationEvent(domainEvent, "evt-1"));
console.log(JSON.stringify(outbox[0], null, 2));

paymentToken stays inside the context, and lines leaves only as a total, because OrderPlacedV1 has no field for either. The public type carries its version in its name. The Sales team can refactor OrderPlacedDomainEvent freely as long as toIntegrationEvent still returns OrderPlacedV1. A breaking change gets a new type, sales.order.placed.v2, published beside the old one until consumers have moved. The CloudEvents post covers the envelope and the versioning mechanics.

In a service, the array becomes an outbox table written in the same transaction as the order. AWS Prescriptive Guidance names an outbox table and change data capture as the two ways to close the dual-write gap. The outbox pattern post has the implementation.

This default has a price. Each public event needs a second schema, a mapping function, an outbox, and a catalog entry. The mapping can also drift: the domain changes and the mapping silently drops meaning. Consumers may couple to fields the publisher considered incidental, so every public field is a commitment.

The Case for Publishing Domain Events Directly#

The most common alternative is to publish the domain events as they are. Its strongest form is a sound argument: less code, no translation layer, no second schema, and often one team that owns every consumer. It holds when publisher and consumers share a team and a deploy cadence, or when a context has a single internal consumer. In those cases the line between them is a module boundary inside one context.

The cost appears when a second team subscribes. Every aggregate refactor becomes a cross-team breaking change, and consumers wait on the publisher’s release schedule. Internal fields also reach every subscriber. The OrderStartedDomainEvent in Microsoft’s reference application carries CardNumber and CardSecurityNumber. That is reasonable for a handler inside the ordering domain and unsuitable for a shared bus.

Payload Size as a Boundary Decision#

Fowler names two patterns for what an event carries: event notification and event-carried state transfer. Yan Cui ties the choice to the boundary. Inside a domain, light events are fine, because one team owns the publisher and the subscriber. Across domains the publisher does not know who is listening, so a richer event is the prudent choice.

Applied to the two kinds of event, a domain event can stay thin. An integration event carries what a consumer needs to act without calling back, and nothing internal. Personal data belongs in the payload only when a consumer needs it, because every subscriber receives a copy. Microsoft’s guide advises against sharing one integration events library across services. The practical consequence is to publish the schemas and let each consumer generate or write its own types.

Message Types on the Context Map#

Evans’ DDD Reference names the relationships between contexts, and Vernon’s Implementing Domain-Driven Design covers integrating contexts through messaging. The DDD introduction on this site shows those relationships in code. For message design, the map needs one more column: the message type that crosses each edge.

RelationshipContract ownerDefault message acrossWhat the downstream does
Customer/supplierUpstream, with downstream needs in its planningIntegration events from upstream. Commands only to an API the supplier designed for that purposeNegotiates its needs and consumes
ConformistUpstreamUpstream integration events, adopted as they areAccepts that its model follows the upstream model
Anti-corruption layerUpstream for the event, downstream for its own modelIntegration events inTranslates each event into a local command or domain event
Open host service with published languageUpstream, as a documented protocol for many consumersIntegration events from a catalogReads the catalog, with no negotiation
Shared kernel or partnershipBoth teams togetherEither type. Commands cost less hereCoordinates releases with the partner

The anti-corruption layer row is where events come in and commands stay inside. The policy that reserves stock whenever an order is placed belongs to Fulfillment. Fulfillment translates sales.order.placed.v1 into its own ReserveStock command, and Sales never learns that the command exists. Policies of this shape are the ones an event storming session puts on the wall. The Azure Architecture Center lists the costs of the layer: added latency and one more component to operate and scale. It also advises keeping business rules and orchestration out of it.

open host: OrderPlaced v1 event

local command: ReserveStock

named command: ChargeCard

PaymentCaptured v1 event

CustomerRegistered v1 event

Sales

Fulfillment ACL

Fulfillment model

Billing

Notifications

The single command on this map, ChargeCard, is visible as a dependency of Sales on Billing. Every other edge is an event that its publisher owns.

Everything as an Event#

The second common alternative is to model every interaction as a fact. Its argument is that events keep publishers ignorant of consumers, so a system built only from events has the loosest coupling. For fan-out, optional reactions, and analytics, that argument is correct.

It fails for messages such as SendWelcomeEmailRequested, ChargeCardRequested, or ReserveStockRequested. Each has exactly one consumer, and the publisher often waits for a reply event. The publisher needs a specific service to act, so the dependency exists whatever the name says. Fowler describes this trap as an event used as a passive-aggressive command. He argues that a sender who wants the receiver to act should use a command message to show that intention. Dudycz reaches the same conclusion: “Usually, it’s just better to ask someone to do it.”

Renaming to the fact is one fix. Sales publishes CustomerRegistered, and Notifications owns the policy that sends a welcome email. The other fix applies when the receiving context offers the action as a capability. In that case the sender uses the receiver’s own SendEmail command.

Lutz Hühnken proposes a different answer in Not All Commands Are Equal (And Neither Are Events) (opens in new tab) on reactivesystems.eu. He separates commands that expect an answer from fire-and-forget commands. He also separates events whose schema the publisher defines from conformist events. A conformist event has a schema defined by the receiver, for example a notification service, and publishers conform to it. In his view, schema ownership and the ratio of senders to receivers should not force a team into commands. He prefers to model a fire-and-forget command as a conformist event.

I do not adopt that term. When the receiver owns the schema, every sender depends on the receiver’s model, which is the dependency a command has. A reader who sees an event on a context map assumes a publisher-owned fact. Calling the message an event therefore keeps a sender-to-receiver dependency out of sight. The delivery style, fire-and-forget or request and reply, does not change who must conform to whom.

Cross-Context Commands as Named Dependencies#

A cross-context command is the right call in a small set of cases. The first is a process manager or saga orchestrator that must drive steps in order and compensate on failure. Enterprise Integration Patterns describes the process manager as a central unit that maintains the state of the sequence and determines the next processing step. In the AWS saga orchestration pattern, a Step Functions workflow calls the participant services and runs the compensations. The second case is a capability context whose public API is a request: payments, notifications, document generation. The third is any request that the receiver must be able to refuse. The saga pattern post covers compensation design.

Four rules keep such a command explicit:

  • The receiver defines and versions the command schema, and the sender conforms.
  • The command appears on the context map and in the receiver’s catalog.
  • The sender handles rejection and receiver downtime in its own flow.
  • The orchestrator lives in the context that owns the business process. A shared middle layer that orchestrates for everyone has no owner for the process.

The trade-off is a runtime dependency. A receiver outage stalls the sender’s process, the sender must track a schema it does not own, and both teams carry the dependency into reviews. That cost is acceptable when the business process needs a step to happen and needs to know when it did not.

On a queue, three outcomes are separate. A successful SendMessage call means only that the queue accepted the message. The receiver can still refuse the command when it processes it. The business result, such as a captured or declined payment, reaches the sender later as a reply message or an event from the receiver. The sender’s process therefore waits for that result with a timeout.

The diagram below places the default and the override on one path.

No

Yes

Yes

No

Yes

No

Yes

No

Does the message cross a bounded context boundary?

Command or domain event, team choice

Is it a fact the sender would publish with zero consumers?

Integration event, publisher-owned schema, via outbox

Must one named receiver act, and may it refuse?

Does the receiver offer this command in its own API?

Not modelled yet. Find the fact behind the request

Named command on a receiver-owned channel, recorded on the context map

Ask the receiving team to define it, or publish the fact

Channel Ownership on AWS#

On AWS the rule turns into a question about which stack creates the channel. The team that owns the contract should also own the resource that carries it.

MessageContract ownerChannelWho creates itHow access is granted
Cross-context commandReceiverSQS queue, or the receiver’s APIThe receiver’s stackThe queue policy grants sqs:SendMessage to named senders
Integration eventPublisherEventBridge bus or SNS topicThe publisher’s stackSubscribers receive through rules. Across accounts, the receiving bus policy admits the sending account
Domain eventThe context itselfIn-process dispatch or an internal queueThe same teamNothing crosses the boundary
Orchestrated stepReceiverStep Functions task that calls the receiver’s API or queueThe workflow belongs to the context that owns the processThe receiver grants the workflow’s role

The SQS documentation shows the first row as a basic policy example: the queue owner grants another account permission to send. For events across accounts, the EventBridge documentation describes three parts. The receiving account allows the sender on its bus, the sender creates a rule that targets that bus, and the receiver creates rules that match the incoming events. The event fan-out post walks through that topology. The choice between SQS, SNS, and EventBridge is covered in the AWS messaging comparison.

Failure ownership follows the same split. The receiver owns the dead-letter queue and its alarm on a command queue. The sender owns retries and compensation in its process manager.

Common Mistakes at the Boundary#

A command defined by its sender inverts ownership. The sending team invents the shape, the receiver has to accept it, and the receiver cannot evolve its own input. The receiver should publish the command schema.

A publisher that waits for EmailSent after SendWelcomeEmailRequested is running a command with a response. Model it as a command, or as request and response.

Within one version only additive changes are safe. A breaking change made in place should become a new versioned type, published beside the old one while consumers migrate.

An anti-corruption layer should only translate. When it starts to collect business rules, move those policies to the application layer of the receiving context, where that team can test and change them.

A cross-context command that is missing from the context map is a dependency that surfaces only when the receiver is down. Counting the cross-context command channels and checking each one against the map is a short review exercise.

When the Default Holds#

The default is an integration event with a publisher-owned schema. It holds whenever a message crosses a bounded context and describes a fact the sender would publish with no consumers at all. Inside one context, or between contexts owned by one team with one deploy cadence, the translation step can be skipped and commands are cheap. A cross-context command is the override when one named receiver must act and may refuse, as in an orchestrated process or a capability API. In that case the receiver owns the schema and the channel, and the dependency is recorded on the context map. A useful first step is to list every message that crosses a context and write the schema owner next to each one.

References#

Related posts