DEV Community

Dominik Paszek
Dominik Paszek

Posted on

Aggregate Root in Spring Boot — Sealed Classes, Lifecycle Guards, and Why @Entity Doesn't Go on Your Domain Object

Aggregate Root in Spring Boot — Sealed Classes, Lifecycle Guards, and Why @entity Doesn't Go on Your Domain Object

Episode 3 of the DDD series — watch on YouTube | source code on GitLab


Last episode we replaced primitives with Value Objects. PackageSize, PackageWeight, LockerAddress — all self-validating, immutable, typed.

But we left something open. The status field on Parcel was still mutable with no guards. Anyone could do this:

parcel.setStatus(ParcelStatus.COLLECTED);
parcel.setStatus(ParcelStatus.CREATED);  // backwards?
parcel.setStatus(ParcelStatus.CANCELLED); // after collection?
Enter fullscreen mode Exit fullscreen mode

The compiler was fine with all of it. That's what Aggregate Root fixes.


What Is an Aggregate Root?

An Aggregate Root is the only object in a cluster that outside code is allowed to call directly.

Parcel owns PackageSize, PackageWeight, TrackingNumber, ParcelStatus. They have to be consistent with each other — a COLLECTED parcel can't go back to CREATED. The AR is what enforces that.

Three rules:

  1. External code talks to the root only. Nobody reaches past Parcel to modify PackageSize directly.
  2. Business rules live in the root. Not in a service that might forget to call a validator. In the object itself.
  3. One aggregate, one transaction. You save the whole Parcel or nothing.

BaseAggregateRoot

Before building Parcel, we set up a base class in common/:

public abstract class BaseAggregateRoot<ID extends Serializable> {

    private final List<Object> domainEvents = new ArrayList<>();

    protected void registerEvent(Object event) {
        domainEvents.add(Objects.requireNonNull(event));
    }

    @DomainEvents
    protected Collection<Object> domainEvents() {
        return Collections.unmodifiableList(domainEvents);
    }

    @AfterDomainEventPublication
    protected void clearDomainEvents() {
        domainEvents.clear();
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof BaseAggregateRoot<?> other)) return false;
        return getId() != null && getId().equals(other.getId());
    }

    @Override
    public int hashCode() { return getClass().hashCode(); }

    public abstract ID getId();
}
Enter fullscreen mode Exit fullscreen mode

equals and hashCode are identity-based — two Parcel objects with the same ParcelId are equal regardless of field values. That's the difference between an AR and a Value Object.

@DomainEvents and registerEvent() are there for episode 6 when we cover domain events. For now the infrastructure is in place, the calls aren't wired yet.


Parcel as an Aggregate Root

public final class Parcel extends BaseAggregateRoot<ParcelId> {

    private final ParcelId parcelId;
    private final TrackingNumber trackingNumber;
    private final PackageWeight packageWeight;
    private final PackageSize packageSize;
    private ParcelStatus status;
    private final LocalDateTime createdAt;

    @Override
    public ParcelId getId() { return parcelId; }

    public static Parcel register(ParcelId parcelId,
            PackageWeight weight, PackageSize size) {
        return new Parcel(parcelId, weight, size,
            ParcelStatus.CREATED, LocalDateTime.now());
        // domain event: episode 6
    }

    public static Parcel restore(ParcelId parcelId, PackageWeight weight,
            PackageSize size, ParcelStatus status, LocalDateTime createdAt) {
        return new Parcel(parcelId, weight, size, status, createdAt);
    }
}
Enter fullscreen mode Exit fullscreen mode

status is the only non-final field. Everything else is set once at creation.

Two factory methods with different purposes:

  • register() is the business operation — creates a new parcel, will publish a domain event in ep6
  • restore() is the persistence operation — reconstructs existing state from the database, no events, no guards

Without restore() you either bypass invariant checks (dangerous) or re-validate already-persisted state (wasteful and fragile). Naming them differently makes the intent explicit.


Lifecycle Guards

Every transition method has a guard at the top:

public void pickUpFromSender() {
    if (this.status != ParcelStatus.CREATED) {
        throw new IllegalStateException(
            "Parcel must be CREATED to be picked up. Got: " + this.status
        );
    }
    this.status = ParcelStatus.PICKED_UP_FROM_SENDER;
    // domain event: episode 6
}

public void storeInLocker(LockerSlotId slotId) {
    if (this.status != ParcelStatus.IN_TRANSIT_TO_LOCKER) {
        throw new IllegalStateException(
            "Parcel must be IN_TRANSIT_TO_LOCKER. Got: " + this.status
        );
    }
    this.status = ParcelStatus.STORED_IN_LOCKER;
    // domain event: episode 6
}

