Skip to content

Quantity

A quantity in Arda is never just an amount. Every figure that counts or measures something carries a unit of measure alongside it. Quantity is the twin of Money: the same closed-algebra shape, keyed by UnitOfMeasure instead of Currency.

Quantities are frequently fractional. Suppliers sell in 1.6 kg tins, 0.5 L bottles, and 2.5 m lengths. Any layer that assumes a whole number — a validator, an input control, a formatter — is a defect, not a simplification.

Quantity is a value object whose two fields — amount and unit — are inseparable. Storing or transmitting a bare number without its unit is a domain error at every layer.

The canonical Kotlin type is cards.arda.common.lib.domain.general.Quantity, a sealed interface with a single concrete variant Quantity.Value:

@ConsistentCopyVisibility
data class Value private constructor(
override val amount: Double,
override val unit: UnitOfMeasure,
) : Quantity

The constructor is private; values are built through the companion — the fallible invoke for external raw input, which rejects NaN/Infinity on the Result channel, or the internal non-failing ofFinite for the algebra’s own combinations.

UnitOfMeasure is deliberately an opaque stub: units are free-form codes, validated only for non-blankness, and are never converted across. There is no cross-unit conversion and no each-collapse in the algebra.

This is the distinction that trips people up. UnitOfMeasure models a quantity unit — a discrete packaging or handling unit such as pallet, case, tin, or each. It does not model a physical unit on a dimensional scale.

A physical-unit system would let you say that 1 L and 6 dL are the same dimension and reconcile them to 0.4 L. Arda deliberately does not do this. Mixing L and dL here produces a MultiQuantity holding both as separate entries, and comparing across them yields INCOMPARABLE — the codes are compared as opaque strings, so L, l, and litre are three different units.

Physical units are a genuinely different animal, and a well-explored one. When Arda needs them, these are the reference implementations to draw on:

The driver for adopting them will be bulk materials, which the platform does not support well today. Until that need is real, quantity units stay opaque.

Plain Quantity covers the single-unit case. The moment arithmetic spans two different units, the result is no longer expressible as a single Quantity; it is a collection of per-unit amounts. The GeneralizedQuantity sealed interface captures all three outcomes in one closed type — every +, -, and unary - returns a GeneralizedQuantity, never null and never a Result.

PlantUML diagram

Amounts may be negative and may net to zero: demand intent is advisory, so the algebra imposes no non-negativity. The canonical normal form carries no zero entries — a unit that nets to zero is dropped, and a lone zero collapses to ZeroQuantity.

The collapse exists so that a multi-unit holding sheds components it no longer has, rather than accumulating zero-valued noise:

StepValue
Initial2 pallets, 4 cases
Updateminus 2 cases
Result2 pallets, 0 cases → collapse → 2 pallets

Read it as “the holding no longer includes any cases”, not as “the unit was forgotten”.

It is worth being precise about what this does not mean. A depleted holding losing its cases entry does not erase the fact that the item is handled in cases: the item keeps its own units — minimum quantity, order quantity, and so on — independently of any particular holding’s value. What disappears is the inventory entry, not the item’s unit identity.

Arda does not yet model preferred units on the item — a Receiving Unit, Handling Unit, and Shipping Unit — which is the mechanism that will express this properly. Until that exists, an item’s operative unit is inferred from the quantity fields it already carries.

Cross-unit comparison is a genuine partial order, not a boolean that lies. compareWith returns QuantityOrderingLT, EQ, GT, or INCOMPARABLE — and consumers treat INCOMPARABLE conservatively, typically as a guard refusal.

All Quantity.Value instances normalize to 12 decimal places (QUANTITY_PRECISION = 12) using HALF_EVEN rounding at construction. This mirrors MONEY_PRECISION and its rationale: ample headroom while staying well inside the IEEE-754 significant-digit budget, so structural equality and zero-detection are reliable without a tolerance epsilon.

Canonicalisation uses java.math.BigDecimal.valueOf (string-based) rather than BigDecimal(Double), to avoid binary-FP artefacts.

The TypeScript mirror in arda-frontend-app’s src/types/domain.ts exports the same constant and a canonicalizeQuantity helper, sitting beside their money equivalents.

A fractional quantity has to survive every layer between the keystroke and the database. These rules exist because each has been violated in production:

LayerRule
InputAccept any finite decimal. Never a bare <input type="number"> for a quantity — the step constraint defaults to 1, so arrow keys round a fractional value and HTML constraint validation rejects it. Set step="any".
Editing stateHold the typed text as a string while the field is being edited, and convert to a number only on submit. Re-deriving the displayed text from a number on every keystroke erases an in-progress decimal point: Number("1.") is 1 and String(1) is "1", so 1.6 lands as 16. A leading-zero decimal fails harder — 0 || '' is '', blanking the field.
Canonical12 decimal places, as above.
DisplayAdaptive: an integral amount shows no decimals; two or fewer decimal places show two; anything finer shows three. Display truncation never feeds back into the stored value.
StorageDOUBLE PRECISION. Quantity components persist as a paired *_amount / *_unit column set.

Be aware when working across modules that there are currently two quantity types in the tree:

TypeShapeUsed by
cards.arda.common.lib.domain.general.Quantityamount: Double, unit: UnitOfMeasure; full GeneralizedQuantity algebrathe demand module
cards.arda.operations.reference.item.domain.Quantityamount: Double, unit: String; local helpers onlyitems, kanban cards, orders

Both store decimals faithfully, so this is not a correctness problem for fractional units. It is duplication: the local type predates the canon and has its own comparison, formatting, and totalling helpers.

The two serialize differently — the local type as {"amount": 1.6, "unit": "kg"}, the algebra as a unit -> amount map {"kg": 1.6}. Consolidating them is therefore a breaking wire change across every item, order, and kanban endpoint, and is tracked as its own effort rather than folded into feature work.

  • Money — the Currency-keyed twin of this algebra; same closed-algebra shape and precision rationale.
  • Value Objects — how multi-field value objects like Quantity are modeled and persisted.
  • Primitives — scalar types that underpin Quantity.Value.

Copyright: (c) Arda Systems 2025-2026, All rights reserved