How Domain Events Decouple Communication Between Bounded Contexts

The article analyzes the pitfalls of synchronous calls in a payroll microservice, defines domain events and their naming conventions, compares them with message‑queue messages, and presents two concrete implementations—Spring ApplicationEvent for intra‑process decoupling and RocketMQ with a transactional outbox for cross‑process communication—while also clarifying when event sourcing is appropriate.

Tinker Programmer
Tinker Programmer
Tinker Programmer
How Domain Events Decouple Communication Between Bounded Contexts

Problem

A payroll service method closeMonthlyPayroll performed the core calculation and directly invoked finance‑report generation, employee notification, attendance locking and tax summarisation. Each new downstream requirement forced a change to this method, increasing coupling and failure risk.

Domain events

Domain events are immutable records of business facts expressed in the past tense (e.g., PaySlipCalculated, PaySlipIssued). They originate inside the domain model, use ubiquitous language, and the publisher does not need to know any consumer.

Essence : business fact vs. transport carrier.

Naming : business language, past tense vs. technical free‑form.

Origin : inside a bounded context vs. anywhere.

Consumer : may be in‑process or cross‑process vs. usually cross‑process.

Coupling : publisher unaware of consumer vs. publisher may know consumer.

Solution 1 – Local domain events (Spring ApplicationEvent )

Applicable when publisher and consumer run in the same JVM.

public abstract class DomainEvent {
    private final String eventId = UUID.randomUUID().toString();
    private final String eventType;
    private final Instant occurredAt = Instant.now();
    protected DomainEvent(String eventType) { this.eventType = eventType; }
    public String getEventId() { return eventId; }
    public String getEventType() { return eventType; }
    public Instant getOccurredAt() { return occurredAt; }
}

Concrete payroll event:

public class PaySlipCalculated extends DomainEvent {
    private final EmployeeId employeeId;
    private final PayCycle cycle;
    private final GrossSalary grossSalary;
    private final NetSalary netSalary;
    public PaySlipCalculated(EmployeeId employeeId, PayCycle cycle,
                             GrossSalary grossSalary, NetSalary netSalary) {
        super("PAY_SLIP_CALCULATED");
        this.employeeId = employeeId;
        this.cycle = cycle;
        this.grossSalary = grossSalary;
        this.netSalary = netSalary;
    }
    // getters only – immutable
}

Aggregate root records the event and exposes a pull method:

public void markAsCalculated(GrossSalary grossSalary, NetSalary netSalary) {
    if (this.status != PaySlipStatus.PENDING) {
        throw new DomainException("Only pending slips can be marked calculated");
    }
    this.grossSalary = grossSalary;
    this.netSalary = netSalary;
    this.status = PaySlipStatus.CALCULATED;
    domainEvents.add(new PaySlipCalculated(this.employeeId, this.cycle, grossSalary, netSalary));
}

public List<DomainEvent> pullDomainEvents() {
    List<DomainEvent> events = new ArrayList<>(domainEvents);
    domainEvents.clear();
    return events;
}

Repository saves the aggregate and publishes events within the same transaction:

@Transactional
public void save(PaySlip paySlip) {
    jpaRepository.save(PaySlipPO.from(paySlip));
    paySlip.pullDomainEvents().forEach(eventPublisher::publishEvent);
}

Listeners consume events asynchronously:

@Component
public class AttendanceLockEventHandler {
    private final AttendanceLockDomainService lockService;
    @EventListener
    @Async
    public void onPaySlipCalculated(PaySlipCalculated event) {
        lockService.lockAttendance(event.getEmployeeId(), event.getCycle());
    }
}

@Component
public class EmployeePaySlipNotificationHandler {
    private final NotificationService notificationService;
    @EventListener
    @Async
    public void onPaySlipCalculated(PaySlipCalculated event) {
        notificationService.pushPaySlipToEmployee(event.getEmployeeId(), event.getNetSalary());
    }
}

After refactoring, closeMonthlyPayroll only coordinates calculation and persistence; downstream actions are driven by events, so adding a new requirement does not modify the method.

Solution 2 – Cross‑process domain events (RocketMQ + Transactional Outbox)

When bounded contexts are deployed separately, a message queue is needed. The key challenge is atomicity between persisting the aggregate and publishing the message.

