Commands are the messages that cross the boundary between the Functionality layer and the Service layer. Every inter-service call inside a workflow step is expressed as a command dispatched through CommandGateway.
A Command subclass carries the inputs for one service operation. It holds the unit of work, the target service name, the primary aggregate ID (passed to super(...)), and any domain-specific payload fields.
public class AddShipmentItemCommand extends Command {
private Integer shipmentAggregateId;
private WarehouseDto warehouseDto;
protected AddShipmentItemCommand() {}
public AddShipmentItemCommand(UnitOfWork unitOfWork, String serviceName,
Integer shipmentAggregateId, WarehouseDto warehouseDto) {
super(unitOfWork, serviceName, shipmentAggregateId);
this.shipmentAggregateId = shipmentAggregateId;
this.warehouseDto = warehouseDto;
}
public Integer getShipmentAggregateId() { return shipmentAggregateId; }
public WarehouseDto getWarehouseDto() { return warehouseDto; }
public void setShipmentAggregateId(Integer shipmentAggregateId) { this.shipmentAggregateId = shipmentAggregateId; }
public void setWarehouseDto(WarehouseDto warehouseDto) { this.warehouseDto = warehouseDto; }
}The no-arg constructor and the setters are mandatory, not stylistic. The test profile sets
local.messaging.serialize: trueto mimic remote communication, so every command round-trips through Jackson before it reaches its handler. A command with only a multi-arg constructor and getters fails to deserialize at runtime, on the first command the application sends.
super(unitOfWork, serviceName, aggregateId):
unitOfWork— the activeSagaUnitOfWorkfor this workflow execution.serviceName— the routing key. AlwaysServiceMapping.{AGGREGATE}.getServiceName()(e.g.ServiceMapping.SHIPMENT.getServiceName()→"shipment"), never a hardcoded literal and nevergetAggregateTypeName()'s PascalCase value — see § Routing Commands below for why the two differ.aggregateId— stored on the baseCommandasrootAggregateId. It is the aggregate whose semantic lock lifecycle this command participates in. It has no consumers in theunitOfWorkpackage; under sagas it is read only bySagaCommandHandler, forverifySagaState(rootAggregateId, forbiddenStates)and for lock registration.
Commands are plain data carriers — no business logic, no Spring beans.
When there is no root aggregate to name, pass
null. Several step shapes have none: a create command, whose id the service has not minted yet (it generates it viaaggregateIdGeneratorService); an unfiltered collection read - aGet{Aggregates}Commandthat returns every instance of its type and so takes no id at all; and a composite-key read - aGet{Aggregate}By{Field}Commandthat names its target by a compound domain key held on the aggregate, so the target's own id is unknown until the service has resolved that key.A filtered collection read is not one of them: its filter is a foreign aggregate id, which it passes.
This is safe rather than merely tolerated, and the reason is a property of the step, not of createness: the field is dereferenced only by
SagaCommandHandler, and only for aSagaCommandcarrying forbidden states or a semantic lock. A step that declares neither never reaches that code. All the shapes above declare neither. Version conflict detection is unaffected either way; it operates on the aggregates registered read/changed with the unit of work, not on this field.The converse still holds: a step that does declare a semantic lock or forbidden states must name the aggregate whose lock lifecycle it participates in.
It is the third super(...) argument that goes null, not a payload field:
public Create{Aggregate}Command(UnitOfWork unitOfWork, String serviceName, {Aggregate}Dto {aggregate}Dto) {
super(unitOfWork, serviceName, null); // rootAggregateId - the service mints the id
this.{aggregate}Dto = {aggregate}Dto;
}A bulk read filtered by a non-PK field likewise has no single root aggregate. Pass
null, for the same reason: it declares no semantic lock and no forbidden states, so the handler never dereferences it. Do not pass the filter value even when the filter is a foreign aggregate's id -rootAggregateIdnames the aggregate whose lock lifecycle this command joins, and a foreign aggregate that merely narrows the result set is not it.
Same position, same argument: super(unitOfWork, serviceName, null), with the filter value carried
as an ordinary payload field beside it.
| Purpose | Pattern | Example |
|---|---|---|
| Read command | Get<Xxx>Command |
GetShipmentByIdCommand |
| Mutate command | <Operation><Xxx>Command |
AddShipmentItemCommand, RemoveWarehouseCommand |
| Event-driven update | Update<Field>Command |
UpdateUserNameCommand |
src/main/java/pt/ulisboa/tecnico/socialsoftware/{app}/commands/{aggregate}/
Get<Xxx>Command.java
<Operation><Xxx>Command.java
All commands for the same target aggregate live in one package under the application root (not inside a microservice package) so they can be shared across services.
Each application defines a ServiceMapping enum that maps aggregate names to their service routing strings. Use these constants when constructing commands — never hardcode string literals.
public enum ServiceMapping {
SHIPMENT("shipment"),
WAREHOUSE("warehouse"),
// ...
;
private final String serviceName;
ServiceMapping(String serviceName) { this.serviceName = serviceName; }
public String getServiceName() { return serviceName; }
}Multi-word aggregates — camelCase required: The framework derives the commit/abort service bean at runtime by calling
resolveServiceName(aggregateClass), which strips "Saga" from the simple class name and lowercases the first character. For multi-word aggregates this produces camelCase:SagaShipmentItem→"shipmentItem". TheServiceMappingvalue must match this result exactly. A shortened alias (e.g."item") causes a silent bean-lookup failure that only manifests when the aggregate is locked viaSagaCommandat runtime. Example of a correct entry:SHIPMENT_ITEM("shipmentItem"), // SagaShipmentItem → resolveServiceName → "shipmentItem"
Pass ServiceMapping.SHIPMENT.getServiceName() as the serviceName argument to a command constructor.
Inside a saga step, dispatch a command with commandGateway.send(...). The return type is Object — cast to the expected DTO for read commands; ignore it for mutating commands.
// Read step — returns a DTO
SagaStep getWarehouseStep = new SagaStep("getWarehouseStep", () -> {
GetWarehouseByIdCommand cmd = new GetWarehouseByIdCommand(
unitOfWork,
ServiceMapping.WAREHOUSE.getServiceName(),
warehouseAggregateId);
this.warehouseDto = (WarehouseDto) commandGateway.send(cmd);
});
// Mutate step — depends on getWarehouseStep; sets forbiddenStates for Sagas
SagaStep addShipmentItemStep = new SagaStep("addShipmentItemStep", () -> {
AddShipmentItemCommand cmd = new AddShipmentItemCommand(
unitOfWork,
ServiceMapping.SHIPMENT.getServiceName(),
shipmentAggregateId,
this.warehouseDto);
List<SagaAggregate.SagaState> forbiddenStates = new ArrayList<>();
forbiddenStates.add(ShipmentSagaState.IN_UPDATE_SHIPMENT);
SagaCommand sagaCommand = new SagaCommand(cmd);
sagaCommand.setForbiddenStates(forbiddenStates);
commandGateway.send(sagaCommand);
}, List.of(getWarehouseStep));setForbiddenStates is declared on SagaCommand, not on Command, and its parameter type is
List<SagaAggregate.SagaState> — a List.of(ShipmentSagaState.IN_UPDATE_SHIPMENT) infers
List<ShipmentSagaState> and will not convert. sagas.md § Semantic Locks in Practice
owns both rules.
Commands travel upstream only (R8). A saga may only send commands to aggregates it depends on, never to an aggregate that depends on it — downstream aggregates learn of changes through events, not commands. See sagas.md § Step Ordering.
Each aggregate has one CommandHandler that receives all commands for that aggregate and dispatches them to the service. Use a switch over sealed/pattern-matched types.
@Component
public class ShipmentCommandHandler extends CommandHandler {
private static final Logger logger = Logger.getLogger(ShipmentCommandHandler.class.getName());
@Autowired
private ShipmentService shipmentService;
@Override
public String getAggregateTypeName() {
// PascalCase, used for decorator lookup - not the routing key (see below)
return "Shipment";
}
@Override
public Object handleDomainCommand(Command command) {
return switch (command) {
case GetShipmentByIdCommand cmd -> shipmentService.getShipmentById(
cmd.getShipmentAggregateId(), cmd.getUnitOfWork());
case AddShipmentItemCommand cmd -> {
shipmentService.addShipmentItem(
cmd.getShipmentAggregateId(), cmd.getWarehouseDto(), cmd.getUnitOfWork());
yield null;
}
// ... one case per command
default -> {
logger.warning("Unknown command: " + command.getClass().getName());
yield null;
}
};
}
}The logger declaration is part of the template: the base CommandHandler in ms.messaging declares
no logger field, so the default -> branch above does not compile without it.
getAggregateTypeName() returns a PascalCase name (e.g. "Warehouse") used by CommandHandlerDecorator for decorator lookup — it is not the routing key.
Actual routing: LocalCommandService.send() resolves the handler via applicationContext.getBean(command.getServiceName() + "CommandHandler"). The Spring bean name of the CommandHandler must equal ServiceMapping.{AGGREGATE}.getServiceName() + "CommandHandler" (e.g. "warehouseCommandHandler"). The @Bean method in BeanConfigurationSagas must use that exact lowercase camelCase name. For multi-word aggregates this is a camelCase name — e.g. the bean method for ShipmentItem must be named shipmentItemCommandHandler, not itemCommandHandler.
Mutating handlers return null; read handlers return the DTO produced by the service method.
Some DTOs returned by upstream service commands are structurally incomplete — they carry enough data for display or membership checks but omit fields (such as version) that a saga needs to construct a dependent aggregate. When this happens, insert an extra command step to fetch the missing data rather than inferring or hardcoding it.
A DTO nested inside an owning aggregate's DTO (e.g. WarehouseSlotDto, returned as part of WarehouseDto.slots) may not carry a version field. Any saga that needs to record the member aggregate's version when creating or modifying an aggregate that embeds a snapshot of it (e.g. Shipment.origin, Shipment.items) cannot derive the version from the membership-check step alone.
Compensating pattern: insert a dedicated Get{Publisher}ByIdCommand step immediately after the membership-check step to retrieve the full publisher DTO, which does carry version:
// Step 1 — verify the referenced member belongs to the owner (returns WarehouseDto)
SagaStep getWarehouseStep = new SagaStep("getWarehouseStep", () -> {
this.warehouseDto = (WarehouseDto) commandGateway.send(
new GetWarehouseByIdCommand(unitOfWork,
ServiceMapping.WAREHOUSE.getServiceName(), warehouseAggregateId));
});
// Step 2 — fetch the full CarrierDto so we have the version field
SagaStep getOriginCarrierStep = new SagaStep("getOriginCarrierStep", () -> {
this.carrierDto = (CarrierDto) commandGateway.send(
new GetCarrierByIdCommand(unitOfWork,
ServiceMapping.CARRIER.getServiceName(), carrierAggregateId));
}, List.of(getWarehouseStep));Name the step after the role the fetched aggregate plays in the operation (getOriginCarrierStep when it is the single owner-like reference, get{Publisher}Step when it is one of many members). The DTO returned by Get{Publisher}ByIdCommand carries version and can be passed directly to the downstream command.