Skip to content
ioob.dev
Go back

DDD Part 7 — Domain Event and Anti-Corruption Layer

· 8 min read
DDD Series (7/7)
  1. DDD Part 1 — Why Domain-Centric Design Matters
  2. DDD Part 2 — Ubiquitous Language and Bounded Context
  3. DDD Part 3 — Context Mapping
  4. DDD Part 4 — Entity and Value Object
  5. DDD Part 5 — Aggregate and Repository
  6. DDD Part 6 — Domain Service and Application Service
  7. DDD Part 7 — Domain Event and Anti-Corruption Layer
Table of contents

Table of contents

The Gap Between Aggregates

In Part 5, we covered the principle that a single transaction should only modify a single aggregate. But in real business, changes in one aggregate almost always affect other aggregates. When an order is placed, inventory needs to decrease; when a payment completes, loyalty points need to be awarded.

Domain Events bridge this gap. They express something happened at the domain level, and interested parties receive that fact and update their own aggregates.

What Is a Domain Event

A Domain Event is an object that represents a fact that occurred in the domain. An order was placed, a payment was completed, a member's grade was changed — since it records something that already happened, it’s immutable, and it’s named in the past tense.

data class OrderPlacedEvent(
    val orderId: Long,
    val customerId: Long,
    val totalAmount: BigDecimal,
    val occurredAt: Instant = Instant.now()
)

data class PaymentCompletedEvent(
    val paymentId: Long,
    val orderId: Long,
    val amount: BigDecimal,
    val occurredAt: Instant = Instant.now()
)

Naming conventions matter. OrderPlaced, PaymentCompleted, MemberGradeChanged — all past tense. They must be distinguished from present tense (OrderPlacing) or imperative (PlaceOrder). A command is a request to please do this, while an event is a notification that this happened. Commands can be rejected, but events are facts that already occurred and cannot be rejected.

Components of an Event

A Domain Event should contain at minimum the following information:

If an event carries too much data, it becomes a snapshot of the aggregate. If it carries too little, the receiving side must constantly query the source, increasing coupling. Finding the right balance is a matter of design judgment.

Publishing Events — Spring’s ApplicationEventPublisher

Spring Framework supports internal application event publishing/subscribing through ApplicationEventPublisher. Without a separate message broker, you can implement event-driven design, making it well-suited for applying DDD’s Domain Events.

Event publishing code goes in the Application Service.

@Service
@Transactional
class PlaceOrderService(
    private val orderRepository: OrderRepository,
    private val eventPublisher: ApplicationEventPublisher
) {
    fun execute(command: PlaceOrderCommand) {
        val order = orderRepository.findById(command.orderId)
            ?: throw OrderNotFoundException(command.orderId)

        order.place()
        orderRepository.save(order)

        eventPublisher.publishEvent(
            OrderPlacedEvent(
                orderId = order.id,
                customerId = order.customerId,
                totalAmount = order.totalAmount().amount
            )
        )
    }
}

The subscribing side uses @EventListener or @TransactionalEventListener.

@Component
class InventoryEventHandler(
    private val inventoryService: DeductInventoryService
) {
    @EventListener
    fun handle(event: OrderPlacedEvent) {
        inventoryService.deduct(event.orderId)
    }
}

PlaceOrderService doesn’t know about inventory deduction. It places the order and publishes the event — that’s all. Inventory deduction is handled independently by InventoryEventHandler, which receives the event. Thanks to this structure, even when a new requirement appears — say, sending a notification when an order is placed — PlaceOrderService doesn’t need to be modified. You just add a new handler.

Event Publishing/Subscribing Flow

Here’s the entire flow in a diagram:

sequenceDiagram
    participant AS as PlaceOrderService
    participant OR as OrderRepository
    participant EP as EventPublisher
    participant IH as InventoryHandler
    participant PH as PointHandler
    participant NH as NotificationHandler

    AS->>OR: findById / save
    AS->>EP: publish(OrderPlacedEvent)
    EP->>IH: handle(event)
    EP->>PH: handle(event)
    EP->>NH: handle(event)

    Note over IH: Deduct inventory
    Note over PH: Award points
    Note over NH: Send notification