CREATE TABLE domain_event_outbox (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    event_id VARCHAR(64) NOT NULL UNIQUE, -- idempotence
    event_type VARCHAR(128) NOT NULL,
    payload JSON NOT NULL,
    status VARCHAR(16) NOT NULL DEFAULT 'PENDING', -- PENDING / SENT / FAILED
    created_at DATETIME NOT NULL,
    sent_at DATETIME
);

Repository writes outbox rows inside the same transaction:

@Transactional
public void save(PaySlip paySlip) {
    jpaRepository.save(PaySlipPO.from(paySlip));
    paySlip.pullDomainEvents().forEach(event -> {
        DomainEventOutboxPO outbox = new DomainEventOutboxPO();
        outbox.setEventId(event.getEventId());
        outbox.setEventType(event.getEventType());
        outbox.setPayload(serialize(event));
        outbox.setStatus("PENDING");
        outbox.setCreatedAt(LocalDateTime.now());
        outboxRepository.save(outbox);
    });
}

Independent publisher polls pending rows every second and sends them to RocketMQ:

@Scheduled(fixedDelay = 1000)
public void publishPendingEvents() {
    List<DomainEventOutboxPO> pending = outboxRepository.findByStatus("PENDING");
    pending.forEach(outbox -> {
        try {
            rocketMQTemplate.syncSend("payroll-events:" + outbox.getEventType(), outbox.getPayload());
            outbox.setStatus("SENT");
            outbox.setSentAt(LocalDateTime.now());
        } catch (Exception e) {
            log.error("Failed to send domain event, eventId={}", outbox.getEventId(), e);
            outbox.setStatus("FAILED");
        }
        outboxRepository.save(outbox);
    });
}

Consumer in the tax service listens to the PAY_SLIP_CALCULATED topic, deserialises the JSON, checks idempotence and updates tax summaries:

@Service
@RocketMQMessageListener(topic = "payroll-events",
                         selectorExpression = "PAY_SLIP_CALCULATED",
                         consumerGroup = "tax-service-payslip-consumer")
public class TaxSummaryEventConsumer implements RocketMQListener<String> {
    private final TaxSummaryService taxSummaryService;
    private final ObjectMapper objectMapper;
    @Override
    public void onMessage(String message) {
        PaySlipCalculated event = objectMapper.readValue(message, PaySlipCalculated.class);
        if (taxSummaryService.alreadyProcessed(event.getEventId())) return;
        taxSummaryService.summarize(event.getEmployeeId(), event.getCycle(), event.getGrossSalary());
    }
}

The outbox pattern guarantees that either both the database write and the outbox row are persisted, or neither is, eliminating the “write‑db but not send‑mq” inconsistency.

Event sourcing – when (not) to use it

Event sourcing stores only events and rebuilds current state by replaying them. Benefits include perfect audit trails and time‑travel debugging. Costs are:

Read queries require replay or snapshots.

Event schemas become immutable; schema evolution is hard.

Usually requires a specialised event store.

It is worthwhile for accounting, financial ledgers or compliance‑critical systems. For typical business applications, plain domain events plus a lightweight audit table are sufficient.

Choosing between the two implementations

Consumer and publisher in same process?
    └─YES → Spring ApplicationEvent (add @Async for async)
    └─NO → Can you tolerate eventual consistency?
            └─YES → RocketMQ + Transactional Outbox (cross‑service, reliable delivery)
            └─NO → Use synchronous RPC (strong consistency)

The guiding principle is not to introduce events merely for the sake of using them; if a downstream operation must succeed before the upstream one is considered complete, a synchronous call is more appropriate.

Key takeaways

Domain events are emitted from aggregate roots, not from service layers.

In‑process decoupling uses Spring ApplicationEvent with optional @Async.

Cross‑process decoupling uses RocketMQ together with a transactional outbox to guarantee atomic DB‑write + message‑send.

Event sourcing is a separate pattern; most systems only need plain domain events and a lightweight audit log.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

microservicesRocketMQDDDDomain EventsEvent‑Driven ArchitectureTransactional OutboxSpring ApplicationEvent
Tinker Programmer
Written by

Tinker Programmer

Solving problems with code, sharing practical tech insights, and leveling up together!

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.