Back to Event-Driven Architecture & CQRS
Q4Question 4 of 8

Domain event versus integration event

Key Concepts
  • A domain event is internal to one bounded context, handled in-process, and may use the service's private model.
  • An integration event is published to a broker like Kafka for cross-service communication and its schema is a public contract.
  • Integration events must act as an Anti-Corruption Layer, exposing only curated fields and never the producer's internal model.
  • Use a schema-first format like Avro or Protobuf with a Schema Registry that enforces backward compatibility.
  • Key integration events by the aggregate-root ID so all events for one entity land on the same Kafka partition for ordering.
  • The transactional outbox pattern bridges domain events to integration events reliably within the same database transaction.
  • Publishing raw internal domain events to Kafka is the anti-pattern that couples every consumer to your private model.

Definition

Domain events are private, in-process, and may use your internal model; integration events are public, broker-carried, versioned contracts that shield consumers from your internals and are keyed by aggregate ID for ordering.
  • Domain event – an internal event raised and handled within a single bounded context through a mechanism like Spring Application Events, to trigger side effects across aggregates inside that one service.
  • Integration event – a published, cross-service event carried over a broker like Kafka, whose schema is a public contract that consumers depend on.
  • Anti-Corruption Layer – the deliberate translation from the internal model to a curated external schema at the service boundary, so internal refactoring never breaks consumers.
  • Schema governance – integration events use Avro or Protobuf with a Schema Registry that enforces compatibility rules.
  • Partition key – keying by the aggregate-root ID (account ID) ensures per-entity ordering in Kafka.

Inside National Bank of India's transfer department, when a transfer completes, a note is passed between desks in the same room: "transfer done, update the daily tally, recompute the fee." That note is written in the department's own shorthand because only people in that room read it. That shorthand note is a domain event. When the department wants to tell the SMS team and the statement team in other buildings, it fills out an official, standardized form with only the fields outsiders are allowed to see, versioned so old forms still work. That official form is an integration event, and the rule that you never show outsiders your private shorthand is the Anti-Corruption Layer.

Understanding the Concepts

What is a domain event?

A domain event lives entirely inside one bounded context, which is one microservice. It represents something meaningful that happened in the domain, like TransferCompleted, and it exists to decouple side effects within that service. Instead of the transfer aggregate directly calling the fee recalculation and the daily-tally update, it publishes a domain event and lets in-process listeners react.

JAVA
package com.bank.domain.event;

import java.math.BigDecimal;
import java.time.Instant;

// Domain event: internal to the Transfer Service
public record TransferCompletedDomainEvent(
        String transferId,
        String fromAccount,
        String toAccount,
        BigDecimal amount,
        BigDecimal fee,
        String internalLedgerRef,   // Internal detail, never exposed
        String riskBucket,           // Internal detail, never exposed
        Instant completedAt
) {}
JAVA
package com.bank.service;

import com.bank.domain.event.TransferCompletedDomainEvent;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.time.Instant;

@Service
public class TransferService {

    private final ApplicationEventPublisher eventPublisher;

    public TransferService(ApplicationEventPublisher eventPublisher) {
        this.eventPublisher = eventPublisher;
    }

    public void completeTransfer(String transferId, String from,
                                  String to, BigDecimal amount) {
        // ... business logic ...
        BigDecimal fee = amount.multiply(new BigDecimal("0.005"));

        // Publish domain event in-process
        eventPublisher.publishEvent(new TransferCompletedDomainEvent(
                transferId, from, to, amount, fee,
                "LEDGER-REF-9987", "LOW_RISK", Instant.now()));
    }
}
JAVA
package com.bank.listener;

import com.bank.domain.event.TransferCompletedDomainEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionalEventListener;

@Component
public class InternalSideEffectListener {

    @TransactionalEventListener
    public void onTransferCompleted(TransferCompletedDomainEvent event) {
        // Internal side effects: update daily tally, recalculate fees
        // These run in-process after the transaction commits
        updateDailyTally(event.fromAccount(), event.amount());
        recalculateFeeCache(event.fromAccount());
    }

    private void updateDailyTally(String accountId, java.math.BigDecimal amount) {
        // Internal logic
    }

    private void recalculateFeeCache(String accountId) {
        // Internal logic
    }
}
TEXT
// Domain event:
// - Published via ApplicationEventPublisher
// - Handled by @TransactionalEventListener (fires after commit)
// - Carries internal fields: internalLedgerRef, riskBucket
// - Never leaves the Transfer Service process

What is an integration event?

An integration event is published to a broker like Kafka specifically so other services, owned by other teams, can react. The moment an event crosses the service boundary, its schema becomes a contract exactly like a public API.

JAVA
package com.bank.event.integration;

import java.math.BigDecimal;
import java.time.Instant;