Multiple handlers can respond to a single event. Each handler is unaware of the others and executes independently. This is the key advantage of event-driven architecture. The coupling between publisher and subscriber disappears.

@TransactionalEventListener — The Relationship Between Transactions and Events

@EventListener executes the handler immediately when the event is published. The problem is that the event is published before the transaction commits. If you publish an event after order.place() but a subsequent operation throws an exception and the transaction rolls back — what happens? The order wasn’t placed, but inventory was already deducted.

@TransactionalEventListener solves this problem.

@Component
class InventoryEventHandler(
    private val inventoryService: DeductInventoryService
) {
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    fun handle(event: OrderPlacedEvent) {
        inventoryService.deduct(event.orderId)
    }
}

Specifying the AFTER_COMMIT phase ensures the handler only runs after the transaction has successfully committed. If the transaction rolls back, the handler doesn’t execute at all. There are four available phases:

PhaseExecution TimingUse Case
AFTER_COMMITAfter successful commitMost common. Execute side effects
AFTER_ROLLBACKAfter rollbackCompensating actions on failure
AFTER_COMPLETIONRegardless of commit/rollbackResource cleanup
BEFORE_COMMITJust before commitAdditional work within the same transaction

An important caveat: handlers executing in AFTER_COMMIT run outside the original transaction. If the handler needs to modify the DB, it must start a new transaction, and if the handler fails, the original transaction has already committed so automatic rollback won’t happen. This gap is exactly where eventual consistency becomes necessary.

There are several strategies for handling handler failures:

@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Retryable(maxAttempts = 3, backoff = Backoff(delay = 1000))
fun handle(event: OrderPlacedEvent) {
    inventoryService.deduct(event.orderId)
}

Using Spring Retry’s @Retryable enables retries for transient failures. If retries also fail, the typical approach is to record to a Dead Letter Queue or failure log table, with manual operator intervention or batch job retries.

Collecting Events Inside the Aggregate

So far we’ve been publishing events directly from the Application Service. But the cause of the event lies in the domain logic. OrderPlacedEvent occurs because order.place() was executed. If you want to make this causal relationship explicit in code, you can use the pattern of collecting events inside the aggregate.

Spring Data provides @DomainEvents and @AfterDomainEventPublication annotations. Alternatively, you can implement it manually.

abstract class AggregateRoot {
    @Transient
    private val _domainEvents: MutableList<Any> = mutableListOf()

    val domainEvents: List<Any> get() = _domainEvents.toList()

    protected fun registerEvent(event: Any) {
        _domainEvents.add(event)
    }

    fun clearEvents() {
        _domainEvents.clear()
    }
}

Order extends this base class:

class Order private constructor(
    val id: Long,
    val customerId: Long,
    private val _items: MutableList<OrderItem>,
    private var _status: OrderStatus
) : AggregateRoot() {

    fun place() {
        require(_items.isNotEmpty()) { "Cannot place an order with no items" }
        _status = OrderStatus.PLACED

        registerEvent(
            OrderPlacedEvent(
                orderId = id,
                customerId = customerId,
                totalAmount = totalAmount().amount
            )
        )
    }
}

The Application Service extracts the accumulated events from the aggregate and publishes them:

@Service
@Transactional
class PlaceOrderService(
    private val orderRepository: OrderRepository,
    private val eventPublisher: ApplicationEventPublisher
) {
    fun execute(command: PlaceOrderCommand) {
        val order = orderRepository.findById(command.orderId)
            ?: throw OrderNotFoundException(command.orderId)

        order.place()
        orderRepository.save(order)

        order.domainEvents.forEach { eventPublisher.publishEvent(it) }
        order.clearEvents()
    }
}

The advantage of this structure is that which state change triggers which event is explicit in the domain code. The Application Service doesn’t need to figure out which events to publish, and since state transitions and event publication are co-located, the chance of omission is reduced.

Anti-Corruption Layer — Protecting the Domain from the Outside

Now let’s shift topics. While Domain Events handle communication between internal aggregates, the Anti-Corruption Layer (ACL) is a pattern that protects the domain in communication with external systems.

ACL is one of the Context Mapping patterns covered in Part 2. When models from external systems — legacy monoliths, third-party APIs, other teams’ services — differ from our domain model, it places a translation layer that absorbs the differences.

