Value Objects
Value objects describe attributes without identity — immutable, compared by value, and safe to share.
Introduction
Value objects describe attributes without identity — immutable, compared by value, and safe to share. Amazon fulfillment uses value objects for Money, Address, SKU dimensions, and delivery windows — preventing primitive obsession bugs that lose currency units or mix inches with centimeters.
Real production story
An Amazon warehouse routing service stored weight as raw double pounds and dimensions as three floats — a European vendor submission in kilograms caused sortation robots to misroute oversize packages to standard chutes, jamming three fulfillment centers. Root cause: primitives instead of value objects with unit enforcement. Introducing Weight, Dimensions, and Money value objects with factory validation, immutable fields, and explicit unit conversion eliminated unit-mismatch class incidents. Sortation misroute rate dropped 99%; code review time on shipping logic halved because invariants live in types.
Business problem
Amazon logistics combines global vendors, multi-currency settlements, and dimensional weight pricing. Primitive strings and floats push validation to scattered if-statements — one missed check becomes physical world failure.
- Operational cost: Misrouted packages burn labor and SLAs — not just software exceptions.
- Financial accuracy: Money without currency type caused settlement discrepancies in marketplace payouts.
- Code clarity: Primitive obsession hides domain rules — every service revalidates differently.
Architecture overview
Value object rules: immutable after creation; equality by all attributes; no identity field; factory methods validate; operations return new instances (e.g., Money.add returns new Money).
- Examples: Money, EmailAddress, GeoCoordinate, DateRange, SKU, Percentage.
- Composition: Address contains City, PostalCode VOs — validate at construction.
- Persistence: Often embedded columns in entity row — no separate VO table needed.
- vs Entity: Two Money instances both $10 are interchangeable — entities with same balance are not.
Architecture motivation
Value objects encapsulate validation once and compose into entities and aggregates. Staff architects replace stringly-typed IDs and floats with tiny immutable types — cheap in Java/TypeScript, expensive when skipped.
- Force: Global units, currencies, and address formats vary — centralize rules.
- Constraint: Legacy APIs expose primitives — ACL converts at boundary to VO.
- Outcome: Invalid states unrepresentable — compiler and tests enforce.
Internal architecture
Amazon shipment value object composition inside Shipment aggregate:
- VOs are immutable — withWeight returns new Shipment instance or builder.
- Unit conversion only inside VO — never at call sites.
Shipment (entity / aggregate root)├── ShipmentId├── Weight (VO: amount + Unit.KG | LB)├── Dimensions (VO: length,width,height + Unit)├── Money freightCharge (VO: amount + Currency)├── DeliveryWindow (VO: start,end TimeZone-aware)└── OriginAddress / DestAddress (VO trees)Invalid: shipment.weight = 12.5 // primitiveValid: shipment.withWeight(Weight.ofPounds(12.5))
Data flow
Inbound: API DTO primitives → factory validates → VO constructed → entity accepts only VOs → ORM embeds VO fields as columns. Outbound: VO.toPublishedFormat() for partner APIs.
- Create: ShipmentRequest JSON → ShipmentFactory → Weight/Dimensions VOs → Shipment.create.
- Price: Money.add/multiply returns new Money — never mutate.
- Sortation: Robot API gets normalized metric VOs — conversion hidden inside.
System design diagram
Two diagrams show the Value Objects topology and the primary request/event path used in production at scale.
Production code example
Money and Weight value objects — TypeScript immutable types Amazon teams use:
- BigInt minor units for money — never floating point currency math.
- Private constructor forces factory validation — invalid gram weight unrepresentable.
export class Money {private constructor(readonly amountMicros: bigint,readonly currency: CurrencyCode) {}static ofMajor(amount: string, currency: CurrencyCode): Money {const [whole, frac = "00"] = amount.split(".");if (!/^\d+$/.test(whole) || !/^\d{2}$/.test(frac.padEnd(2, "0").slice(0, 2))) {throw new InvalidMoneyError(amount, currency);}const micros = BigInt(whole) * 1_000_000n + BigInt(frac.padEnd(2, "0").slice(0, 2)) * 10_000n;return new Money(micros, currency);}add(other: Money): Money {if (this.currency !== other.currency) {throw new CurrencyMismatchError(this.currency, other.currency);}return new Money(this.amountMicros + other.amountMicros, this.currency);}}export class Weight {private constructor(readonly grams: number) {}static ofPounds(lbs: number): Weight {if (lbs < 0) throw new InvalidWeightError(lbs);return new Weight(lbs * 453.59237);}static ofKilograms(kg: number): Weight {if (kg < 0) throw new InvalidWeightError(kg);return new Weight(kg * 1000);}}
Enterprise case study
Amazon sortation unit safety program: Primitive floats caused physical misroutes and robot jams.
- Before: Raw doubles; 400 misroutes/week across three FCs during EU vendor onboarding.
- Decision: Weight/Dimensions/Money VOs, factory validation, embed in Shipment aggregate, lint ban raw floats in domain package.
- After: Misroutes 99% down; settlement currency bugs eliminated in marketplace lane.
Trade-offs
- VO overhead vs primitives: More types and mapping — buys correctness at Amazon physical scale.
- Embedded vs separate table: VOs embed in entity row — avoid normalizing VO to own table unless reuse across aggregates heavily.
- Validation strictness: Reject bad input early vs sanitize — money never silently rounds without explicit policy VO.
- Legacy interop: ACL converts primitives at edge — do not leak primitives into domain core.
Security considerations
VOs validate at trust boundaries: EmailAddress VO enforces normalization preventing homograph login bugs; Money prevents negative amount tricks if constructed only via factory.
- Input sanitization: VO factory is single choke point — fuzz test factories.
- PII: Address VO redact() method for logs — structured scrubbing.
- Injection: SKU VO alphanumeric constraint — block path traversal in warehouse APIs.
Scalability analysis
Value objects are cheap at scale — immutable and thread-safe, no identity sync. Serialization size matters for events — use compact encoded forms.
- Event payloads: Money as {amount_micros, currency} not formatted strings.
- Cache keys: VO value equality enables safe cache keys — Address VO normalizes formatting.
- GC pressure: Short-lived VO instances fine — avoid megabyte VOs in hot loops.
Failure scenarios
Primitive obsession failures: unit confusion, currency float rounding, null address fields partially filled.
- Float money: Never use double for Money — use integer minor units in VO.
- Partial address: Address VO rejects missing postal code for countries that require it.
- Timezone window: DeliveryWindow without TZ caused same-day miss — ZonedDateTime inside VO.
Staff engineer insights
- If your domain model has String amount and String currency, you do not have a money concept — you have a future incident.
- Value objects are the cheapest DDD tactic with highest ROI — start here before event sourcing fantasies.
- Make invalid states unrepresentable — constructors that throw beat validators scattered in controllers.
Interview questions
Interview Prep
Practice concise answers, then expand each card for the explanation.
1AdvancedQuestionWhen would you promote a value object to an entity?+
Answer
Follow-up
2AdvancedQuestionHow do value objects persist in relational DB?+
Answer
Follow-up
3AdvancedQuestionDesign value objects for international shipping address.+
Answer
Follow-up
Architecture review questions
- Are money, dimensions, and addresses modeled as VOs — not primitives?
- Are VOs immutable with factory validation?
- Is equality value-based across all attributes?
- Are unit conversions encapsulated inside VO only?
- Do API/ACL layers convert primitives at boundary?
- Are VO serialization formats documented for events?
Summary
Value objects at Amazon encode units, money, and addresses as immutable validated types — stopping sortation misroutes and currency bugs that primitives invite when validation is scattered across services.