0007. Decimal as canonical string in DTOs

Status: Accepted Date: 2026-05-01

#Context

The domain layer represents money (prices, quantities, cash, fees, PnL) as Core.Decimal.t — a fixed-point fixed-scale (10⁸) integer type whose semantics are formally specified in decimal.mlw and discharged via Why3. This is load-bearing for the project's reference-application thesis: the verified Decimal arithmetic underpins every reservation, fee calculation, and position update.

DTOs (CQRS commands, integration events, read-model view models) must carry these monetary values across:

  1. The internal message bus (in-memory today, Kafka tomorrow) — serialised JSON via [@@deriving yojson].
  2. The HTTP API consumed by the Angular UI and the CLI.
  3. External broker adapters (Finam REST, BCS REST, future ones).

DTOs be serialisable and not contain ValueObjects — so the question is which primitive form carries a Decimal across these boundaries.

Until this ADR the answer was inconsistent:

The result was a precision leak that bypassed the formal verification: a wire string "100.10" parsed at the HTTP edge into an exact Decimal, was projected back out through to_float (IEEE 754 round-off), bus-serialised as a JSON Float, and re-entered the workflow via of_float (more round-off). The verified Why3 invariants held only inside a window the wire format arguably never let real values reach intact.

The Finam REST schema (their order endpoint, accounts/portfolio endpoint, etc.) independently uses Google's google.type.Decimal convention — a wrapper object { "value": "<decimal-string>" } — for the same reasons: gRPC well-known types, AIP-128, BigDecimal/decimal.Decimal on the client side. So the industry pattern was already in front of us.

#Decision

DTOs (commands, integration events, view models) carry Decimal values as canonical decimal strings matching the OCaml Core.Decimal.to_string output. Parsing in / out goes through Decimal.of_string / Decimal.to_string — both lossless, bit-exact round-trip — and never through of_float / to_float.

The internal wire shape is a bare JSON string ("100.10"). External broker adapters that follow google.type.Decimal (Finam) add their own { "value": "..." } wrapper on the way out via Acl_common.Decimal_wire.yojson_of_t_wrapped — the wrapper lives only at the ACL boundary, not in our internal message format.

Concretely:

DTO field shape Inside the system At Finam REST boundary
Reading string (bare) { "value": string }
Writing string (bare) { "value": string }
OCaml type string Decimal_wire.t (= string)
Parser Decimal.of_string Acl_common.decimal_of_json
Emitter Decimal.to_string Decimal_wire.yojson_of_t_wrapped

The bare-string internal form is intentional: it minimises wire noise on every event/command in the bus (one "100.10" vs. {"value":"100.10"}); it keeps [@@deriving yojson] derivation trivial; and the wrapping that Finam's API requires belongs in the ACL anyway. If a future internal bus consumer (audit, downstream service) wants the AIP-128 self-describing wrapper, the projection lives in their adapter, not in our shared shape.

UI follows the same discipline. A ui/src/app/decimal.ts module mirrors Core.Decimal (10⁸ scale, BigInt-backed, same parse rules, same canonical string output). Wire-format DTOs in the Angular HTTP layer use string for monetary fields; parsing and emission go through this Decimal class. The single permitted lossy step is Decimal.toNumber(), called explicitly at the chart-library boundary where lightweight-charts requires a JS number — every such call is grep-able from the codebase, making the precision-loss sites auditable.

Float remains permitted for fields that are not Decimal-derived in the domain: ratios (Backtest.total_return, max_drawdown), strategy confidence (Signal.strength), workflow configuration parameters (slippage_buffer, fee_rate — see "To watch for" below), and timestamps. These are domain floats, not money.

#Alternatives considered

#float (the prior state)

The wire format every broker REST originally seems to suggest, and what the codebase started with. Rejected because of the round-trip precision leak described in Context: every DTO crossing erased the formally-verified Decimal semantics that the rest of the system depends on. Wlaschin (DMMF) and Vernon (IDDD) both call this out as an anti-pattern for monetary values; we were paying for formal verification we then discarded at the wire boundary.

#Wrapped form { "value": "<string>" } everywhere (AIP-128 / google.type.Decimal)

The Finam REST shape, also used by gRPC well-known types. Considered because it would make our internal DTOs structurally identical to the external format, eliminating one translation step in the ACL.

Rejected for internal use: the wrapper is a protobuf artefact (every scalar in protobuf becomes a message), and we are not on gRPC. Inside an in-memory or Kafka bus the wrapper costs ~10 extra bytes per field with no semantic benefit; the { "value": ... } shape adds nesting that downstream consumers (SSE projector, audit) must unpeel for every field. The bare string carries the same information without the ceremony, and the ACL adapter for Finam adds the wrapper on its way out — a single file (broker/lib/infrastructure/acl/common/decimal_wire.ml) encapsulates the translation. If a future broker requires raw strings (not wrapped), the same ACL pattern serves it without our internal shape changing.

#Int64 with a per-field declared scale

What Core.Decimal itself uses internally (10⁸ scaled int64). Considered because it's even more compact on the wire and round-trip-trivial. Rejected because:

#Internal type Decimal.t carried in DTOs (no projection)

Would skip the string round-trip entirely. Rejected because DTOs must be JSON-serialisable per [@@deriving yojson] needs a primitive — adding custom yojson converters for Decimal.t would have re-introduced the same parse/emit functions we use today, just hidden inside derived code rather than visible at DTO field declarations. Visibility is the point: each DTO field should make its precision contract obvious to a reader.

#Custom decimal library on the UI side (decimal.js / dnum / bignumber.js)

Considered for the Angular layer instead of writing ui/src/app/decimal.ts. Rejected because:

If UI-side arithmetic grows beyond what the bespoke module naturally supports (display formatting + occasional Δ / sums), the decision can be revisited; switching to decimal.js would be a local change to ui/src/app/decimal.ts's implementation, since the public surface mirrors Core.Decimal not the library.

#Consequences

Easier:

Harder:

To watch for:

#References