ADR-0001: RaceNumber state transitions live in the service, not the callers

Status

Accepted

Date

2026-04-24

Deciders

[email protected]

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

recordNumberAssignment(ep, number, reason, actor, note)

Admin DTO save/update, bulk auto-assign, EP import (future)

Result-import encounter

recordResultImport(ep, number, eventDate, actor, note)

ResultImportXLS, async RaceResultRowProcessor

Return to stock (single / bulk)

returnNumber(rn, …​) / returnNumbers(ids, …​)

Stock-take scan, admin UI "return"

Flag as unfit (single / bulk)

flagUnfit(ids, …​)

Admin UI

Dispose (UNFIT → DESTROYED)

dispose(ids, …​)

Admin UI

Mark lost

markNumberLost(ep, …​)

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 — RaceNumberAssignmentServiceEx is 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.

3.2. Negative

  • Callers lose local flexibility. A special-case transition required by one importer must become a new service method, not a local mutation.

  • The service grows over time. Discipline is required to keep methods focused and well-named rather than adding boolean flags.

3.3. Neutral

  • The service is @Transactional — callers join whatever transaction context they’re in. Tests and integration patterns need to respect this.

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).

5. References

  • Design journal: design-journal/2026-03/number-tag-management.adoc (sessions 1-3 — state machine design)

  • Code: admin-service/src/main/java/za/co/idealogic/event/admin/service/RaceNumberAssignmentServiceEx.java

  • ADO: Feature #473, US #475, Task #482