Why is this needed? If the response format of an external payment API differs from our domain model, we shouldn’t reshape our domain objects to match the external API. That would cause the domain model to shake every time the external system changes. The ACL is a buffer zone that absorbs this impact.

flowchart LR
    subgraph Our["Our Domain"]
        PS["PaymentService<br/>(Domain)"]
    end

    subgraph ACL["Anti-Corruption Layer"]
        Adapter["PaymentGatewayAdapter"]
        Translator["ResponseTranslator"]
    end

    subgraph External["External Payment System"]
        API["PG REST API"]
    end

    PS -->|"Domain language"| Adapter
    Adapter -->|"External protocol"| API
    API -->|"External response"| Translator
    Translator -->|"Domain object"| PS

    style Our fill:#5ca45c,stroke:#2e7d32,color:#fff
    style ACL fill:#d4943a,stroke:#f9a825,color:#fff
    style External fill:#c0392b,stroke:#c62828,color:#fff

The ACL has two responsibilities: Translation and Isolation. It translates external models into our domain models and isolates changes in external systems from propagating to the domain layer.

ACL Implementation — External Payment API Integration

Let’s implement the most common ACL scenario in practice: external payment API integration. Each PG (Payment Gateway) has a different API spec and response format. The domain should not deal with this diversity directly.

First, define the payment interface in the domain layer.

// Domain layer — unaware of external systems
interface PaymentGateway {
    fun requestPayment(request: PaymentRequest): PaymentResult
    fun cancelPayment(paymentId: PaymentId): CancelResult
}

data class PaymentRequest(
    val orderId: Long,
    val amount: Money,
    val method: PaymentMethod
)

sealed class PaymentResult {
    data class Success(val paymentId: PaymentId, val approvedAt: Instant) : PaymentResult()
    data class Failure(val reason: String, val code: String) : PaymentResult()
}

The domain only knows the PaymentGateway interface. It knows nothing about which PG is used or whether it’s REST or gRPC.

Next, define the external PG’s API response model. This lives in the infrastructure layer.

// Infrastructure layer — the external PG's response format as-is
data class TossPaymentResponse(
    val paymentKey: String,
    val status: String,       // "DONE", "CANCELED", "ABORTED", etc.
    val approvedAt: String?,  // ISO 8601
    val totalAmount: Int,
    val method: String,       // "Card", "Virtual Account", "Easy Pay", etc.
    val failure: TossFailure?
)

data class TossFailure(
    val code: String,
    val message: String
)

Now implement the core of the ACL: the Adapter. Its role is to call the external API and translate responses into domain models.

@Component
class TossPaymentAdapter(
    private val restClient: RestClient
) : PaymentGateway {

    override fun requestPayment(request: PaymentRequest): PaymentResult {
        val tossRequest = toTossRequest(request)

        val response = restClient.post()
            .uri("/v1/payments/confirm")
            .body(tossRequest)
            .retrieve()
            .body(TossPaymentResponse::class.java)
            ?: return PaymentResult.Failure("No response", "NO_RESPONSE")

        return toPaymentResult(response)
    }

    override fun cancelPayment(paymentId: PaymentId): CancelResult {
        // Cancel API call and translation
    }

    // --- Translation logic ---

    private fun toTossRequest(request: PaymentRequest): Map<String, Any> {
        return mapOf(
            "orderId" to request.orderId.toString(),
            "amount" to request.amount.amount.toInt()
        )
    }

    private fun toPaymentResult(response: TossPaymentResponse): PaymentResult {
        return when (response.status) {
            "DONE" -> PaymentResult.Success(
                paymentId = PaymentId(response.paymentKey),
                approvedAt = Instant.parse(response.approvedAt)
            )
            else -> PaymentResult.Failure(
                reason = response.failure?.message ?: "Unknown error",
                code = response.failure?.code ?: "UNKNOWN"
            )
        }
    }
}

Imagine switching PG providers in this structure. If you need to switch from Toss to another provider, just create a new Adapter instead of TossPaymentAdapter. The domain layer’s PaymentGateway interface and PaymentResult stay the same. Being able to swap PG providers without modifying a single line of domain code — that’s the value of ACL.

