20.3 Context Integration: Domain Events, ACLs, and Model Evolution
Bounded contexts need to exchange information without sharing their internal models. Integration boundaries are responsible for language translation, isolating changes, and reliably propagating facts generated by local transactions.
Domain Events and Integration Events Separated
Domain events express facts within a context. This continues the previous lesson: DomainEvent is a project marker interface, and the example retains its capacity limit of 1024 players:
import java.util.Objects;
public record RegistrationConfirmed(
TournamentId tournamentId,
PlayerId playerId,
int confirmedCount
) implements DomainEvent {
public RegistrationConfirmed {
Objects.requireNonNull(tournamentId);
Objects.requireNonNull(playerId);
if (confirmedCount < 1 || confirmedCount > 1024) {
throw new IllegalArgumentException("invalid confirmed count");
}
}
}They can trigger rules within the same context, but they are not necessarily suitable as public messages. Integration events are stable contracts intended for external consumers, and they may hide internal fields, rename properties, or add versioning metadata:
{
"eventId": "a7726f68-9b1b-46df-84c2-bc0f786d6e8c",
"eventType": "TournamentEntryConfirmed",
"schemaVersion": 1,
"aggregateVersion": 12,
"entryId": "entry-92",
"tournamentId": "tournament-7",
"participantId": "participant-42",
"confirmedAt": "2026-08-03T10:15:30Z"
}This is a custom integration-event envelope. The internal event has no entryId, confirmation time, or persisted version. The application service must obtain them from the registration record and transaction result and map internal IDs to public IDs; the relay must not invent them. Keep eventId and confirmedAt stable on redelivery.
Serializing internal classes directly to the broker turns internal field refactoring into a breaking change for other teams.
When Events Are Published
Aggregates can record domain events during the execution of business operations, but external event publication must occur after a transaction succeeds, and must be reliably preserved via an Outbox to ensure consistent event delivery:
Aggregate generates domain events
→ Application service saves the aggregate
→ Within the same transaction, writes integration events to Outbox
→ After commit, relay publishes the eventsIf a transaction rolls back, it is not safe to publish an "enrollment confirmed" event externally. If the relay re-publishes, consumers must be idempotent.
An Anti-Corruption Layer Protects Local Language
Here ACL means Anti-Corruption Layer, not access-control list. Suppose a legacy system uses status 7 for authorization approval, 9 for refusal, and fields named ORD and CUS_NO. These are teaching conventions. The adapter uses project payment types, a client, and a mapping function. The mapper handles units, identity, and a stable operationId; the client uses LegacyTransportException for communication failures with potentially unknown outcomes:
import java.util.Objects;
import java.util.function.Function;
final class LegacyBillingAdapter implements PaymentAuthorization {
private final LegacyBillingClient client;
private final Function<PaymentRequest, LegacyOrder> toLegacyOrder;
LegacyBillingAdapter(LegacyBillingClient client,
Function<PaymentRequest, LegacyOrder> toLegacyOrder) {
this.client = Objects.requireNonNull(client);
this.toLegacyOrder = Objects.requireNonNull(toLegacyOrder);
}
public Authorization authorize(PaymentRequest request) {
Objects.requireNonNull(request);
LegacyOrder order = Objects.requireNonNull(toLegacyOrder.apply(request));
LegacyOrderResponse response;
try {
response = client.createOrder(order);
} catch (LegacyTransportException e) {
return Authorization.unknown(request.operationId());
}
if (response == null) {
return Authorization.unknown(request.operationId());
}
return switch (response.statusCode()) {
case 7 -> Authorization.approved(response.orderNo());
case 9 -> Authorization.declined(response.reasonCode());
default -> Authorization.unknown(request.operationId());
};
}
}Authorization.unknown(operationId) is an explicit unknown result. Callers should query by operation ID rather than retrying indefinitely as if this were merely an availability failure. The client decoder must validate order numbers and rejection reasons for known statuses, and record raw status and transport errors in redacted diagnostics. This example implements the mapping branches, not a real gateway protocol or retry loop.
An ACL is not just a DTO mapper. It also translates error, identity, time, unit, idempotency, and consistency semantics. If the upstream cannot distinguish between "denied" and "temporarily unavailable," the ACL can only expose that uncertainty; it cannot manufacture reliable semantics.
Shared Kernel Must Be Minimal and Commonly Governed
When two contexts share only a small amount of models or code, any change requires coordinated effort. Shared Kernel is only suitable for truly stable, jointly owned components, such as a small jointly maintained model and its identity rules. Sharing generated message DTOs or schemas alone does not make a Shared Kernel; they may simply be artifacts of a Published Language.
Putting all common-domain into a shared package effectively recreates a distributed monolith. Copying a simple value type can sometimes be cheaper than permanently sharing and evolving the code together.
Models Evolve as Understanding Improves
Domain models are not finalized in one modeling session. Production feedback may reveal:
- The same term actually encompasses two distinct lifecycles;
- A single aggregate suffers from excessive concurrency conflicts and needs to be split;
- Rules initially believed to be eventually consistent actually require reserved states;
- Call directions between contexts do not align with team responsibilities;
- Support personnel need intermediate states that are absent from the model.
The evolution process can follow these steps:
- Describe current model failures using real-world instances and incidents;
- Refine language and invariants with domain experts;
- Adjust code boundaries and contracts;
- Migrate data using expand–migrate–contract;
- Update events, monitoring, documentation, and team responsibilities;
- Remove old language and compatibility layers only after old consumers and historical replay no longer depend on them.
When Full DDD Is Not Necessary
For data entry, simple CRUD operations, short-lived internal tools, or well-established, generic domains, clear layering and module boundaries may be sufficient. The investment in DDD modeling is most justified in core domains where business rules are complex, language ambiguity is high, changes are long-term, and the cost of errors is significant.
Avoid applying tactical patterns to overcomplicate simple problems, and don't dismiss the value of deep modeling in core domains simply because peripheral functions are straightforward.
Volume 6: Connecting the Topics
Construction and Contracts
→ Requirements, architecture, testing, refactoring, and delivery
→ Design principles and object patterns
→ Layering, ports, and dependency rules
→ Modular monoliths and service extraction
→ Resilience, consistency, deployment, and events
→ AI systems, API contracts, and domain boundariesThese topics share a goal: keep changes, failures, and responsibilities within understandable boundaries, and check that those boundaries work through tests, contracts, and production feedback.
References
- Microsoft, Anti-Corruption Layer
- Eric Evans, DDD Reference
- Chris Richardson, Domain Event
- Chris Richardson, Transactional Outbox