feat: transaction lifecycle — states, events, and invariant guards (Phase 3.1/3.2)#50
Merged
jorge07 merged 1 commit intojorge07:masterfrom Feb 22, 2026
Conversation
…hase 3.1/3.2)
Phase 3.1 + 3.2 from the evolution roadmap.
### Domain
TransactionStatus enum (PENDING → CONFIRMED | FAILED; CONFIRMED → REFUNDED)
Three new domain events — primitives only, no domain objects:
- TransactionWasConfirmed(aggregateId)
- TransactionFailed(aggregateId, reason)
- TransactionWasRefunded(aggregateId)
Transaction aggregate updated:
- status field, starts PENDING on create
- confirm(): guard throws InvalidStateException if not PENDING
- fail(reason): guard throws InvalidStateException if not PENDING
- refund(): guard throws InvalidStateException if not CONFIRMED
- registerHandler for all four events; currentStatus getter
InvalidStateException: new shared exception for invariant violations.
Source: Vernon (IDDD) ch. 7 — each state transition must be guarded.
ITransactionReadDTO: added status field
IRepository: added updateStatus(uuid, status)
### Infrastructure
Transactions entity: added status column (varchar, default PENDING)
PostgresRepository: save() includes status; new updateStatus() via .update()
PostgresProjector: handles all four events; onTransactionWas* delegates
to readModel.updateStatus for the three transition events
BillingAPI: added patch case to bindRouting switch
### Application
New command/handler pairs: Confirm, Fail, Refund
- Load aggregate from event store
- Call the domain method (guard throws if invalid state)
- Save back (events published to event bus, projector updates read model)
- Throws NotFoundException if aggregate not found
TransactionModule: registers Confirm, Fail, Refund commands; subscribes
projector to all four events
HTTP: PATCH /transaction/:uuid/confirm|fail|refund
- confirm: 204 on success
- fail: accepts optional { reason } body
- refund: 204 on success
### Tests
Test doubles cleaned up:
- InMemoryReadModelRepository: proper IRepository implementation (Map)
- Replaced GenericInMemoryRepository + InMemoryReadModelRepository<any>
combo with a typed, interface-compliant double
- InMemoryProjector: now typed to IRepository, handles all four events
- TestKernelFactory: subscribes projector to all four events
Transaction.test.ts: 11 domain tests covering create, guard invariants
for confirm/fail/refund (happy path + guard violations)
Lifecycle.test.ts: 7 integration tests covering PENDING→CONFIRMED,
PENDING→FAILED, CONFIRMED→REFUNDED, not-found, double-confirm guard,
refund-before-confirm guard
41/41 tests pass.
jorge07
approved these changes
Feb 22, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Phase 3.1 + 3.2 from the evolution roadmap: full transaction lifecycle end-to-end.
Domain
TransactionStatusenum —PENDING → CONFIRMED | FAILED; CONFIRMED → REFUNDEDThree new domain events (primitives only):
TransactionWasConfirmed(aggregateId)TransactionFailed(aggregateId, reason)TransactionWasRefunded(aggregateId)Transactionaggregate updated:statusfield, startsPENDINGon creationconfirm()— guard throwsInvalidStateExceptionif notPENDINGfail(reason)— guard throwsInvalidStateExceptionif notPENDINGrefund()— guard throwsInvalidStateExceptionif notCONFIRMEDInvalidStateException— new shared exception for invariant violations.Source: Vernon (IDDD) ch. 7 — "each state transition must be guarded by invariants that prevent invalid moves."
ITransactionReadDTO— addedstatusfieldIRepository— addedupdateStatus(uuid, status)Infrastructure
Transactionsentity:statuscolumn (varchar, defaultPENDING)PostgresRepository:save()includes status;updateStatus()via.update()PostgresProjector: handles all four events; delegates status changes toreadModel.updateStatus()BillingAPI: addedpatchcase tobindRouting()switchApplication layer
New command/handler pairs: Confirm, Fail, Refund
NotFoundExceptionif aggregate not foundTransactionModule: registers the three new commands; subscribes projector to all four eventsHTTP:
PATCH /transaction/:uuid/confirm|fail|refundTests
Test doubles cleaned up:
InMemoryReadModelRepository: properIRepositoryimplementation backed by aMapGenericInMemoryRepository+InMemoryReadModelRepository<any>duck-typing comboInMemoryProjectornow typed toIRepositoryand handles all four eventsTestKernelFactorysubscribes projector to all four eventsTransaction.test.ts— 11 domain tests: create, all three lifecycle methods (happy path + guard violations)Lifecycle.test.ts(new) — 7 integration tests: PENDING→CONFIRMED, PENDING→FAILED, CONFIRMED→REFUNDED, not-found, double-confirm guard, refund-before-confirm guard41/41 tests pass.