What Happens Without an ACL

Let’s look concretely at what happens when you use an external API directly without an ACL.

// Code that directly uses external responses without ACL (bad example)
@Service
class OrderPaymentService(
    private val restClient: RestClient,
    private val orderRepository: OrderRepository
) {
    fun pay(orderId: Long) {
        val order = orderRepository.findById(orderId)!!

        val response = restClient.post()
            .uri("https://api.tosspayments.com/v1/payments/confirm")
            .body(mapOf("orderId" to orderId, "amount" to order.totalAmount()))
            .retrieve()
            .body(TossPaymentResponse::class.java)!!

        // External model penetrates the domain logic
        if (response.status == "DONE") {
            order.markAsPaid(response.paymentKey)
        } else if (response.failure?.code == "ALREADY_PROCESSED_PAYMENT") {
            // PG-specific error code appears in the domain
        }
    }
}

This code has three problems:

  1. External model penetrates the domain. PG-specific strings like "DONE" and "ALREADY_PROCESSED_PAYMENT" are hardcoded into domain logic.
  2. PG change triggers domain change. A different PG might use "SUCCESS" for the status value, with an entirely different error code system.
  3. Testing is difficult. You need to mock the external API, and the mock target differs per PG.

With an ACL, all three problems are resolved. The domain only needs to know PaymentResult.Success and PaymentResult.Failure, and switching PGs is just a matter of changing the Adapter.

Domain Event + ACL — A Practical Combined Pattern

Domain Event and ACL are independent concepts, but in practice they’re often used together. A representative pattern is translating externally-originated events (webhooks) into domain events through an ACL.

For example, consider a PG that notifies payment status changes via webhook:

@RestController
class PaymentWebhookController(
    private val webhookTranslator: PaymentWebhookTranslator,
    private val eventPublisher: ApplicationEventPublisher
) {
    @PostMapping("/webhook/payment")
    fun handleWebhook(@RequestBody payload: Map<String, Any>) {
        // ACL translates the external webhook payload into a domain event
        val domainEvent = webhookTranslator.translate(payload)

        if (domainEvent != null) {
            eventPublisher.publishEvent(domainEvent)
        }
    }
}

@Component
class PaymentWebhookTranslator {
    fun translate(payload: Map<String, Any>): Any? {
        val status = payload["status"] as? String ?: return null
        val paymentKey = payload["paymentKey"] as? String ?: return null

        return when (status) {
            "DONE" -> PaymentCompletedEvent(
                paymentId = PaymentId(paymentKey),
                occurredAt = Instant.now()
            )
            "CANCELED" -> PaymentCancelledEvent(
                paymentId = PaymentId(paymentKey),
                occurredAt = Instant.now()
            )
            else -> null  // Ignore unknown statuses
        }
    }
}

Even if the external webhook format changes, only PaymentWebhookTranslator needs modification. The domain event format stays the same, so the handlers that receive the events are unaffected.

Wrapping Up the Series

Over 7 parts, we’ve examined the core concepts of DDD. Starting from strategic design in Part 1, we established model boundaries with ubiquitous language and bounded contexts, and defined inter-boundary relationships with context mapping. From Part 4, we moved into tactical design — making models concrete with entities and value objects, setting consistency boundaries with aggregates, and addressing logic placement with domain services and application services. Finally, we implemented loose coupling between aggregates and with external systems using domain events and ACL.

The theme running through all these concepts is ultimately boundaries. Bounded contexts define model boundaries, aggregates define consistency boundaries, and ACL defines boundaries with external systems. Most design decisions in DDD boil down to the question: where do you draw the boundary?

DDD is not a silver bullet that should be applied to every project. Introducing aggregates and domain events into a simple CRUD application only adds unnecessary complexity. DDD shines where business rules are complex, communication with domain experts matters, and the system needs long-term maintenance.

Topics not covered here include CQRS, Event Sourcing, and the Saga pattern. These are architectural patterns for leveraging DDD’s tactical patterns more sophisticatedly. For more on these, refer to the respective official documentation and Vaughn Vernon’s Implementing Domain-Driven Design.


Related Posts

Share this post on:

Comments

Loading comments...


Previous Post
DDD Part 6 — Domain Service and Application Service