This document is the system-level companion to the per-concept docs. Read it before implementing a new application to understand how the layers fit together, what constraints govern each one, and which simulator base classes to extend.
| Concept | One-liner | Deep dive |
|---|---|---|
| Aggregate | Unit of consistency; each write creates a new row; aggregateId is the logical identity; version is global |
concepts/aggregate.md |
| Unit of Work | Coordinates reads and writes for one functionality execution; committed or aborted atomically. Saga: SagaUnitOfWork |
concepts/sagas.md |
| Functionality | A WorkflowFunctionality subclass that orchestrates a cross-service operation as a DAG of Steps |
concepts/sagas.md |
| Sagas | Concurrency protocol using semantic locks (SagaState); conflicting steps declare forbiddenStates |
concepts/sagas.md |
| Class | Path | Role |
|---|---|---|
Aggregate |
simulator/.../ms/aggregate/Aggregate.java |
Base for all domain aggregates; defines verifyInvariants(), getEventSubscriptions(), version chain |
SagaAggregate |
simulator/.../ms/transaction/sagas/aggregate/SagaAggregate.java |
Interface for semantic-lock protocol; adds getSagaState() / setSagaState() |
WorkflowFunctionality |
simulator/.../ms/coordination/WorkflowFunctionality.java |
Base for all cross-service workflows; provides executeWorkflow(), executeUntilStep(), resumeWorkflow() |
Every application built on the simulator has five synchronous layers plus an asynchronous event pipeline:
| Layer | Key class(es) | Responsibility |
|---|---|---|
| Controller | *Controller |
HTTP entry points; maps requests to commands and returns DTOs |
| Functionality | *FunctionalitySagas |
Orchestrates cross-aggregate workflows as a DAG of steps |
| Command Handler | *CommandHandler |
Routes commands from CommandGateway to service methods |
| Service | *Service |
Holds business logic; reads and writes its own aggregate type via aggregateLoadAndRegisterRead |
| Aggregate | Xxx, SagaXxx |
Encapsulates domain state; enforces intra-invariants at UoW commit |
Asynchronous event pipeline (one per inter-invariant dependency):
Aggregate publishes event
→ EventHandling polls for new events
→ EventHandler invokes EventProcessing
→ Update Functionality caches publisher state in consumer aggregate
Canonical directory layout for one microservice. Each package maps to an architectural layer:
Two packages under microservices/ are shared rather than per-service: exception/
({App}Exception, {App}ErrorMessage) and domain/ ({App}DomainConstants, present only when some
aggregate declares a sentinel). Both hold plain constants and exception types rather than beans, so
they cross service boundaries without coupling one microservice's package to another's.
microservices/{serviceName}/
├── {Xxx}ServiceApplication.java
├── aggregate/ ← LAYER: Aggregate
│ ├── {Xxx}.java (base abstract aggregate)
│ ├── {Xxx}Dto.java (immutable DTO)
│ ├── {Xxx}Repository.java
│ ├── {Xxx}CustomRepository.java
│ ├── {Xxx}Factory.java
│ └── sagas/
│ ├── Saga{Xxx}.java (implements SagaAggregate)
│ ├── states/{Xxx}SagaState.java
│ ├── factories/
│ └── repositories/
├── service/ ← LAYER: Service
│ └── {Xxx}Service.java
├── messaging/ ← LAYER: Command Handler
│ └── {Xxx}CommandHandler.java
├── coordination/ ← LAYER: Functionality + Controller
│ ├── webapi/
│ │ └── {Xxx}Controller.java
│ ├── functionalities/
│ │ └── {Xxx}Functionalities.java (entry point / dispatch)
│ ├── sagas/
│ │ └── {Operation}FunctionalitySagas.java
│ └── eventProcessing/ ← optional: only if aggregate consumes events
│ └── {Xxx}EventProcessing.java
└── notification/ ← optional: only if aggregate consumes events
├── handling/
│ ├── {Xxx}EventHandling.java (polling loop)
│ └── handlers/
│ └── {Xxx}EventHandler.java (single dispatcher - one per aggregate, not per event)
└── subscribe/
└── {Xxx}Subscribes{Event}.java
Optional directories:
coordination/eventProcessing/andnotification/are present only in aggregates that consume events from other services (e.g., Shipment). Aggregates that only publish events (e.g., Warehouse) omit both directories.
Each microservice has a @SpringBootApplication entry point gated by @Profile("{xxx}-service"). This lets the service run in isolation (activated by its named profile) while remaining inert inside the simulator's monolithic {AppClass}Simulator context (which does not activate any per-service profile). The class:
- Scans only its own
microservices/{xxx}package pluspt.ulisboa.tecnico.socialsoftware.ms - Implements
InitializingBeanto calleventService.clearEventsAtApplicationStartUp()on startup - Is profile-gated and does not conflict with the monolithic simulator entry point
A minimal @RestController stub under coordination/webapi/. In the simulator, HTTP endpoints are not exercised by the test harness (tests drive operations directly via {Xxx}Functionalities). The controller is created as an empty stub to mark the architectural slot. Endpoints can be filled in when a web-API layer is needed.
Happy-path flow from an HTTP request through UoW commit and into the async event tail. Pattern codes refer to the taxonomy in concepts/rule-enforcement-patterns.md.
HTTP Request
│
▼
Controller
│ creates Command
▼
Functionality (WorkflowFunctionality)
│
├──► Step N: commandGateway.send(GetXxxCommand)
│ └─ CommandHandler → Service.getXxx()
│ aggregateLoadAndRegisterRead(id, uow) ← own type only
│ returns XxxDto to the functionality
│
└──► Step M: commandGateway.send(MutateXxxCommand) ← depends on N
└─ CommandHandler → Service.mutateXxx()
[P3] service-layer guard
own-table reads, uniqueness checks, or DTO field validation;
runs inside @Transactional(SERIALIZABLE), throw if precondition violated
aggregate.mutate()
unitOfWorkService.registerChanged(aggregate, uow)
UoW commit
[P1] verifyInvariants() on each changed aggregate
persist new version row
publish domain events
Async (~1 s poll interval)
EventHandling detects new events
[P2] EventProcessing → Update Functionality
consumer aggregate caches publisher state
These rules are not enforced by the compiler. Violating them produces subtle runtime failures or breaks the concurrency guarantees the simulator is designed to provide.
aggregateLoadAndRegisterRead(aggregateId, uow) must be called only for the aggregate type that the service owns. Passing an ID that belongs to a different aggregate type breaks UoW read tracking and produces incorrect version-conflict detection.
Instead: Read another aggregate's state by issuing a Get*Command through a workflow step (commandGateway.send(...)), which routes to that aggregate's own service.
A service class may @Autowired (or constructor-inject) only the repository, custom repository, and factory belonging to its own aggregate type, plus shared infrastructure (UnitOfWorkService, AggregateIdGeneratorService). Injecting a foreign service class mixes transaction boundaries and bypasses the UoW; injecting a foreign repository gives the service direct read/write access to a foreign data store — both escape the coordinated commit.
Correct injections in XxxService:
XxxRepository/XxxCustomRepositoryXxxFactoryUnitOfWorkServiceAggregateIdGeneratorService
Instead: Coordinate cross-aggregate operations at the Functionality layer. Each service is only called through its CommandHandler via commandGateway.
A service may accept and return {Xxx}Dto objects belonging to any aggregate. It must never hold a reference to another aggregate's concrete class (e.g., SagaWarehouse, CausalShipment). Aggregate instances carry UoW registration state that must not leak across service boundaries.
Instead: Expose all observable state through immutable DTO classes. The Functionality receives the DTO from a Get*Command step and passes the needed fields to downstream steps as plain values.
In the Sagas protocol, a step that touches an aggregate that already exists when the saga starts must declare how it guards against concurrent operations. Which mechanism applies depends on the aggregate's relationship to the saga:
- Primary aggregate (the one owning this saga) — wrap the read command in
SagaCommandand callsetSemanticLock(state)on it. The mutate step that follows sends a plain, unwrapped command and declares the lock step as a dependency. Do not useforbiddenStatesto acquire a primary-aggregate lock. - Foreign aggregate (an upstream aggregate a cross-aggregate step touches) — wrap the command in
SagaCommandand callsetForbiddenStates([...])on it, listing theSagaStatevalues of concurrent operations that would conflict. This checks that the foreign aggregate is not already mid-saga; it does not acquire a lock. Both saga-state setters live onSagaCommand, so a guarded step wraps exactly as a lock step does.
Declaring neither lets two operations interleave in ways that violate business rules.
The scope is deliberate. A step that creates an aggregate has no prior state to guard: nothing else can hold a lock on an aggregate whose id the service has not minted yet. Such a step declares neither a semantic lock nor forbiddenStates, and instead registers a compensation that removes what it created if and only if a later step follows it. See concepts/sagas.md § "Create Functionality Sagas".
concepts/sagas.md § "R4 Decision Table" is authoritative for which of the three cases applies, and § "Lock-Acquisition Step Pattern" shows how semantic locks are acquired and checked.
Subscriptions encode a one-way dependency: the consumer caches state from the publisher. The upstream (publisher) aggregate must not subscribe to its own events and must not reference downstream aggregate types. Adding subscriptions in the wrong direction creates circular dependencies in the event pipeline.
See concepts/rule-enforcement-patterns.md P2 for the upstream/downstream model.
verifyInvariants() is called inside the UoW commit path, after all mutations have been applied. Repository calls at this point risk deadlocks and violate the layering contract. Intra-invariants must check only fields already present on the aggregate instance.
Instead: Use a P3 service-layer guard in *Service.java, which runs before the UoW commit and can safely read from the DB.
DTOs are point-in-time snapshots of an aggregate's observable state. A Functionality step must not mutate a DTO it received from a Get*Command. Mutations must be expressed as new commands dispatched to the owning service.
// WRONG — mutates a snapshot owned by another aggregate. The write is invisible to
// that aggregate's UoW, so it is never persisted and never compensated.
{Aggregate}Dto dto = ({Aggregate}Dto) commandGateway.send(new Get{Aggregate}ByIdCommand(id));
dto.set{Field}(newValue);
// RIGHT — read the snapshot, send the change as a command to the owning service.
{Aggregate}Dto dto = ({Aggregate}Dto) commandGateway.send(new Get{Aggregate}ByIdCommand(id));
commandGateway.send(new Update{Aggregate}{Field}Command(dto.getAggregateId(), newValue));A functionality that belongs to aggregate A may only issue commands (read or mutate) to aggregates that are upstream of A in the event dependency graph. It must never send commands to aggregates that are downstream of A.
Why: Downstream aggregates depend on A's events to cache A's state — the data-flow direction is A → downstream. Sending a command from A's functionality to a downstream aggregate reverses that direction, couples A to downstream internals, and risks circular command chains.
// WRONG — A's functionality reaches into a downstream aggregate to push the new value.
// The command chain now runs both ways, and A must know which downstream types cache the field.
commandGateway.send(new Update{Aggregate}Command(unitOfWork,
ServiceMapping.{AGGREGATE}.getServiceName(), {aggregate}AggregateId, newValue));
commandGateway.send(new Set{Downstream}{Field}Command(unitOfWork,
ServiceMapping.{DOWNSTREAM}.getServiceName(), {downstream}AggregateId, newValue));
// RIGHT — A's step updates A only. The downstream aggregate subscribes to A's event and
// folds the new value into its cached copy through its own ByEvent path.
commandGateway.send(new Update{Aggregate}Command(unitOfWork,
ServiceMapping.{AGGREGATE}.getServiceName(), {aggregate}AggregateId, newValue));
// {Aggregate}Service registers Update{Aggregate}Event; the downstream aggregate declares the
// matching subscription in getEventSubscriptions() — see concepts/events.md § Canonical Wiring Snippet.For a quick decision, use this table. For the full decision flowchart and pattern recipes, see concepts/rule-enforcement-patterns.md.
| Rule type | Pattern |
|---|---|
| Always true within one aggregate; derivable from its own fields | P1 — verifyInvariants() |
| Synchronous service-level check (own-table uniqueness OR saga-assembled DTO field validation) | P3 — service guard |
| Cross-aggregate; eventual consistency is acceptable | P2 — inter-invariant via domain events |
| Precondition implicit in saga fetch / same value to two aggregates | P4a/P4b — by construction |
| Topic | Path |
|---|---|
| Aggregate versioning | concepts/aggregate.md |
| Service layer patterns | concepts/service.md |
| Commands & CommandHandler | concepts/commands.md |
| Sagas semantic locks | concepts/sagas.md |
| Domain events | concepts/events.md |
| Rule-enforcement patterns (full) | concepts/rule-enforcement-patterns.md |