// Integration event: public contract, no internal details
public record TransferCompletedIntegrationEvent(
        String eventId,
        String transferId,
        String fromAccount,
        String toAccount,
        BigDecimal amount,
        String currency,
        Instant completedAt,
        int schemaVersion
) {}
// Note: NO internalLedgerRef, NO riskBucket
// Only fields consumers legitimately need

Why must integration events act as an Anti-Corruption Layer?

If you publish your internal model directly, every refactor of your internals becomes a breaking change for every consumer. The Anti-Corruption Layer translates from the internal model to a curated external representation at the boundary.

JAVA
package com.bank.translator;

import com.bank.domain.event.TransferCompletedDomainEvent;
import com.bank.event.integration.TransferCompletedIntegrationEvent;
import java.util.UUID;

public class EventTranslator {

    // Anti-Corruption Layer: translates internal → external
    public static TransferCompletedIntegrationEvent toIntegration(
            TransferCompletedDomainEvent domain) {
        return new TransferCompletedIntegrationEvent(
                UUID.randomUUID().toString(),
                domain.transferId(),
                domain.fromAccount(),
                domain.toAccount(),
                domain.amount(),
                "INR",
                domain.completedAt(),
                1  // schema version
        );
        // Deliberately excludes: internalLedgerRef, riskBucket, fee
        // Consumers never see the private internals
    }
}
DIAGRAM
// WRONG: publishing raw domain event to Kafka
// Consumers see: internalLedgerRef, riskBucket → coupled to internals
// Rename riskBucket → breaks all consumers

// CORRECT: translating to integration event
// Consumers see: transferId, fromAccount, toAccount, amount, currency
// Rename riskBucket internally → zero impact on consumers

How do you use Avro with Schema Registry?

Avro provides a schema-first, binary format. The Schema Registry enforces that new versions are backward compatible so old consumers can still read new events.

JSON
{
  "type": "record",
  "name": "TransferCompleted",
  "namespace": "com.bank.events",
  "fields": [
    {"name": "eventId", "type": "string"},
    {"name": "transferId", "type": "string"},
    {"name": "fromAccount", "type": "string"},
    {"name": "toAccount", "type": "string"},
    {"name": "amount", "type": "string"},
    {"name": "currency", "type": "string", "default": "INR"},
    {"name": "completedAt", "type": "long"}
  ]
}
DIAGRAM
// Schema Registry compatibility modes:
// BACKWARD: new schema can read old data (add fields with defaults)
// FORWARD: old schema can read new data (remove optional fields)
// FULL: both backward and forward compatible
// Adding a field with a default → backward compatible → safe
// Removing a required field → breaking → Schema Registry rejects it

Why key integration events by the aggregate-root ID?

Keying by the aggregate-root ID, such as the account ID, ensures all events for one aggregate land on the same Kafka partition. This guarantees per-aggregate ordering, which matters because out-of-order events about one account could corrupt derived state.

JAVA
package com.bank.publisher;

import com.bank.event.integration.TransferCompletedIntegrationEvent;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.stereotype.Service;

@Service
public class IntegrationEventPublisher {

    private final KafkaTemplate<String, TransferCompletedIntegrationEvent> kafka;

    public IntegrationEventPublisher(
            KafkaTemplate<String, TransferCompletedIntegrationEvent> kafka) {
        this.kafka = kafka;
    }

    public void publish(TransferCompletedIntegrationEvent event) {
        // Key by fromAccount so all events for SAV2001 go to same partition
        kafka.send("transfer-events", event.fromAccount(), event);
    }
}
DIAGRAM
// Events for SAV2001: debit → credit → fee-charged
// Same key → same partition → consumed in order
// Without key: events scatter across partitions → consumed out of order
// Out-of-order: statement shows credit before debit → confuses customer

How does the transactional outbox bridge domain and integration events?

The transactional outbox writes the integration event to an outbox table in the same database transaction as the business change. A relay (CDC or poller) publishes it to Kafka afterward. This ensures the event is never lost and never published without the business change.

JAVA
package com.bank.outbox;

import com.bank.domain.event.TransferCompletedDomainEvent;
import com.bank.translator.EventTranslator;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionalEventListener;

@Component
public class OutboxWriter {

    private final OutboxRepository outboxRepo;

    public OutboxWriter(OutboxRepository outboxRepo) {
        this.outboxRepo = outboxRepo;
    }

    @TransactionalEventListener
    public void onTransferCompleted(TransferCompletedDomainEvent domain) {
        // Translate domain → integration (Anti-Corruption Layer)
        var integration = EventTranslator.toIntegration(domain);
        // Write to outbox in the same transaction
        outboxRepo.save(new OutboxEntry(
                integration.eventId(),
                "transfer-events",
                integration.fromAccount(),  // partition key
                serialize(integration)));
    }