public void collect() {
    if (this.status != ParcelStatus.STORED_IN_LOCKER) {
        throw new IllegalStateException(
            "Parcel must be STORED_IN_LOCKER. Got: " + this.status
        );
    }
    this.status = ParcelStatus.COLLECTED;
}

public void cancel() {
    if (this.status == ParcelStatus.COLLECTED) {
        throw new IllegalStateException(
            "Cannot cancel a collected parcel"
        );
    }
    this.status = ParcelStatus.CANCELLED;
}
Enter fullscreen mode Exit fullscreen mode

The full ParcelStatus lifecycle:

CREATED
  → PICKED_UP_FROM_SENDER
    → IN_TRANSIT_TO_WAREHOUSE
      → IN_WAREHOUSE
        → IN_TRANSIT_TO_LOCKER
          → STORED_IN_LOCKER
            → COLLECTED (terminal)
  → CANCELLED (terminal, from most states)
Enter fullscreen mode Exit fullscreen mode

Every arrow is a method with a guard. COLLECTED and CANCELLED have no outgoing transitions.


Order — Sealed Class, Two ARs

One parcel always creates two orders — a SenderOrder and a ReceiverOrder. Same ParcelId, different actors, different lifecycles. We express that with a sealed class:

public abstract sealed class Order extends BaseAggregateRoot<OrderId>
        permits SenderOrder, ReceiverOrder {

    protected final OrderId orderId;
    protected final ParcelId parcelId;
    protected final PartyId senderId;
    protected final PartyId receiverId;
    protected final DeliveryOption deliveryOption;
    protected OrderStatus status;
}
Enter fullscreen mode Exit fullscreen mode

sealed means the compiler knows every possible subtype. A switch on Order won't compile unless you handle both SenderOrder and ReceiverOrder. Add a third subclass and every existing switch breaks until you update it.

ReceiverOrder has an assign() method that stores the locker ID and pickup code when a slot is ready:

public void assign(LockerId lockerId, String pickupCode) {
    if (this.status != OrderStatus.NOT_ASSIGNED
            && this.status != OrderStatus.REGISTERED) {
        throw new IllegalStateException(
            "Cannot assign ReceiverOrder in status: " + status
        );
    }
    this.lockerId = lockerId;
    this.pickupCode = pickupCode;
    this.status = OrderStatus.ASSIGNED;
    // domain event: ReceiverOrderAssigned — episode 6
    // handler in party/ sends SMS with locker address + pickup code
}
Enter fullscreen mode Exit fullscreen mode

In episode 6 we add ReceiverOrderAssigned here. A handler in the party module picks it up and sends the notification. ReceiverOrder doesn't know any of that — it just records the state change.


Why @entity Doesn't Go on Parcel

JPA needs a no-arg constructor. JPA accesses fields through reflection. If @Entity goes on Parcel, JPA starts managing the aggregate's lifecycle — it gets to decide when things load and when they flush. The AR loses control.

Two separate classes:

// domain — no JPA
public final class Parcel extends BaseAggregateRoot<ParcelId> { ... }

// adapter/out/persistence — JPA only
@Entity
@Table(name = "parcels")
public class ParcelJpaEntity { ... }
Enter fullscreen mode Exit fullscreen mode

The adapter converts between them. toDomain() calls Parcel.restore(). from(parcel) builds the entity from the domain object. Neither class leaks into the other's layer.


What Is NOT an Aggregate Root

LockerSlot — no meaning without Locker. Package-private constructor so only Locker can create slots. The locker enforces one parcel per slot in assignParcel(). One aggregate, one transaction, slots included.

Customer.orders — we removed List<OrderId> from Customer this week. Every new order would have had to modify Customer — two aggregates in one transaction, a list that grows without a business rule attached to it. That's a query, not domain state. orderRepository.findByReceiverId(partyId) does the job without touching any aggregate.

Quick gut-check: does it have invariants to protect across its own fields? Probably AR. Is it coordinating other objects or just holding data? Probably not.


Next Episode

Repository pattern. ParcelRepository as a port in application/port/out/. ParcelPersistenceAdapter implementing it. Manual wiring through @Configuration — no @Autowired, no @Component on use cases.

Source code: gitlab.com/PaszekDevv/locker — branch part2-aggregate-root

Branch off it, try something differently, open a merge request — I read them.

Top comments (0)