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.
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.
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.
| Relationship | Contract owner | Default message across | What the downstream does |
|---|---|---|---|
| Customer/supplier | Upstream, with downstream needs in its planning | Integration events from upstream. Commands only to an API the supplier designed for that purpose | Negotiates its needs and consumes |
| Conformist | Upstream | Upstream integration events, adopted as they are | Accepts that its model follows the upstream model |
| Anti-corruption layer | Upstream for the event, downstream for its own model | Integration events in | Translates each event into a local command or domain event |
| Open host service with published language | Upstream, as a documented protocol for many consumers | Integration events from a catalog | Reads the catalog, with no negotiation |
| Shared kernel or partnership | Both teams together | Either type. Commands cost less here | Coordinates 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.
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.
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.
| Message | Contract owner | Channel | Who creates it | How access is granted |
|---|---|---|---|---|
| Cross-context command | Receiver | SQS queue, or the receiver’s API | The receiver’s stack | The queue policy grants sqs:SendMessage to named senders |
| Integration event | Publisher | EventBridge bus or SNS topic | The publisher’s stack | Subscribers receive through rules. Across accounts, the receiving bus policy admits the sending account |
| Domain event | The context itself | In-process dispatch or an internal queue | The same team | Nothing crosses the boundary |
| Orchestrated step | Receiver | Step Functions task that calls the receiver’s API or queue | The workflow belongs to the context that owns the process | The 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#
- Domain events: design and implementation (Microsoft .NET microservices guide) (opens in new tab) - Defines domain events as in-process and same-domain, contrasts them with integration events, and shows a domain event handler that emits an integration event.
- Implementing event-based communication between microservices (integration events) (opens in new tab) - Defines integration events and advises against a shared integration events library across services.
- What do you mean by “Event-Driven”? (Martin Fowler) (opens in new tab) - Event notification, event-carried state transfer, and the passive-aggressive command trap.
- DDD Reference (Eric Evans) (opens in new tab) - Pattern summaries for customer/supplier, conformist, anticorruption layer, open-host service, and published language.
- Implementing Domain-Driven Design (Vaughn Vernon) (opens in new tab) - Book with a chapter on integrating bounded contexts through REST and messaging.
- Messages, events, and commands (NServiceBus documentation) (opens in new tab) - Commands are sent to their logical owner, and events are published by theirs.
- Not All Commands Are Equal (And Neither Are Events) (Lutz Hühnken) (opens in new tab) - Ask and tell commands, supplier and conformist events, and schema ownership as one criterion among several.
- Passive-Aggressive Events (Oskar Dudycz) (opens in new tab) - Events that are commands in practice, and why asking the owner directly is often clearer.
- When to use light events vs rich events (Yan Cui) (opens in new tab) - Light events inside a domain and richer integration events across domains.
- Process Manager (Enterprise Integration Patterns) (opens in new tab) - A central unit that maintains the state of a sequence and determines the next processing step.
- Anti-Corruption Layer pattern (Azure Architecture Center) (opens in new tab) - A translation layer between subsystems, its costs, and the advice to keep business rules out of it.
- Transactional outbox pattern (AWS Prescriptive Guidance) (opens in new tab) - The dual-write problem and the outbox table and change data capture options on AWS.
- Saga orchestration pattern (AWS Prescriptive Guidance) (opens in new tab) - A Step Functions orchestrator that calls participant services and runs compensations.
- Sending and receiving events between AWS accounts (Amazon EventBridge) (opens in new tab) - Receiver-side bus permissions and sender-side rules for cross-account events.
- Basic examples of Amazon SQS policies (opens in new tab) - A queue policy that grants another account permission to send messages.
Related posts
Named signals that justify a Kafka migration from a managed event bus, and a four-phase outbox-anchored playbook to move without rip-and-replace.
messaging · event-driven · aws +4
Choose between SQS, SNS, and EventBridge by communication pattern, not features, with working CDK examples and a clear cost analysis.
sqs · sns · eventbridge +5
Multi-account AWS architecture patterns for resilient event-driven systems: account structure, EventBridge routing, and cross-service communication.
aws · eventbridge · multi-account +5
Learn the mental shift behind event-driven systems: announce facts instead of issuing commands. Covers naming, decoupling, eventual consistency, and idempotency.
event-driven · messaging · idempotency +2
A platform default for multi-team AWS orgs: one event, many consumers, each in its own account with its own SQS and DLQ, and fan-out in the event bus layer.
aws · eventbridge · event-driven +4