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?
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:
-
External code talks to the root only. Nobody reaches past
Parcelto modifyPackageSizedirectly. - Business rules live in the root. Not in a service that might forget to call a validator. In the object itself.
-
One aggregate, one transaction. You save the whole
Parcelor 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();
}
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);
}
}
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;
}
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)
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;
}
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
}
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 { ... }
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)