Skip to content

fix: replace Price object in TransactionWasCreated with primitives#46

Merged
jorge07 merged 2 commits intojorge07:masterfrom
josecarlospeer-cloud:fix/event-primitive-data
Feb 22, 2026
Merged

fix: replace Price object in TransactionWasCreated with primitives#46
jorge07 merged 2 commits intojorge07:masterfrom
josecarlospeer-cloud:fix/event-primitive-data

Conversation

@josecarlospeer-cloud
Copy link
Contributor

What

TransactionWasCreated was carrying a Price value object instead of primitive data. This couples the event schema to the Price class — a latent replay correctness bug.

Why it matters

Domain events are the durable record of what happened. They are serialized to the event store and replayed to rebuild aggregates. If Price ever changes (e.g., gains a taxRate field), old events deserialized from storage will be structurally inconsistent with the new class — silently producing wrong aggregate state on replay.

"Events are the durable record. They must be self-describing with primitive data."
— Young, CQRS Documents (2010)

Before

export default class TransactionWasCreated implements Domain.DomainEvent {
    constructor(
        public readonly aggregateId: string,
        public readonly product: string,
        public readonly price: Price,  // ← complex object, schema-coupled
        occurredAt?: Date,
    ) {}
}

After

export default class TransactionWasCreated implements Domain.DomainEvent {
    constructor(
        public readonly aggregateId: string,
        public readonly product: string,
        public readonly amount: number,    // ← primitive
        public readonly currency: string,  // ← primitive
        occurredAt?: Date,
    ) {}
}

Transaction.onTransactionWasCreated() reconstructs Price from primitives:

this.price = new Price(event.amount, event.currency);

Transactions.fromCreated() reads event.amount / event.currency directly (no change in semantics, removes property traversal through a domain class).

Transaction.test.ts updated to assert event.amount and event.currency individually.

Verification

yarn lint   ✅ 0 errors
yarn test   ✅ 13/13 passing

Domain events should carry only primitive data so they are safe to
serialize and replay without coupling the event schema to domain classes.

TransactionWasCreated previously embedded a Price value object. If Price
gains or changes a field, old events deserialized from the event store
would be silently inconsistent — a latent replay correctness bug.

Fix: TransactionWasCreated now carries amount: number and currency: string.
Transaction.onTransactionWasCreated() reconstructs Price from these primitives.
Transactions.fromCreated() uses event.amount and event.currency directly.

Transaction.test.ts updated: asserts event.amount and event.currency
instead of event.price (which no longer exists on the event).

Source: Young — "Events are the durable record. They must be self-describing
with primitive data." CQRS Documents (2010).
Copy link
Owner

@jorge07 jorge07 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this needs precision, not the best way to deal with money in the long run

IEEE 754 floats cannot represent all decimal values exactly. Storing
money amounts as float is a correctness risk — 0.1 + 0.2 yields
0.30000000000000004, which serialised into an event store produces a
permanently incorrect durable record.

Fix: Price.amount is now a string (e.g. "12.50"). Validators convert
to Number() only for range checks; the raw string is stored unchanged.

Propagation:
- TransactionWasCreated.amount: number → string (event carries string)
- Transaction.onTransactionWasCreated: new Price(event.amount, event.currency)
  reconstructs directly — no float conversion
- CreateCommand: String(price.amount) converts the HTTP JSON number to
  string at the application boundary before constructing Price
- Transactions.fromCreated: Number(event.amount) for the float DB column
  (acceptable in the read model; event store remains precise)

All callers updated. Tests updated to pass string literals to Price.
@josecarlospeer-cloud
Copy link
Contributor Author

Good call. Addressed in a second commit: Price.amount is now a string throughout.

What changed:

  • Price.amount: numberstring — no IEEE 754 anywhere in the domain or event
  • TransactionWasCreated.amount: numberstring — event carries exact decimal string
  • CreateCommand: String(price.amount) converts the JSON number from the HTTP payload at the application boundary
  • Transactions.fromCreated: Number(event.amount) for the TypeORM float column (read model precision is a separate concern; the event store record is now exact)
  • All tests updated to pass string literals (new Price("12.5", "EUR"))

For a production system the next step would be accepting amount as string (or integer minor units) from the HTTP API too — but that's a breaking API change and Phase 4 territory.

@jorge07 jorge07 merged commit 06ac47b into jorge07:master Feb 22, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants