DEV Community

Ivan Rossouw
Ivan Rossouw

Posted on

One New State Means a Projection Audit

The migration was three columns. The behaviour change touched eleven files and introduced sixteen focused integration tests.

That ratio is a useful engineering signal.

A recent committed change added a soft cancellation state to a small operational workflow. Storing the state was straightforward. The real work was deciding what that state meant to every queue, count, aggregate, snapshot, export, transition, and recovery path that already interpreted the record.

The broader lesson is simple: a workflow state is not merely data. It is a contract across every projection and adjacent transition.

A state changes meaning, not only storage

Imagine an operational record that can be active, completed, or cancelled. Adding CancelledAt answers one narrow question: how is cancellation represented?

It does not answer the questions users and downstream systems actually ask:

  • Should cancelled work remain in the active queue?
  • Should it count as outstanding work?
  • Does it contribute to financial or operational totals?
  • Should history retain it or hide it?
  • What should an immutable end-of-period snapshot record?
  • Should a detailed export preserve the row while a summary export excludes it?
  • Can the state be restored?
  • What happens if a success event arrives after cancellation?

Each answer changes a read model or a transition. Leaving one answer implicit is how a locally correct filter becomes a system-wide contradiction.

Build a projection matrix before changing code

A small state-impact matrix makes those decisions reviewable:

Consumer Cancelled record Reason
Active work queue Exclude Nobody should act on it
Follow-up count Exclude It is no longer outstanding
Review history Relocate Preserve an auditable explanation
Live totals Exclude Do not treat it as completed activity
Closed snapshot Aggregate separately Preserve historical reconciliation
Detailed export Include and annotate Keep the event visible
Summary export Exclude Do not inflate completed quantities

The exact answers will differ by domain. The useful pattern is the set of verbs: include, exclude, relocate, annotate, or aggregate separately.

Even “no change” should be an explicit decision. A projection may already exclude the new state because another invariant makes the two conditions mutually exclusive. That is still worth recording. Otherwise a future change can break the hidden relationship without touching the projection.

Transitions belong in the same review

Read models are only half the contract. The state machine also needs rules around entry, exit, and competing events.

For a soft state, useful questions include:

  1. Which states may enter cancellation?
  2. Is repeating the command harmless?
  3. Can an operator restore the record?
  4. Which later events must clear or supersede cancellation?
  5. What audit information survives every transition?

The reviewed work made cancellation idempotent, provided a restore path, rejected one invalid adjacent state, and declared that a late success event should take precedence. Those behaviours were backed by focused tests.

There is an important limit, though. A sequential test—cancel, then process success—proves ordering in that sequence. It does not prove safety when two handlers read and write concurrently. If the race is plausible, enforce the precedence with optimistic concurrency, a conditional update, or another database-level invariant. A comment and a unit test cannot make overlapping writes atomic.

The trade-off: ceremony versus drift

A state matrix, explicit transition rules, and cross-projection tests cost more than adding a Boolean and scattering a few filters. They also create more places that must evolve when the workflow changes again.

That cost is real. Keep the matrix small, centralise shared predicates where the semantics truly match, and avoid inventing a heavyweight state-machine framework for a tiny workflow.

The return is operational consistency. Without the ceremony, an active queue can exclude a record while its badge still counts it. A live total can exclude it while a frozen snapshot includes it. A detailed export can preserve it while a summary quietly treats it as completed. Every query may compile and still tell a different story.

Test crossings, not implementation details

The strongest tests exercise the boundaries between a transition and its consumers:

  • cancelling removes the record from active and follow-up views;
  • restoring returns it to the correct view;
  • repeated cancellation is harmless;
  • live counts match their corresponding lists;
  • live totals and historical snapshots apply the same meaning;
  • detailed and summary exports make their different policies visible;
  • a later event follows the declared precedence rule.

These tests are more valuable than asserting that a property became non-null. They protect the semantics users rely on.

Integration coverage still has limits. Tests against a lightweight database can validate real query shapes without proving the production migration, provider-specific behaviour, complete UI wiring, or true concurrency. Name those limits so a green suite is evidence, not theatre.

A practical review sequence

When adding a workflow state:

  1. Draw the allowed transitions.
  2. Inventory queues, counters, aggregates, snapshots, exports, notifications, and recovery jobs.
  3. Mark each consumer include, exclude, relocate, annotate, or separate.
  4. Define idempotency and race precedence.
  5. Test each transition-to-projection crossing.
  6. Add database protection where concurrency matters.

The write is usually the smallest part of a state change. Audit the reads, because that is where the system explains what the state means.

Which projection in your system would be easiest to overlook?

Top comments (0)