When One Aggregate Isn’t Enough: Using Domain Services and Domain Events

The article explains why cross‑aggregate business logic belongs in domain services, how domain events capture immutable business facts, and demonstrates practical Spring Boot implementations—including in‑process publishing, message‑queue integration, and the Outbox pattern—while highlighting pitfalls and verification techniques.

Yumin Fish Harvest
Yumin Fish Harvest
Yumin Fish Harvest
When One Aggregate Isn’t Enough: Using Domain Services and Domain Events

Most business rules should live inside entities or value objects, but logic that naturally spans multiple aggregates and does not fit any single object belongs in a Domain Service . For example, calculating the discount of an order with a coupon involves both Order and Coupon aggregates, so the calculation is placed in a stateless service rather than in either aggregate.

Judgment rule: if an operation does not belong to any entity/value object and coordinates several domain objects, use a domain service; however, avoid over‑using it—if the logic can fit into an entity, keep it there to prevent anemic models.

Code example (PromotionCalculator):

public class PromotionCalculator {
    // Calculate discount amount for an order using a coupon
    public Money calculateDiscount(Order order, Coupon coupon) {
        if (!coupon.isUsable()) {
            return Money.ZERO_CNY;
        }
        // Full‑reduction rule: if order total >= threshold, apply amount
        if (order.getTotalAmount().isGreaterOrEqual(coupon.getThreshold())) {
            return coupon.getDiscountAmount();
        }
        return Money.ZERO_CNY;
    }
}

The article then contrasts Domain Service with Application Service . Domain services reside in the domain layer and contain core business rules (e.g., discount calculation). Application services sit in the application layer and orchestrate workflow: fetching data, invoking domain services, persisting aggregates, publishing events, and managing transactions. Application services should not contain business decisions such as if (amount > xxx).

Domain Event definition: a past‑tense fact that has already happened, e.g., OrderPlacedEvent, OrderPaidEvent, OrderCancelledEvent, StockDeductedEvent. Naming with the past tense signals that the event is a completed fact, not a command.

Why use domain events? The traditional placeOrder method bundles all side‑effects (stock deduction, points addition, coupon usage, delivery creation, notification) into one method, causing strong coupling, difficulty to extend, a large transaction prone to deadlocks, and unclear primary vs. secondary responsibilities.

Benefits of domain events:

Decoupling – the order code does not need to know who listens; adding a new subscriber (e.g., risk check) requires no change to the order code.

Lightweight main flow – the order transaction only persists the order; other actions are handled asynchronously.

Business‑centric – the code structure mirrors the factual sequence "order placed → a series of subsequent facts".

Three ways to publish events in Spring Boot:

In‑process events: use ApplicationEventPublisher. The default SimpleApplicationEventMulticaster invokes listeners synchronously in the publishing thread; slow listeners can be made asynchronous with @Async or a custom multicaster.

Message‑queue integration: publish to RocketMQ or Kafka so other microservices can consume the events. This is the standard approach for cross‑process decoupling.

Outbox pattern: write events to a domain_event_outbox table inside the same DB transaction as the order persistence. A background task later reads the table and pushes events to the MQ; only after successful delivery is the outbox row marked SENT. This guarantees that "order saved ⇒ event not lost".

Outbox pattern diagram
Outbox pattern diagram

Pitfalls (gotchas):

Idempotency: messages may be delivered multiple times; consumers must deduplicate using the event ID.

Past‑tense naming: use OrderPaidEvent instead of PayOrderEvent (the latter is a command).

Self‑contained payload: include all data needed by consumers (e.g., amount, member ID) to avoid extra queries that re‑introduce coupling.

Correct timing: add points on OrderPaidEvent (after payment) rather than on OrderPlacedEvent (after creation) to prevent abuse.

Verification and troubleshooting techniques:

Use @TransactionalEventListener(phase = AFTER_COMMIT) to ensure listeners run only after the transaction commits.

In tests, roll back the transaction and confirm that listeners did not produce side effects.

Check the domain_event_outbox table for pending rows; after successful publishing the status should become SENT.

Verify idempotency by attempting to process the same eventId twice and ensuring business effects (e.g., stock deduction, points) occur only once.

Confirm event class naming follows the past‑tense convention; commands should use verb forms (e.g., PayOrderCommand).

Example SQL for an idempotency table:

CREATE TABLE consumed_event (
    event_id      VARCHAR(64) PRIMARY KEY,
    consumer_name VARCHAR(100) NOT NULL,
    consumed_at   DATETIME NOT NULL
);

Listener example that marks an event as consumed before processing:

@Transactional
public void on(OrderPaidEvent event) {
    if (!consumedEventRepository.tryMarkConsumed(event.eventId(), "member-points")) {
        return; // already processed
    }
    memberService.addPoints(event.memberId(), event.paidAmount());
}

Finally, the article notes that domain services, domain events, repositories, and factories together keep the domain model pure while preventing persistence concerns from leaking into the domain layer.

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.

Domain-Driven DesignSpring BootEvent-Driven ArchitectureDomain EventOutbox PatternDomain Service
Yumin Fish Harvest
Written by

Yumin Fish Harvest

A deep‑sea salvage fisherman sharing architecture insights, practical tips, and lessons learned.

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.