Skip to content
HN On Hacker News ↗

Crossing Boundaries with Integration Events

▲ 17 points 0 comments by alembic_fumes 7d ago HN discussion ↗

Pangram verdict · v3.3

We believe this text is mainly AI, with some human-written content.

92 %

AI likelihood · overall

AI
2% human-written 98% AI-generated
SEGMENTS · HUMAN 0 of 1
SEGMENTS · AI 1 of 1
WORD COUNT 1,448
PEAK AI % 94% · §1
Analyzed
Sep 2
backend: pangram/v3.3
Segments scanned
1 windows
avg 1448 words each
Distribution
2 / 98%
human / AI fraction
Verdict
AI
Pangram v3.3

Article text · 1,448 words · 1 segments analyzed

Human AI-generated
§1 AI · 94%

This article is a part of an ongoing series on Domain-Driven Design.You can check out the rest of the series here. This article is about everything that has to go right for one bounded context to tell another that something happened - and how to design for the ways it goes wrong. In Modeling Facts and Reactions with Domain Events, we saw how a domain event records a meaningful business fact inside the model where that fact became true. It can trigger local reactions, but its name, types, and payload belong to that domain model and are free to evolve with it. We can think of domain events as internal events. Here we cover what happens when a fact must cross into another bounded context: a model with its own language and responsibilities. Crossing the boundary Suppose Ordering and Fulfillment are independently owned and deployed contexts, and Fulfillment needs to prepare a newly placed order. They need a stable public contract containing the business information that Fulfillment requires, without coupling it to Ordering’s internal model. This contract is an integration event, which we can think of as an external event. Integration events travel over durable asynchronous messaging, using infrastructure such as Apache Kafka, RabbitMQ, or Azure Service Bus, so Ordering can finish without waiting for Fulfillment. Each context can process work at its own pace, temporary outages do not have to propagate back to the producer, and messages can remain available until a consumer is ready to handle them. These benefits come with new challenges: either context may be unavailable, messages may be delayed or duplicated, and the two contexts cannot share a database transaction. We therefore have two problems to solve: Designing the contract Delivering it reliably Designing the contract for the boundary Don’t publish your domain model Suppose that, in this model, placing an order makes it ready to enter Fulfillment, and Ordering raises this domain event when that happens: OrderPlaced orderId: OrderId customer: CustomerId items: List<OrderItem> placedAt: Timestamp OrderItem productId: ProductId quantity: Quantity This event is designed for Ordering’s internal handlers. ProductId, Quantity, and the other typed identifiers are value objects that belong to Ordering’s model and are free to change with it. Publishing the event directly turns those internals into a public contract - probably the most common mistake in event-driven integrations. It feels like reuse: the event already exists, why not share it? But now every consumer depends on Ordering’s model, and every refactor made for Ordering’s own needs starts breaking other teams. The payload is also a poor fit for the boundary: it exposes customer, which Fulfillment does not need, and describes products using Ordering’s internal identifiers. Instead, Ordering can translate the same fact into an integration event designed for the Fulfillment context: { "metadata": { "type": "order-ready-for-fulfillment", "version": 1, "messageId": "01JZ2Q5Y7M8K9N0P1R2S3T4V5W", "occurredAt": "2026-08-29T09:42:18Z", "orderEventSequence": 4 }, "body": { "orderId": "ord-8472", "items": [ { "productCode": "CHAIR-BLK", "quantity": 2 }, { "productCode": "DESK-OAK", "quantity": 1 } ] } } The envelope separates transport and contract metadata from the business payload. metadata identifies the event contract and carries information used to route, deduplicate, and interpret the message; body contains the fact consumed by Fulfillment. The orderEventSequence is a producer-assigned counter per orderId - we’ll see why it’s useful later on. The exact JSON shape is not essential. Some messaging platforms place metadata in headers, and standards such as CloudEvents define their own envelope fields. What matters is that the separation is semantic and consistent across producers and consumers - a uniform envelope lets messaging infrastructure handle concerns such as tracing and deduplication without understanding each body. The OrderPlaced domain event and this integration event describe the same occurrence without sharing a name or shape. The integration event carries stable, serialized values instead of Ordering’s value-object classes, and drops customer, which Fulfillment does not need. Ordering owns this boundary-specific contract, and another boundary may need a different integration event derived from the same domain event. Keeping the payload to the facts the consumer needs limits coupling: the fewer producer-internal details it exposes, the less a producer-side refactor can ripple out. How much data to carry, versus letting the consumer fetch it, is its own tradeoff, which we cover below. An integration event is a published interface. Once another context depends on it, changes carry the same compatibility concerns as changes to a versioned REST or gRPC API. Its schema, field meanings, guarantees, and versioning policy become part of that interface. Business facts rather than internal state changes Names can leak the internal domain model just as easily as payloads. Suppose Fulfillment publishes the following event for Ordering: FulfillmentStatusChanged orderId oldStatus: PICKING // or even worse - numeric keys instead of strings newStatus: DISPATCHED This contract makes Ordering understand Fulfillment’s statuses and reproduce part of its state machine. It may expose every transition when Ordering cares about only a few. Changing a status can then affect every consumer, even when the business fact they need has not changed. The contract couples other contexts to Fulfillment’s internal model and leaks implementation details. Asynchronous messaging does not remove coupling by itself; the contract still needs to express the business meaning. The same interaction can instead be expressed as the fact that matters to Ordering: OrderDispatched orderId dispatchedAt OrderDispatched communicates what happened without requiring Ordering to interpret Fulfillment’s internal lifecycle. FulfillmentStatusChanged is not inherently wrong when a status change is itself meaningful domain language; it becomes problematic when it merely synchronizes fields or exposes implementation details. Data events are not integration contracts Event sourcing and Change Data Capture produce their own streams of events, and it is tempting to treat them as ready-made integration events. They are not. An event-sourced event is a persistence record used to reconstruct internal state. It may express a genuine business fact such as OrderPlaced, but its name, schema, and evolution still serve the producer’s model and persistence needs. A change-data-capture stream is lower-level: it is a row-level feed of database mutations that describes how stored data changed, usually without expressing why it changed in business terms. Both streams are internal contracts, not public integration contracts. A CDC record such as OrderRow.status changed from 2 to 5 forces consumers to know that status 5 means “fulfilled” and to infer a business fact from a database mutation. Exposing an event-sourced stream creates similar coupling at a higher semantic level: consumers become dependent on records that the producer may need to split, merge, or reshape as its model evolves. Both streams can instead serve as internal sources from which integration events are derived. When the consumer needs more data A consumer often needs data owned by the producing context to act on the fact. There are three common approaches, each with a different tradeoff between autonomy and coupling: Event-carried state transfer: each event includes the producer-owned data needed to react to that fact. The consumer can act without calling the producer, at the cost of larger payloads and duplicated data. The order-ready-for-fulfillment event above takes this approach: it carries the items so Fulfillment never has to ask. Thin notification plus callback: the event carries identifiers, and the consumer queries the producer for details via the producer’s public API. This keeps payloads small but creates a runtime dependency and may return data that has changed since the event occurred. It fits when many consumers each need different data: rather than one payload bloated to satisfy everyone, each consumer fetches only what it needs. Local replica: the consumer builds a read model by processing a public integration-event stream from the producer - for example, Fulfillment maintaining a local product table from a Catalog context’s events, so it can look up weights and dimensions without calling Catalog per order. It can query that data without depending on the producer’s availability, but must operate and synchronize additional storage and tolerate replication lag. Whichever approach we choose, notification does not transfer ownership: the producing context remains authoritative for its data. Translation at the edge Translation from an internal fact to a public contract can happen at the boundary rather than inside the aggregate: The application or infrastructure layer maps the domain event to an integration event. The aggregate remains unaware of the integration contract and raises only its domain event. A domain event may produce no integration event when the fact does not need to leave the bounded context. A single domain event may also produce more than one integration event. on OrderPlaced event: message = OrderReadyForFulfillmentV1( messageId = newId(), orderId = event.orderId, items = event.items.map(item -> { productCode = productCodeFor(item.productId), quantity = item.quantity.value }), occurredAt = event.occurredAt, orderEventSequence = nextSequenceFor(event.orderId)) publish(message) // this line is about to become a problem