A *Service class holds the business logic for one aggregate type. It is the only class allowed to load, mutate, and persist that aggregate. All service methods are called through the aggregate's CommandHandler via commandGateway.
A service may inject only components that belong to its own aggregate, plus shared infrastructure:
@Service
public class WarehouseService {
private final WarehouseRepository warehouseRepository; // own aggregate's JPA repo
private final WarehouseCustomRepository warehouseCustomRepository; // own aggregate's custom repo
private final WarehouseFactory warehouseFactory; // own aggregate's factory
private final UnitOfWorkService unitOfWorkService; // raw type, deliberately
private final AggregateIdGeneratorService aggregateIdGeneratorService;
public WarehouseService(WarehouseRepository warehouseRepository,
WarehouseCustomRepository warehouseCustomRepository,
WarehouseFactory warehouseFactory,
UnitOfWorkService unitOfWorkService,
AggregateIdGeneratorService aggregateIdGeneratorService) {
this.warehouseRepository = warehouseRepository;
this.warehouseCustomRepository = warehouseCustomRepository;
this.warehouseFactory = warehouseFactory;
this.unitOfWorkService = unitOfWorkService;
this.aggregateIdGeneratorService = aggregateIdGeneratorService;
}
}Every dependency goes through the constructor. A service declares no
@Autowiredfield. All fields arefinal, which makes a half-wired service impossible to construct and lets the compiler, rather than a runtimeNullPointerException, catch a dependency that was added to the class but not to the@Beanmethod. It also keeps one rule instead of two: a session appending a method that needs a collaborator the service does not yet hold widens the constructor and the matching@Beanmethod inBeanConfigurationSagas.groovy, whichever collaborator it is — there is no second, field-injected category that would let it skip the@Beanedit.The list above is closed: own repository, own custom repository, own factory,
UnitOfWorkService,AggregateIdGeneratorService. Omit any the service genuinely does not use; add nothing else.
Never inject a foreign service class or a foreign repository — see R1, R2 in architecture.md.
aggregateLoadAndRegisterRead is called only with ids of this service's own aggregate type (R1). A
foreign aggregate's state never arrives by loading it here; it arrives as a DTO parameter, assembled
by the Functionality from a Get*Command step.
Never hold a reference to another aggregate's concrete class either (R3). A service may accept and return any aggregate's {Xxx}Dto, but aggregate instances carry UoW registration state that must not cross service boundaries — cross-aggregate state flows as DTOs, assembled by the Functionality from a Get*Command step and passed downstream as plain values.
Inject factories and repositories via their abstract interfaces, not the concrete sagas-profile classes. The example above injects
WarehouseFactory(the interface defined inaggregate/) —SagasWarehouseFactoryis never referenced in the service. This keeps the service layer profile-agnostic and allows a TCC or other implementation to be wired in without touching the service.
UnitOfWorkServiceis used raw, on both the field and the constructor parameter. No type argument, no cast, no@SuppressWarnings.UnitOfWorkService<U extends UnitOfWork>is abstract withUin both parameter and return positions, and its concrete subclasses areSagaUnitOfWorkServiceandCausalUnitOfWorkService. Java generics are invariant, soUnitOfWorkService<UnitOfWork>is satisfied by neither and describes a type that cannot exist.The service is profile-agnostic, so it treats the unit of work as an opaque token that it hands back to the collaborator which issued it. It never inspects it, so the type parameter buys nothing here, and naming a concrete profile type would violate
AGENTS.md§ Architecture principle.Two alternatives were considered and rejected. Making the service itself generic (
{Aggregate}Service<U extends UnitOfWork>) propagates<U>into everyCommandandCommandHandlersignature for zero added safety at the only call site. Adding a non-generic facade tosimulator/changes the framework to repair a documentation defect, which is disproportionate.
Every service method is annotated @Transactional(isolation = Isolation.SERIALIZABLE). This makes P3 guards race-free.
Never call
verifyInvariants()from a service method.registerChangedinvokes it on the aggregate it is given (SagaUnitOfWorkService.registerChanged), so an explicit call is at best a duplicate and at worst fires against a half-applied mutation. This holds for every mutation path without exception - saga steps and event-driven (ByEvent) updates alike. This section is the single owner of the rule; other docs point here rather than restating it.
Loads the latest version and converts it to a DTO. No mutation, no registerChanged.
@Transactional(isolation = Isolation.SERIALIZABLE)
public WarehouseDto getWarehouseById(Integer warehouseAggregateId, UnitOfWork unitOfWork) {
return warehouseFactory.createWarehouseDto(
(Warehouse) unitOfWorkService.aggregateLoadAndRegisterRead(warehouseAggregateId, unitOfWork));
}Generates a new aggregate ID, constructs the aggregate via the factory, registers it as changed.
@Transactional(isolation = Isolation.SERIALIZABLE)
public WarehouseDto createWarehouse(WarehouseDto dto, UnitOfWork unitOfWork) {
// [P3] uniqueness guard — reads own table, inside @Transactional(SERIALIZABLE)
Set<Integer> existingIds = warehouseCustomRepository.findWarehouseIdsOfAllNonDeleted();
for (Integer id : existingIds) {
Warehouse existing = (Warehouse) unitOfWorkService.aggregateLoadAndRegisterRead(id, unitOfWork);
if (existing.getCode().equals(dto.getCode())
&& existing.getRegion().equals(dto.getRegion())) {
throw new {AppClass}Exception(DUPLICATE_WAREHOUSE, dto.getCode(), dto.getRegion());
}
}
Integer aggregateId = aggregateIdGeneratorService.getNewAggregateId();
Warehouse warehouse = warehouseFactory.createWarehouse(aggregateId, dto, ...);
unitOfWorkService.registerChanged(warehouse, unitOfWork);
return warehouseFactory.createWarehouseDto(warehouse);
}Loads the current version, copies it via the factory's create{Aggregate}Copy method, applies mutations on the copy, then registers the copy as changed.
@Transactional(isolation = Isolation.SERIALIZABLE)
public void registerShipment(Integer warehouseAggregateId, ShipmentDto shipmentDto, UnitOfWork unitOfWork) {
// [P3] guard — validates DTO field assembled by preceding saga step
Warehouse oldWarehouse = (Warehouse) unitOfWorkService.aggregateLoadAndRegisterRead(
warehouseAggregateId, unitOfWork);
if (!shipmentDto.isActive()) {
throw new {AppClass}Exception(INACTIVE_SHIPMENT, shipmentDto.getAggregateId());
}
// copy-on-write: mutations go on the new version, not the loaded one
Warehouse newWarehouse = warehouseFactory.createWarehouseCopy(oldWarehouse);
newWarehouse.addShipment(new WarehouseShipment(shipmentDto));
unitOfWorkService.registerChanged(newWarehouse, unitOfWork);
}After registerChanged, register the event. The event is persisted atomically when the UoW commits.
@Transactional(isolation = Isolation.SERIALIZABLE)
public void removeWarehouse(Integer warehouseAggregateId, UnitOfWork unitOfWork) {
Warehouse oldWarehouse = (Warehouse) unitOfWorkService.aggregateLoadAndRegisterRead(
warehouseAggregateId, unitOfWork);
Warehouse newWarehouse = warehouseFactory.createWarehouseCopy(oldWarehouse);
newWarehouse.remove(); // sets state = DELETED
unitOfWorkService.registerChanged(newWarehouse, unitOfWork);
unitOfWorkService.registerEvent(
new DeleteWarehouseEvent(newWarehouse.getAggregateId()), unitOfWork);
}When a saga updates an aggregate that contains a sub-collection, but cannot reconstruct the full sub-objects from the DTOs available at that point (e.g. the DTO only carries IDs, not full objects), pass null for the sub-collection parameter and guard the setter with an explicit null check:
@Transactional(isolation = Isolation.SERIALIZABLE)
public void updateShipment(Integer shipmentAggregateId, ShipmentDto shipmentDto,
Set<ShipmentItem> shipmentItems, // may be null
UnitOfWork unitOfWork) {
Shipment oldShipment = (Shipment) unitOfWorkService
.aggregateLoadAndRegisterRead(shipmentAggregateId, unitOfWork);
Shipment newShipment = shipmentFactory.createShipmentCopy(oldShipment);
if (shipmentDto.getStartTime() != null) {
newShipment.setStartTime(shipmentDto.getStartTime());
}
// ...
if (shipmentItems != null) { // skip if caller couldn't reconstruct sub-objects
newShipment.setShipmentItems(shipmentItems);
}
unitOfWorkService.registerChanged(newShipment, unitOfWork);
}When to use: The calling saga fetches an upstream aggregate's DTO but that DTO only returns IDs for sub-objects (e.g. WarehouseDto.shipmentIds). Fetching each sub-object individually would require N extra command steps, which buy nothing when the sub-collection is not the operation's primary intent (see § When not to use). Passing null and guarding the setter keeps the update intent explicit without polluting the saga with unnecessary reads.
When not to use: If the sub-collection update is the primary intent of the operation (the caller always has the data), make the parameter non-null and remove the guard — a missing null check is then a silent data loss bug.
Never mutate the aggregate instance returned by aggregateLoadAndRegisterRead. Always create a new version via factory.create{Aggregate}Copy(old) and mutate that copy. The old version remains in the UoW read set for conflict detection; the new version is the write target.
Soft-delete (remove()) — use copy-on-write: Even for soft-delete, always create a factory copy before calling remove():
Warehouse oldWarehouse = (Warehouse) unitOfWorkService.aggregateLoadAndRegisterRead(warehouseAggregateId, unitOfWork);
Warehouse newWarehouse = warehouseFactory.createWarehouseCopy(oldWarehouse);
newWarehouse.remove();
unitOfWorkService.registerChanged(newWarehouse, unitOfWork);Sub-collection clearing before remove(): When an aggregate's verifyInvariants() enforces a rule like "must have no members when deleted" (e.g., SHIPMENT_DELETE requires items.isEmpty() when state == DELETED), clear the collection on the copy before calling remove():
Shipment oldShipment = (Shipment) unitOfWorkService.aggregateLoadAndRegisterRead(
shipmentAggregateId, unitOfWork);
Shipment newShipment = shipmentFactory.createShipmentCopy(oldShipment);
newShipment.setItems(new HashSet<>()); // satisfy SHIPMENT_DELETE invariant
newShipment.remove();
unitOfWorkService.registerChanged(newShipment, unitOfWork);This is required when two invariants interact: a deletion invariant checks a collection, and a cancellation invariant forbids mutations on cancelled aggregates. The clear step satisfies the deletion invariant before remove() fires verifyInvariants().
Why copy-on-write is required here: If you call remove() on the managed JPA entity returned by aggregateLoadAndRegisterRead, JPA marks that entity dirty immediately. Before the saga abort path can run its findNonDeletedSagaAggregate JPQL query, JPA may auto-flush the dirty state — setting the aggregate's state to DELETED in the DB. The abort query then finds nothing (it filters state != DELETED), causing the abort to fail silently. Copy-on-write keeps the original managed entity unmodified; only the new (unmanaged) copy carries the DELETED state, so the abort query always succeeds. This problem surfaces specifically when verifyInvariants() contains a rule that checks state == DELETED (e.g., REMOVE_NO_ITEMS): the abort path re-loads the aggregate and calls verifyInvariants, which fires the check — but JPA's auto-flush has already written the DELETED state, so the invariant throws a SimulatorException instead of the expected application exception.
Rule: Soft-delete goes through copy-on-write like any other mutation, unconditionally — not only where an invariant is known to check
state == DELETED. In-placeremove()appears to work whenever an aggregate's invariants happen not to fire on the abort path, so a service that mutates in place is a latent bug that surfaces the moment such an invariant is added.
A Functionality method receives saga-assembled DTOs from preceding steps. Never mutate a DTO passed into a service method — it is a read-only snapshot from an upstream aggregate. Extract the field values you need and pass them directly to the aggregate or a new value object; do not call any setters on the DTO itself.
When a service method creates an owned entity (e.g. ShipmentItem) and one of its constructor fields cannot be derived from the DTOs available at that point in the implementation order, initialize that field to null as a placeholder:
// TODO: inStock cannot be determined from WarehouseDto — no stock data
// available until Inventory (session 2.8) is implemented.
new ShipmentItem(warehouseId, sequenceNumber, /*inStock=*/ null);Rules:
- Add the
// TODOcomment inline explaining which field is missing and why. - Flag the placeholder explicitly in the session retro — it is not an implicit placeholder; a future session must revisit it.
- When the missing upstream aggregate is implemented, search for the
TODOand fill in the field.
Always throw exceptions with the raw error-message constant and no format arguments, unless the constant string contains a % placeholder that must be filled in and the test assertion checks the formatted value.
// Correct — test can assert: ex.message == SHIPMENT_ALREADY_CANCELLED
throw new {AppClass}Exception(SHIPMENT_ALREADY_CANCELLED);
// Avoid unless the test expects the formatted string, not the constant
throw new {AppClass}Exception(SHIPMENT_ALREADY_CANCELLED, shipmentId);Why: Test assertions use ex.message == CONSTANT (raw format string comparison). When a format argument is passed, getMessage() returns the interpolated string (e.g., "Shipment 42 is already cancelled"), not the literal constant "SHIPMENT_ALREADY_CANCELLED", breaking the equality check. Only use format args when the assertion explicitly verifies the interpolated value.
findAll() (or jpaRepo.findAll()) returns all versions of every aggregate, including historical, superseded, and soft-deleted ones. Any bulk read that must reflect current state must use a "latest active version per aggregateId" query.
JPQL pattern:
@Query("select s from Shipment s " +
"where s.state = 'ACTIVE' " +
"and s.version = (select max(s2.version) from Shipment s2 where s2.aggregateId = s.aggregateId)")
List<Shipment> findAllLatestActive();When to add this: Whenever a custom repository method performs a bulk read (returns multiple aggregate instances) — e.g., findAll, findAllByWarehouseId, findAllOpen. Scoped reads via aggregateLoadAndRegisterRead are unaffected (they already load the latest version).
Add the JPQL method to {Aggregate}Repository.java (JPA repo interface) and call it from {Aggregate}CustomRepositorySagas — never call jpaRepo.findAll() directly in bulk-read implementations.
Declare it on the abstract {Aggregate}CustomRepository too. The service injects the interface,
never the concrete Sagas class (§ Injected Dependencies, and AGENTS.md § Architecture principle),
so a method that exists only on {Aggregate}CustomRepositorySagas is unreachable from the service and
the call does not compile. All three files carry it:
| File | Role |
|---|---|
{Aggregate}Repository.java |
the @Query JPQL itself |
{Aggregate}CustomRepository.java |
the abstract declaration the service calls through |
{Aggregate}CustomRepositorySagas.java |
@Override, delegating to the JPA repo |
P3 guards (own-table reads, uniqueness checks, and DTO field validation from preceding saga steps) belong at the top of the service method, before any create{Aggregate}Copy call. Throwing at this point ensures no aggregate is dirtied before the guard fires. See rule-enforcement-patterns.md for the full taxonomy.