From 0f2c043111105a4625cd1d5c600b00d621427c13 Mon Sep 17 00:00:00 2001 From: Jose Carlos Date: Sun, 22 Feb 2026 07:52:42 +0100 Subject: [PATCH 1/2] fix: replace Price object in TransactionWasCreated with primitives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .../Domain/Events/TransactionWasCreated.ts | 14 ++++++++++++-- src/Billing/Transaction/Domain/Transaction.ts | 7 ++++--- .../ReadModel/Mapping/Transactions.ts | 4 ++-- .../Domain/Transaction/Transaction.test.ts | 7 +++++-- 4 files changed, 23 insertions(+), 9 deletions(-) diff --git a/src/Billing/Transaction/Domain/Events/TransactionWasCreated.ts b/src/Billing/Transaction/Domain/Events/TransactionWasCreated.ts index 01aee3d..cea3dbd 100644 --- a/src/Billing/Transaction/Domain/Events/TransactionWasCreated.ts +++ b/src/Billing/Transaction/Domain/Events/TransactionWasCreated.ts @@ -1,13 +1,23 @@ import type { Domain } from "hollywood-js"; -import type Price from "../ValueObject/Price"; +/** + * Raised when a Transaction is created. + * + * Carries only primitive data so that the event is safe to serialize, store, + * and replay without coupling the event schema to the Price value object. + * To reconstruct a Price, call: new Price(event.amount, event.currency) + * + * Source: Young — "Events are the durable record. They must be self-describing + * with primitive data." CQRS Documents (2010). + */ export default class TransactionWasCreated implements Domain.DomainEvent { public readonly occurredAt: Date; constructor( public readonly aggregateId: string, public readonly product: string, - public readonly price: Price, + public readonly amount: number, + public readonly currency: string, occurredAt?: Date, ) { this.occurredAt = occurredAt ?? new Date(); diff --git a/src/Billing/Transaction/Domain/Transaction.ts b/src/Billing/Transaction/Domain/Transaction.ts index b4a21ac..b7132fa 100644 --- a/src/Billing/Transaction/Domain/Transaction.ts +++ b/src/Billing/Transaction/Domain/Transaction.ts @@ -1,6 +1,6 @@ import { Domain } from "hollywood-js"; import TransactionWasCreated from "./Events/TransactionWasCreated"; -import type Price from "./ValueObject/Price"; +import Price from "./ValueObject/Price"; import type TransactionId from "./ValueObject/TransactionId"; export default class Transaction extends Domain.EventSourcedAggregateRoot { @@ -11,7 +11,8 @@ export default class Transaction extends Domain.EventSourcedAggregateRoot { instance.raise(new TransactionWasCreated( uuid.toString(), product, - price, + price.amount, + price.currency, )); return instance; @@ -27,7 +28,7 @@ export default class Transaction extends Domain.EventSourcedAggregateRoot { private onTransactionWasCreated(event: TransactionWasCreated): void { this.product = event.product; - this.price = event.price; + this.price = new Price(event.amount, event.currency); } get pricing(): Price { diff --git a/src/Billing/Transaction/Infrastructure/ReadModel/Mapping/Transactions.ts b/src/Billing/Transaction/Infrastructure/ReadModel/Mapping/Transactions.ts index e2e3007..0173ccd 100644 --- a/src/Billing/Transaction/Infrastructure/ReadModel/Mapping/Transactions.ts +++ b/src/Billing/Transaction/Infrastructure/ReadModel/Mapping/Transactions.ts @@ -10,8 +10,8 @@ export class Transactions { instance.uuid = event.aggregateId; instance.product = event.product; - instance.priceAmount = event.price.amount; - instance.priceCurrency = event.price.currency; + instance.priceAmount = event.amount; + instance.priceCurrency = event.currency; instance.createdAt = new Date(); return instance; diff --git a/tests/Billing/Transaction/Domain/Transaction/Transaction.test.ts b/tests/Billing/Transaction/Domain/Transaction/Transaction.test.ts index 6f34f1b..aa083c0 100644 --- a/tests/Billing/Transaction/Domain/Transaction/Transaction.test.ts +++ b/tests/Billing/Transaction/Domain/Transaction/Transaction.test.ts @@ -11,10 +11,13 @@ describe("Transaction", () => { const instance = Transaction.create(transactionID, "product", price); const stream = instance.getUncommittedEvents(); + const event = stream.events[0].event as TransactionWasCreated; + expect(instance.getAggregateRootId().toString()).toBe("ae081e7a-ec8c-4ff1-9de5-f70383fe03a7"); expect(stream.events[0]).toBeInstanceOf(Domain.DomainMessage); - expect((stream.events[0].event as TransactionWasCreated).product).toBe("product"); - expect((stream.events[0].event as TransactionWasCreated).price).toBe(price); + expect(event.product).toBe("product"); + expect(event.amount).toBe(price.amount); + expect(event.currency).toBe(price.currency); expect(instance.version()).toBe(0); }); }); From d03ab81b69c84c320f17f248910be8a9f87b0f70 Mon Sep 17 00:00:00 2001 From: Jose Carlos Date: Sun, 22 Feb 2026 09:23:19 +0100 Subject: [PATCH 2/2] fix: store Price.amount as string to avoid IEEE 754 precision loss MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../Transaction/Application/Create/Command.ts | 2 +- .../Domain/Events/TransactionWasCreated.ts | 2 +- .../Transaction/Domain/ValueObject/Price.ts | 17 ++++++++++++----- .../ReadModel/Mapping/Transactions.ts | 2 +- tests/Apps/HTTP/Transaction/GetOne.test.ts | 2 +- .../UseCase/Transaction/GetOne.test.ts | 2 +- .../Domain/Transaction/Transaction.test.ts | 2 +- .../Transaction/ValueObject/Price.test.ts | 6 +++--- 8 files changed, 21 insertions(+), 14 deletions(-) diff --git a/src/Billing/Transaction/Application/Create/Command.ts b/src/Billing/Transaction/Application/Create/Command.ts index 7790701..a4e3c6f 100644 --- a/src/Billing/Transaction/Application/Create/Command.ts +++ b/src/Billing/Transaction/Application/Create/Command.ts @@ -12,6 +12,6 @@ export default class CreateCommand implements ICommand { price: { amount: number, currency: string }, ) { this.uuid = new TransactionId(uuid); - this.price = new Price(price.amount, price.currency); + this.price = new Price(String(price.amount), price.currency); } } diff --git a/src/Billing/Transaction/Domain/Events/TransactionWasCreated.ts b/src/Billing/Transaction/Domain/Events/TransactionWasCreated.ts index cea3dbd..fc62239 100644 --- a/src/Billing/Transaction/Domain/Events/TransactionWasCreated.ts +++ b/src/Billing/Transaction/Domain/Events/TransactionWasCreated.ts @@ -16,7 +16,7 @@ export default class TransactionWasCreated implements Domain.DomainEvent { constructor( public readonly aggregateId: string, public readonly product: string, - public readonly amount: number, + public readonly amount: string, public readonly currency: string, occurredAt?: Date, ) { diff --git a/src/Billing/Transaction/Domain/ValueObject/Price.ts b/src/Billing/Transaction/Domain/ValueObject/Price.ts index 3fb41a8..ab3e84d 100644 --- a/src/Billing/Transaction/Domain/ValueObject/Price.ts +++ b/src/Billing/Transaction/Domain/ValueObject/Price.ts @@ -1,6 +1,11 @@ import InvalidArgumentException from "../../../Shared/Domain/Exceptions/InvalidArgumentException"; import { currencies } from "./CurrencyList"; +/** + * Money value object. Stores the amount as a decimal string to avoid + * IEEE 754 floating-point precision loss. Use string arithmetic or a + * dedicated decimal library (e.g., decimal.js) for money calculations. + */ export default class Price { private static validateCurrency(currency: string): void { @@ -9,21 +14,23 @@ export default class Price { } } - private static validateAmount(amount: number): void { - if (isNaN(amount)) { + private static validateAmount(amount: string): void { + const parsed = Number(amount); + + if (isNaN(parsed)) { throw new InvalidArgumentException("Amount is not a number"); } - if (Math.sign(amount) < 0) { + if (parsed < 0) { throw new InvalidArgumentException("Negative prices not allowed"); } } - public readonly amount: number; + public readonly amount: string; public readonly currency: string; constructor( - amount: number, + amount: string, currency: string, ) { Price.validateAmount(amount); diff --git a/src/Billing/Transaction/Infrastructure/ReadModel/Mapping/Transactions.ts b/src/Billing/Transaction/Infrastructure/ReadModel/Mapping/Transactions.ts index 0173ccd..d9612e4 100644 --- a/src/Billing/Transaction/Infrastructure/ReadModel/Mapping/Transactions.ts +++ b/src/Billing/Transaction/Infrastructure/ReadModel/Mapping/Transactions.ts @@ -10,7 +10,7 @@ export class Transactions { instance.uuid = event.aggregateId; instance.product = event.product; - instance.priceAmount = event.amount; + instance.priceAmount = Number(event.amount); instance.priceCurrency = event.currency; instance.createdAt = new Date(); diff --git a/tests/Apps/HTTP/Transaction/GetOne.test.ts b/tests/Apps/HTTP/Transaction/GetOne.test.ts index 3ea82c3..039e9c7 100644 --- a/tests/Apps/HTTP/Transaction/GetOne.test.ts +++ b/tests/Apps/HTTP/Transaction/GetOne.test.ts @@ -27,7 +27,7 @@ describe("GET /transaction/:uuid", () => { it("GetOne /ae081e7a-ec8c-4ff1-9de5-f70383fe03a7", async () => { const txnuuid = "ae081e7a-ec8c-4ff1-9de5-f70383fe03a7"; - await transactionRepository.save(Transaction.create(new TransactionId(txnuuid), "uuu", new Price(1, "EUR"))); + await transactionRepository.save(Transaction.create(new TransactionId(txnuuid), "uuu", new Price("1", "EUR"))); const result: any = await api.http.inject({ method: "GET", diff --git a/tests/Billing/Transaction/Application/UseCase/Transaction/GetOne.test.ts b/tests/Billing/Transaction/Application/UseCase/Transaction/GetOne.test.ts index fbeff15..65133f9 100644 --- a/tests/Billing/Transaction/Application/UseCase/Transaction/GetOne.test.ts +++ b/tests/Billing/Transaction/Application/UseCase/Transaction/GetOne.test.ts @@ -16,7 +16,7 @@ describe("GetOne Transaction", () => { await repository.save(Transaction.create( new TransactionId("ae081e7a-ec8c-4ff1-9de5-f70383fe03a7"), "", - new Price(1, "EUR"), + new Price("1", "EUR"), )); }); diff --git a/tests/Billing/Transaction/Domain/Transaction/Transaction.test.ts b/tests/Billing/Transaction/Domain/Transaction/Transaction.test.ts index aa083c0..3ee667a 100644 --- a/tests/Billing/Transaction/Domain/Transaction/Transaction.test.ts +++ b/tests/Billing/Transaction/Domain/Transaction/Transaction.test.ts @@ -6,7 +6,7 @@ import TransactionWasCreated from "@Transaction/Domain/Events/TransactionWasCrea describe("Transaction", () => { test("Create transaction", () => { - const price = new Price(2, "EUR"); + const price = new Price("2", "EUR"); const transactionID = new TransactionId("ae081e7a-ec8c-4ff1-9de5-f70383fe03a7"); const instance = Transaction.create(transactionID, "product", price); const stream = instance.getUncommittedEvents(); diff --git a/tests/Billing/Transaction/Domain/Transaction/ValueObject/Price.test.ts b/tests/Billing/Transaction/Domain/Transaction/ValueObject/Price.test.ts index 64d8c4b..eb4f329 100644 --- a/tests/Billing/Transaction/Domain/Transaction/ValueObject/Price.test.ts +++ b/tests/Billing/Transaction/Domain/Transaction/ValueObject/Price.test.ts @@ -3,14 +3,14 @@ import InvalidArgumentException from "@Shared/Domain/Exceptions/InvalidArgumentE describe("Price", () => { test("Create Price VO from Scalars", () => { - const instance = new Price(12.5, "EUR"); + const instance = new Price("12.5", "EUR"); expect(instance.currency).toBe("EUR"); - expect(instance.amount).toBe(12.5); + expect(instance.amount).toBe("12.5"); }); test("Invalid Currency", () => { - const todo = () => new Price(12.5, "GBP"); + const todo = () => new Price("12.5", "GBP"); expect(todo).toThrow(InvalidArgumentException); }); });