    private String serialize(Object event) {
        // Serialize to JSON or Avro
        return event.toString();
    }
}
DIAGRAM
// Flow:
// 1. Transfer completes → domain event fires in-process
// 2. Internal listeners handle side effects (tally, fee cache)
// 3. Outbox writer translates to integration event, saves to outbox table
// 4. All of the above in ONE database transaction
// 5. Debezium CDC reads outbox table → publishes to Kafka
// 6. Notification, Statement, Fraud consume the integration event

What is the anti-pattern of leaking internal events?

Publishing raw domain events to Kafka is the most common anti-pattern. It couples every consumer to the producer's internal model.

JAVA
// WRONG: publishing the raw domain event to Kafka
kafka.send("transfer-events", domain.fromAccount(), domainEvent);
// Consumers now see: internalLedgerRef, riskBucket, fee
// Renaming "riskBucket" to "riskCategory" → breaks all consumers

// CORRECT: translate through the Anti-Corruption Layer
var integration = EventTranslator.toIntegration(domainEvent);
kafka.send("transfer-events", integration.fromAccount(), integration);
// Consumers see only: transferId, fromAccount, toAccount, amount, currency
// Internal refactoring has zero impact on consumers

What should a senior engineer know?

A senior engineer separates domain events from integration events explicitly and treats integration event schemas as public API contracts with the same rigor as REST API contracts. They use the transactional outbox to bridge the two reliably, key by aggregate ID for ordering, and govern schemas through a registry.

JAVA
// Senior checklist:
// 1. Domain events: ApplicationEventPublisher + @TransactionalEventListener
// 2. Integration events: Avro/Protobuf + Schema Registry + Kafka
// 3. Anti-Corruption Layer: explicit translator, no internal fields exposed
// 4. Outbox: same-transaction write for reliability
// 5. Key: aggregate-root ID for per-entity ordering
// 6. Compatibility: Schema Registry enforces BACKWARD or FULL

Visual Overview

DIAGRAM
  Domain Event (Internal, In-Process)
  ───────────────────────────────────
  Transfer Aggregate
        │
        │── ApplicationEventPublisher.publishEvent(domainEvent)
        │
        ├──▶ Listener 1: Update Daily Tally (same service)
        ├──▶ Listener 2: Recalculate Fee Cache (same service)
        └──▶ Listener 3: Write to Outbox Table (same transaction)

  Integration Event (External, Broker-Carried)
  ────────────────────────────────────────────
  Outbox Table ──▶ Debezium CDC ──▶ Kafka Topic
                                       │
                                       ├──▶ Notification Service (SMS)
                                       ├──▶ Statement Service (monthly)
                                       └──▶ Fraud Service (scoring)

  Anti-Corruption Layer
  ─────────────────────
  Internal Model              External Contract
  ┌────────────────────┐      ┌──────────────────────┐
  │ transferId          │─────▶│ transferId            │
  │ fromAccount         │─────▶│ fromAccount           │
  │ toAccount           │─────▶│ toAccount             │
  │ amount              │─────▶│ amount                │
  │ internalLedgerRef   │  X   │                       │
  │ riskBucket          │  X   │ currency (added)      │
  │ fee                 │  X   │ schemaVersion (added) │
  └────────────────────┘      └──────────────────────┘
        Private                    Public Contract

Key Takeaway

A domain event and an integration event can describe the same business fact but are completely different in scope and coupling. A domain event is internal to one bounded context, handled in-process through Spring Application Events with a @TransactionalEventListener, and its job is to decouple side effects across aggregates inside that single service. Because it never leaves the process, it can freely carry the rich internal model, including fields like internalLedgerRef and riskBucket that no outsider should see.

An integration event crosses the service boundary through Kafka, and the moment it does, its schema becomes a public contract that every consumer depends on. That is why it must act as an Anti-Corruption Layer: an explicit translator maps from the internal domain event to a curated external schema that exposes only the fields consumers legitimately need. At National Bank of India, Pankaj's transfer service published integration events with only transferId, fromAccount, toAccount, amount, and currency, never the internal ledger reference or risk bucket. When the team renamed riskBucket to riskCategory internally, the notification and statement teams never noticed because those fields were never in the integration event.

The practices that make integration events safe are a schema-first format like Avro with a Schema Registry that enforces backward compatibility, keying by the aggregate-root ID for per-entity ordering, and the transactional outbox to bridge domain events to integration events reliably within the same database transaction.

Remember one sentence: domain events are private, in-process, and may use your internal model, while integration events are public, versioned contracts that act as an Anti-Corruption Layer, and you must never publish raw internal domain events to Kafka.

---

Share
Use ← → arrow keys to navigate