Loading learning content...
In the previous modules, we explored events within a single application—domain events raised by aggregates, handlers processing them, and event buses coordinating delivery. These patterns work beautifully when everything lives in the same process, shares the same memory, and operates under a single deployment boundary.
But real-world systems rarely exist in isolation. Modern software architectures span multiple services, teams, programming languages, and even organizations. The moment events need to leave the cozy confines of a single application and travel to another system, everything becomes more complex.
This page examines the fundamental challenges and patterns for events that cross boundaries—whether those boundaries are between microservices, between bounded contexts, between organizations, or between synchronous and asynchronous worlds.
By the end of this page, you will understand: the different types of boundaries events must cross, the fundamental challenges of cross-boundary event communication, architectural patterns for designing events at boundaries, and how to maintain loose coupling while enabling integration across system boundaries.
Before we can effectively design events that cross boundaries, we must understand what boundaries actually are and why they exist. A boundary is any demarcation that separates one part of a system from another, creating different contexts, ownership, or execution environments.
Boundaries exist for excellent reasons:
| Boundary Type | Description | Event Challenges | Example |
|---|---|---|---|
| Process Boundary | Different operating system processes, possibly on same machine | Requires serialization; no shared memory; network or IPC overhead | Sidecar proxy receiving events from main application |
| Service Boundary | Different microservices with dedicated responsibilities | Network latency; partial failures; independent deployments | Order service publishing OrderPlaced to Inventory service |
| Bounded Context Boundary | Different domain models with distinct ubiquitous languages | Translation required; anti-corruption layers; context mapping | Sales context's 'Customer' vs Support context's 'User' |
| Team Boundary | Different organizational units with independent ownership | Coordination overhead; versioning contracts; communication overhead | Platform team events consumed by multiple product teams |
| Organization Boundary | Different companies or legal entities | Trust issues; contractual obligations; external API stability | Payment gateway publishing transaction events to merchants |
| Temporal Boundary | Events processed at different times than produced | Ordering challenges; eventual consistency; replay scenarios | Analytics processing yesterday's events today |
Every boundary you cross adds latency, potential failure modes, and complexity. The goal is not to eliminate boundaries—they provide essential organizational and technical benefits. The goal is to design events that cross boundaries cleanly, with clear contracts and minimal coupling.
The first boundary to understand is the process boundary—the fundamental divide between events that stay within a single running application and events that must traverse the network to reach other systems.
In-process events enjoy significant advantages:
Cross-process events face significant challenges:
1234567891011121314151617181920212223242526
// In-process event: uses direct object referencesinterface OrderPlacedEvent { readonly orderId: string; readonly customer: Customer; // Direct object reference readonly items: OrderItem[]; // Complex nested objects readonly placedAt: Date; // Native Date object readonly metadata: Map<string, unknown>; // Complex collection} // Handler receives the exact same object instanceclass InventoryReservationHandler { handle(event: OrderPlacedEvent): void { // Can directly access nested objects const customerId = event.customer.id; const customerTier = event.customer.loyaltyTier; // Can use complex types directly for (const item of event.items) { this.reserveInventory(item.productId, item.quantity); } // Can access native JavaScript types const dayOfWeek = event.placedAt.getDay(); const hasExpressShipping = event.metadata.get("expressShipping"); }}1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950
// Cross-process event: must use primitive/serializable typesinterface OrderPlacedIntegrationEvent { readonly eventId: string; // Unique identifier for idempotency readonly eventType: "OrderPlaced"; // Explicit type discriminator readonly version: 1; // Schema version readonly timestamp: string; // ISO 8601 string, not Date object // All data must be serializable primitives readonly payload: { readonly orderId: string; readonly customerId: string; // Just the ID, not full object readonly customerTier: string; // Primitive representation readonly items: ReadonlyArray<{ readonly productId: string; readonly quantity: number; readonly unitPriceInCents: number; // Avoid floating point }>; readonly totalAmountInCents: number; readonly currency: string; }; // Metadata for tracing and debugging readonly metadata: { readonly correlationId: string; readonly causationId: string; readonly source: string; };} // Handler receives deserialized data, must validateclass ExternalInventoryHandler { async handle(rawEvent: unknown): Promise<void> { // Must validate and parse incoming data const event = this.parseAndValidate(rawEvent); // Work with primitive types const customerId = event.payload.customerId; // Parse timestamps explicitly const placedAt = new Date(event.timestamp); // Convert cents to dollars if needed const totalDollars = event.payload.totalAmountInCents / 100; } private parseAndValidate(raw: unknown): OrderPlacedIntegrationEvent { // Schema validation, type checking, version handling // This is essential for cross-boundary events }}Cross-process events pay a 'serialization tax'—every piece of data must be convertible to bytes and back. This eliminates object references, complex types, and behaviors. Design integration events with serialization in mind from the start, using primitives, explicit type discriminators, and version information.
One of the most critical distinctions in event-driven architecture is between Domain Events and Integration Events. While related, they serve fundamentally different purposes and should be designed differently.
Domain Events are internal to a bounded context:
Integration Events cross bounded context or service boundaries:
1234567891011121314
// Domain Event: Rich, internal representationinterface CustomerPromotedToVIP extends DomainEvent { readonly aggregateId: CustomerId; readonly customer: Customer; // Full aggregate readonly previousTier: LoyaltyTier; // Domain value object readonly newTier: LoyaltyTier; // Domain value object readonly promotionReason: PromotionReason; // Domain enum readonly eligibleBenefits: Benefit[]; // Complex domain objects} // Uses domain-specific concepts freely// Consumers are within the same bounded context// Can expose complex domain objects// Changes as domain model evolves123456789101112131415161718
// Integration Event: Stable, external representationinterface CustomerTierChangedEvent { readonly eventType: "customer.tier.changed"; readonly version: 1; readonly eventId: string; readonly timestamp: string; readonly payload: { readonly customerId: string; readonly previousTier: "STANDARD" | "GOLD" | "VIP"; readonly newTier: "STANDARD" | "GOLD" | "VIP"; readonly changedAt: string; };} // Uses stable, primitive types// Consumers may be external systems// No internal domain concepts exposed// Versioned for backward compatibilityThe Translation Layer:
A well-designed system maintains a clear separation between domain and integration events. Domain events flow freely within a bounded context, triggering internal handlers. When an event needs to cross boundaries, a translation layer (sometimes called an Anti-Corruption Layer or ACL) transforms the domain event into an integration event.
This translation serves several purposes:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455
// Translation service converts domain events to integration eventsclass CustomerEventTranslator { translate(domainEvent: DomainEvent): IntegrationEvent | null { if (domainEvent instanceof CustomerPromotedToVIP) { return this.translateCustomerPromotion(domainEvent); } if (domainEvent instanceof CustomerDowngraded) { return this.translateCustomerDowngrade(domainEvent); } // Some domain events don't need to be published externally return null; } private translateCustomerPromotion( event: CustomerPromotedToVIP ): CustomerTierChangedEvent { return { eventType: "customer.tier.changed", version: 1, eventId: generateEventId(), timestamp: new Date().toISOString(), payload: { customerId: event.customer.id.value, previousTier: this.tierToString(event.previousTier), newTier: this.tierToString(event.newTier), changedAt: event.occurredAt.toISOString(), }, }; } private tierToString(tier: LoyaltyTier): "STANDARD" | "GOLD" | "VIP" { // Map internal domain enum to stable external strings switch (tier.value) { case LoyaltyTierValue.STANDARD: return "STANDARD"; case LoyaltyTierValue.GOLD: return "GOLD"; case LoyaltyTierValue.VIP: return "VIP"; default: throw new Error(`Unknown tier: ${tier}`); } }} // Usage in application service or handlerclass CustomerEventPublisher { constructor( private translator: CustomerEventTranslator, private integrationEventBus: IntegrationEventBus, ) {} async handleDomainEvent(event: DomainEvent): Promise<void> { const integrationEvent = this.translator.translate(event); if (integrationEvent) { await this.integrationEventBus.publish(integrationEvent); } }}Many domain events are purely internal and should never leave their bounded context. An 'OrderRecalculated' event might trigger internal discount logic but has no relevance to external systems. The translation layer acts as a filter, publishing only events that external consumers need.
Domain-Driven Design introduces Context Mapping—a set of patterns for how bounded contexts relate to each other. These patterns directly influence how events flow between contexts and who controls the event contracts.
Understanding context mapping is essential for event-driven integration because it determines:
| Pattern | Relationship | Event Flow | Schema Control |
|---|---|---|---|
| Shared Kernel | Contexts share a common subset | Shared event definitions used by both | Both teams collaborate on shared events |
| Customer-Supplier | Upstream produces, downstream consumes | Events flow downstream; supplier prioritizes customer needs | Upstream defines; downstream adapts |
| Conformist | Downstream fully conforms to upstream | Downstream uses upstream's events directly | Upstream controls completely; downstream has no input |
| Anti-Corruption Layer | Downstream protects itself from upstream | Downstream translates upstream events to internal model | Upstream controls; downstream translates |
| Published Language | Shared, documented integration language | Well-documented, versioned event contracts | Neutral schema; both sides conform |
| Open Host Service | Upstream exposes stable, public API/events | Upstream publishes stable integration events | Upstream maintains stable public contract |
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869
// External event from another bounded context (Payments)interface PaymentProcessedExternalEvent { event_type: "PAYMENT_COMPLETED"; // Their naming convention payload: { transaction_id: string; order_reference: string; // They call it 'order_reference' amount_cents: number; currency_code: string; payment_method: string; processed_timestamp: string; };} // Our internal domain event (Orders context)interface OrderPaymentReceivedEvent extends DomainEvent { readonly orderId: OrderId; readonly paymentId: PaymentId; readonly amount: Money; readonly paidAt: Date;} // Anti-Corruption Layer translates external to internalclass PaymentACL { translatePaymentCompleted( external: PaymentProcessedExternalEvent ): OrderPaymentReceivedEvent { // Validate external data if (!external.payload.order_reference) { throw new InvalidExternalEventError( "Missing order_reference in payment event" ); } // Translate to our domain language return { eventType: "OrderPaymentReceived", occurredAt: new Date(external.payload.processed_timestamp), aggregateType: "Order", aggregateId: OrderId.from(external.payload.order_reference), // Map to our domain concepts orderId: OrderId.from(external.payload.order_reference), paymentId: PaymentId.from(external.payload.transaction_id), amount: Money.fromCents( external.payload.amount_cents, Currency.from(external.payload.currency_code) ), paidAt: new Date(external.payload.processed_timestamp), }; }} // Integration event handler uses ACLclass PaymentEventConsumer { constructor( private acl: PaymentACL, private domainEventBus: DomainEventBus, ) {} async onPaymentCompleted(raw: unknown): Promise<void> { const externalEvent = this.parseExternalEvent(raw); // ACL translates to our domain language const domainEvent = this.acl.translatePaymentCompleted(externalEvent); // Now we can process using our domain handlers await this.domainEventBus.publish(domainEvent); }}ACLs protect your domain model from external pollution. Without an ACL, external naming conventions, data structures, and concepts leak into your codebase. With an ACL, you translate once at the boundary and your domain stays pure. This makes your system resilient to external changes—only the ACL needs updating.
When events cross process and network boundaries, we enter the realm of distributed systems. The famous Eight Fallacies of Distributed Computing become directly relevant:
Each fallacy creates specific challenges for cross-boundary events:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758
// Integration event with delivery safety featuresinterface RobustIntegrationEvent { // Identity for deduplication readonly eventId: string; // UUID, unique per event readonly idempotencyKey: string; // Business key for deduplication // Ordering information readonly sequenceNumber: number; // Ordering within aggregate readonly aggregateVersion: number; // Aggregate version when raised // Timing for timeout/delay detection readonly timestamp: string; // When event occurred readonly publishedAt: string; // When event was published // Tracing for debugging readonly correlationId: string; // Request correlation readonly causationId: string; // Parent event that caused this readonly source: string; // Publishing service // Standard payload readonly eventType: string; readonly version: number; readonly payload: unknown;} // Robust event handler with idempotencyclass RobustEventHandler { constructor( private eventStore: ProcessedEventStore, private businessLogic: BusinessLogic, ) {} async handle(event: RobustIntegrationEvent): Promise<void> { // Step 1: Check for duplicate processing const alreadyProcessed = await this.eventStore.wasProcessed( event.eventId ); if (alreadyProcessed) { console.log(`Event ${event.eventId} already processed, skipping`); return; } // Step 2: Check for out-of-order delivery const lastSequence = await this.eventStore.getLastSequence( event.aggregateId ); if (event.sequenceNumber <= lastSequence) { console.warn(`Out-of-order event detected: ${event.eventId}`); // Decide: skip, queue for later, or process anyway } // Step 3: Process the event await this.businessLogic.process(event); // Step 4: Record successful processing await this.eventStore.markProcessed(event.eventId, event.sequenceNumber); }}True exactly-once delivery is impossible in distributed systems (proven by the Two Generals Problem). What we implement is effectively-exactly-once: at-least-once delivery combined with idempotent handlers. Events may be delivered multiple times, but processing them multiple times produces the same result.
When events cross boundaries, they become contracts—formal agreements between producers and consumers about what data will be provided and in what format. Just like REST or gRPC APIs, integration events require careful contract design.
Key Contract Considerations:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990
/** * Event Contract: Order Placed * * Published when a customer successfully places an order. * This is a stable, versioned integration event designed for * cross-boundary consumption. * * Consumers: Inventory, Shipping, Notifications, Analytics * * @version 1 - Initial version * @since 2024-01-15 */interface OrderPlacedV1 { // === Identity === /** Unique event identifier (UUID v4) */ readonly eventId: string; /** Event type discriminator */ readonly eventType: "order.placed"; /** Schema version for this event type */ readonly version: 1; // === Timing === /** ISO 8601 timestamp when order was placed */ readonly occurredAt: string; // === Payload === readonly payload: { /** Unique order identifier */ readonly orderId: string; /** Customer identifier (not full customer object) */ readonly customerId: string; /** Order line items */ readonly items: ReadonlyArray<{ readonly productId: string; readonly sku: string; readonly quantity: number; readonly unitPriceInCents: number; }>; /** Order totals */ readonly subtotalInCents: number; readonly taxInCents: number; readonly shippingInCents: number; readonly totalInCents: number; readonly currency: "USD" | "EUR" | "GBP"; /** Shipping information */ readonly shippingAddress: { readonly line1: string; readonly line2?: string; readonly city: string; readonly region: string; readonly postalCode: string; readonly country: string; // ISO 3166-1 alpha-2 }; /** Selected shipping method */ readonly shippingMethod: "STANDARD" | "EXPRESS" | "OVERNIGHT"; }; // === Metadata === readonly metadata: { /** Correlation ID for request tracing */ readonly correlationId: string; /** Source system identifier */ readonly source: "order-service"; /** Environment (for routing/filtering) */ readonly environment: "production" | "staging" | "development"; };} // Type guard for runtime validationfunction isOrderPlacedV1(event: unknown): event is OrderPlacedV1 { if (typeof event !== "object" || event === null) return false; const e = event as Record<string, unknown>; return ( e.eventType === "order.placed" && e.version === 1 && typeof e.eventId === "string" && typeof e.occurredAt === "string" && typeof e.payload === "object" // ... additional validation );}Integration events deserve the same documentation treatment as REST APIs. Document each event type, its purpose, when it's emitted, what data it contains, and which services consume it. Tools like AsyncAPI provide standardized formats for documenting event-driven APIs.
Cross-boundary events are fundamentally different from in-process events. Understanding these differences is essential for building robust, evolvable event-driven systems.
What's Next:
Now that we understand the fundamental challenges of cross-boundary events, we need to address a critical technical concern: event serialization. How do we convert rich domain objects into bytes that can travel across networks and be understood by systems written in different languages? The next page explores serialization formats, strategies, and trade-offs.
You now understand the fundamental challenges of events crossing system boundaries. You can distinguish between domain and integration events, apply context mapping patterns, design robust event contracts, and anticipate the challenges of distributed event delivery. Next, we'll dive into the technical details of event serialization.