ADR-0001: RaceNumber state transitions live in the service, not the callers
| Status |
Accepted |
| Date |
2026-04-24 |
| Deciders | |
| Related |
Feature #473 (Number & Tag Lifecycle Management), US #475 (Import-hook integration), US #482 (EP assignment hook) |
1. Context
RaceNumber has a small but non-trivial state machine (MANUFACTURED, IN_STOCK, ISSUED, IN_USE, UNFIT_FOR_SERVICE, DESTROYED). Transitions are driven from several places:
-
Admin UI manual assignment (DTO save / update / partialUpdate).
-
Bulk auto-assign at registration (WS2).
-
Loan return / stock-take scan (WS1a).
-
Result import (post-event).
-
Future EP-import number assignment (US #506).
-
Admin actions: flag unfit, dispose, mark lost.
Before WS1a, each caller mutated RaceNumber.state directly or through legacy helpers (notably addNumberNoLaterThan) with its own idea of what transitions were valid. Concrete examples of drift:
-
ResultImportXLS.addNumberNoLaterThan(…)ignored the UNFIT flag entirely — it would silently re-issue a number flagged as broken. -
Admin DTO save fired a log entry via one code path; the importer fired none.
-
No central place described which source states were valid for which transition.
This drift is exactly the situation where a "why did you do it this way?" question has no canonical answer — the answer depends on which entry point the caller happens to use.
2. Decision
RaceNumberAssignmentServiceEx owns every transition. Callers never write to RaceNumber.state, RaceNumber.person, RaceNumber.lastUsed, or race_number_state_log directly. Instead, they call a named service method that encodes the intent:
| Intent | Service method | Typical callers |
|---|---|---|
Assignment / reassignment to an EP |
|
Admin DTO save/update, bulk auto-assign, EP import (future) |
Result-import encounter |
|
|
Return to stock (single / bulk) |
|
Stock-take scan, admin UI "return" |
Flag as unfit (single / bulk) |
|
Admin UI |
Dispose (UNFIT → DESTROYED) |
|
Admin UI |
Mark lost |
|
Lost-number workflow |
Each method owns the full decision: which transitions are permitted, which log reason is recorded, how last_used is treated, and what rejects with IllegalStateException.
Importers, REST resources, and the admin UI are thin drivers — they resolve the right entity inputs and call the right service method. No business logic about the state machine lives in those layers.
3. Consequences
3.1. Positive
-
Single source of truth. Every rule change lands in one file; tests can assert behaviour without combinatorial coverage across every caller.
-
The state machine becomes documentable and teachable —
RaceNumberAssignmentServiceExis the state machine. -
New callers (REST endpoints, new importers, sync from WooCommerce) reuse established semantics for free.
-
Audit logging (
race_number_state_log) is comprehensive and consistent because it’s emitted from the same code path.
4. Alternatives Considered
4.1. Alternative A: Keep transitions in each caller
This is the state that existed pre-WS1a. Rejected because it directly produced the drift described in the Context. A year of divergence created rules like "result-import silently re-issues UNFIT numbers" that nobody had explicitly decided.
4.2. Alternative B: JPA lifecycle callbacks (@PreUpdate) on the entity
Put the state-machine logic in RaceNumber itself using JPA entity listeners. Rejected because entity listeners have no access to user-facing intent (was this a "carry-over" or a "reassignment"? we can’t tell from a state delta), no clean way to write the audit-log row in the same transaction, and no place to reject invalid transitions with a caller-visible exception. Listeners also run implicitly — they hide state changes from reviewers rather than surfacing them.
4.3. Alternative C: Domain events + reactive handlers
Fire a NumberAssignmentRequested event, let a handler decide and apply. Defensible for a larger system but overkill here — the callers and the transitions are in the same service layer, so indirection would cost more than it buys. Revisit if the feature needs to fan out to external systems (e.g., notifying timing system on assignment).