Пример: Money — составной Value Object
Каноничный составной VO. Хранение в minor units для точных вычислений. Currency как отдельный VO. Арифметика: add, subtract, multiply, divide с типизированными ошибками. Аллокация с распределением остатка. Schema-трансформация major↔minor units на границах слоёв. Паттерны Percentage, Price vs Cost vs Revenue.
Введение
Money (деньги) — это каноничный пример составного Value Object из книги Эрика Эванса и, пожалуй, самый часто приводимый пример в литературе по DDD. Это не случайно: деньги наглядно демонстрируют, почему представление бизнес-концепции примитивным типом (number) — путь к катастрофе.
Составной Value Object отличается от простого (branded) тем, что он состоит из нескольких полей, которые имеют смысл только вместе. Сумма 100 без указания валюты бессмысленна. Валюта USD без суммы — тоже. Только Money(100, "USD") — полноценная бизнес-концепция.
Почему number — плохой тип для денег
Проблема 1: Потеря контекста
// ❌ Что это за числа? В какой валюте? Центы или доллары?
function calculateTotal(price: number, tax: number, discount: number): number {
return price + tax - discount
}
calculateTotal(100, 8.5, 10)
// 98.5 — это в долларах? Евро? Рублях?
// А может 100 — это в центах, а 8.5 — в долларах?
Проблема 2: Несовместимые валюты
// ❌ Складываем доллары с рублями — компилятор не видит проблемы
const priceUSD = 100
const shippingRUB = 500
const total = priceUSD + shippingRUB // 600 — бессмыслица!
Проблема 3: Точность вычислений
// ❌ IEEE 754 floating point — потеря точности
0.1 + 0.2 // 0.30000000000000004
// В банковской системе это недопустимо!
// Проблема накопления:
let balance = 0
for (let i = 0; i < 1000; i++) {
balance += 0.01
}
console.log(balance) // 9.999999999999831 вместо 10.00
Проблема 4: Отрицательные суммы
// ❌ Цена не может быть отрицательной, но number это допускает
const price = -100 // TypeScript: "Всё нормально!"
Проектирование Money Value Object
Ключевые решения
Прежде чем писать код, нужно принять несколько архитектурных решений:
| Вопрос | Решение | Обоснование |
|---|---|---|
| Хранение суммы | Minor units (центы, копейки) | Избегаем проблем с floating point |
| Тип суммы | number (Int) | Целые числа не теряют точность до 2^53 |
| Допустимые валюты | Литеральный тип | Enum или Schema.Literal для type safety |
| Отрицательные суммы | Отдельный тип Expense или запрет | Зависит от домена |
| Операции | Чистые функции | Функциональный стиль |
Minor Units
Хранение сумм в минорных единицах (наименьших единицах валюты) — стандартная практика в финансовых системах. Это позволяет работать с целыми числами, избегая проблем floating point:
$10.50 → 1050 cents (minor units)
€25.99 → 2599 cents
¥1000 → 1000 (у иены нет minor units, exponent = 0)
Каждая валюта имеет свой exponent — количество десятичных знаков:
| Валюта | Код | Exponent | 1 major unit = |
|---|---|---|---|
| US Dollar | USD | 2 | 100 cents |
| Euro | EUR | 2 | 100 cents |
| Russian Ruble | RUB | 2 | 100 kopecks |
| Japanese Yen | JPY | 0 | 1 yen |
| Kuwaiti Dinar | KWD | 3 | 1000 fils |
Реализация: Currency Value Object
Начнём с валюты — это отдельный Value Object:
import { Schema, Data } from "effect"
// ============================================================
// Currency — Value Object для валюты
// ============================================================
/** Поддерживаемые валюты и их экспоненты */
const CURRENCY_CONFIG = {
USD: { code: "USD", symbol: "$", exponent: 2, name: "US Dollar" },
EUR: { code: "EUR", symbol: "€", exponent: 2, name: "Euro" },
RUB: { code: "RUB", symbol: "₽", exponent: 2, name: "Russian Ruble" },
GBP: { code: "GBP", symbol: "£", exponent: 2, name: "British Pound" },
JPY: { code: "JPY", symbol: "¥", exponent: 0, name: "Japanese Yen" },
KWD: { code: "KWD", symbol: "د.ك", exponent: 3, name: "Kuwaiti Dinar" }
} as const
/** Тип кода валюты */
export const CurrencyCode = Schema.Literal(
"USD", "EUR", "RUB", "GBP", "JPY", "KWD"
)
export type CurrencyCode = typeof CurrencyCode.Type
/** Получить конфигурацию валюты */
export const getCurrencyConfig = (code: CurrencyCode) =>
CURRENCY_CONFIG[code]
/** Получить экспоненту валюты */
export const getCurrencyExponent = (code: CurrencyCode): number =>
CURRENCY_CONFIG[code].exponent
/** Получить символ валюты */
export const getCurrencySymbol = (code: CurrencyCode): string =>
CURRENCY_CONFIG[code].symbol
/** Коэффициент перевода: major → minor */
export const getCurrencyFactor = (code: CurrencyCode): number =>
Math.pow(10, getCurrencyExponent(code))
Реализация: Money Value Object
import { Schema, Equal, Data, Either, pipe, Brand } from "effect"
// ============================================================
// Money — Составной Value Object
// ============================================================
/**
* Money Value Object
*
* Хранит сумму в minor units (центы, копейки) для точных вычислений.
* Гарантирует:
* - Целочисленное хранение (нет floating point ошибок)
* - Неотрицательная сумма
* - Привязка к конкретной валюте
* - Structural equality (Equal trait)
*
* @example
* const price = Money.cents(1050, "USD") // $10.50
* const tax = Money.cents(84, "USD") // $0.84
* const total = addMoney(price, tax) // $11.34
*/
export class Money extends Schema.Class<Money>("Money")({
/** Сумма в minor units (центы, копейки). Всегда целое число >= 0 */
amountInMinorUnits: Schema.Number.pipe(
Schema.int({ message: () => "Amount must be an integer (minor units)" }),
Schema.nonNegative({ message: () => "Amount must be non-negative" })
),
/** Код валюты ISO 4217 */
currency: CurrencyCode
}) {
// === Фабричные методы ===
/**
* Создать Money из minor units (центов)
* @example Money.cents(1050, "USD") // $10.50
*/
static cents(amount: number, currency: CurrencyCode): Money {
return new Money({ amountInMinorUnits: Math.round(amount), currency })
}
/**
* Создать Money из major units (долларов, рублей)
* @example Money.of(10.50, "USD") // $10.50 = 1050 cents
*/
static of(amount: number, currency: CurrencyCode): Money {
const factor = getCurrencyFactor(currency)
return new Money({
amountInMinorUnits: Math.round(amount * factor),
currency
})
}
/** Нулевая сумма в указанной валюте */
static zero(currency: CurrencyCode): Money {
return new Money({ amountInMinorUnits: 0, currency })
}
}
// ============================================================
// Ошибки операций над Money
// ============================================================
export class CurrencyMismatchError extends Data.TaggedError(
"CurrencyMismatchError"
)<{
readonly left: CurrencyCode
readonly right: CurrencyCode
readonly operation: string
}> {
get message(): string {
return `Cannot ${this.operation} ${this.left} and ${this.right}: currency mismatch`
}
}
export class NegativeResultError extends Data.TaggedError(
"NegativeResultError"
)<{
readonly amount: number
readonly currency: CurrencyCode
readonly operation: string
}> {
get message(): string {
return `${this.operation} would result in negative amount: ${this.amount} ${this.currency}`
}
}
export class InvalidDivisorError extends Data.TaggedError(
"InvalidDivisorError"
)<{
readonly divisor: number
}> {
get message(): string {
return `Cannot divide by ${this.divisor}`
}
}
// ============================================================
// Операции (чистые функции)
// ============================================================
/** Проверка совместимости валют */
const assertSameCurrency = (
a: Money,
b: Money,
operation: string
): Either.Either<void, CurrencyMismatchError> =>
a.currency === b.currency
? Either.right(undefined)
: Either.left(new CurrencyMismatchError({
left: a.currency,
right: b.currency,
operation
}))
/**
* Сложение двух Money одной валюты
* @returns Either<Money, CurrencyMismatchError>
*/
export const addMoney = (
a: Money,
b: Money
): Either.Either<Money, CurrencyMismatchError> =>
pipe(
assertSameCurrency(a, b, "add"),
Either.map(() =>
new Money({
amountInMinorUnits: a.amountInMinorUnits + b.amountInMinorUnits,
currency: a.currency
})
)
)
/**
* Вычитание Money одной валюты (a - b)
* @returns Either<Money, CurrencyMismatchError | NegativeResultError>
*/
export const subtractMoney = (
a: Money,
b: Money
): Either.Either<Money, CurrencyMismatchError | NegativeResultError> =>
pipe(
assertSameCurrency(a, b, "subtract"),
Either.flatMap(() => {
const result = a.amountInMinorUnits - b.amountInMinorUnits
return result >= 0
? Either.right(new Money({
amountInMinorUnits: result,
currency: a.currency
}))
: Either.left(new NegativeResultError({
amount: result,
currency: a.currency,
operation: "subtract"
}))
})
)
/**
* Умножение Money на коэффициент
* @example multiplyMoney(usd(10), 1.5) // usd(15)
*/
export const multiplyMoney = (money: Money, factor: number): Money =>
new Money({
amountInMinorUnits: Math.round(money.amountInMinorUnits * Math.abs(factor)),
currency: money.currency
})
/**
* Деление Money на число
* @returns Either<Money, InvalidDivisorError>
*/
export const divideMoney = (
money: Money,
divisor: number
): Either.Either<Money, InvalidDivisorError> =>
divisor === 0
? Either.left(new InvalidDivisorError({ divisor }))
: Either.right(new Money({
amountInMinorUnits: Math.round(money.amountInMinorUnits / divisor),
currency: money.currency
}))
/**
* Сумма массива Money
* @returns Either<Money, CurrencyMismatchError>
*/
export const sumMoney = (
items: ReadonlyArray<Money>
): Either.Either<Money, CurrencyMismatchError> => {
if (items.length === 0) {
return Either.right(Money.zero("USD"))
}
const currency = items[0]!.currency
return items.reduce<Either.Either<Money, CurrencyMismatchError>>(
(acc, item) => pipe(acc, Either.flatMap((total) => addMoney(total, item))),
Either.right(Money.zero(currency))
)
}
/**
* Распределение Money на N равных частей (с учётом остатка)
* Banker's allocation: остаток распределяется по одному центу
*
* @example allocateMoney(Money.cents(100, "USD"), 3)
* // [34 cents, 33 cents, 33 cents] (сумма = 100)
*/
export const allocateMoney = (
money: Money,
parts: number
): ReadonlyArray<Money> => {
if (parts <= 0) return []
const base = Math.floor(money.amountInMinorUnits / parts)
const remainder = money.amountInMinorUnits % parts
return Array.from({ length: parts }, (_, i) =>
new Money({
amountInMinorUnits: base + (i < remainder ? 1 : 0),
currency: money.currency
})
)
}
/**
* Распределение Money пропорционально весам
* @example allocateByRatios(Money.cents(100, "USD"), [70, 30])
* // [70 cents, 30 cents]
*/
export const allocateByRatios = (
money: Money,
ratios: ReadonlyArray<number>
): ReadonlyArray<Money> => {
const totalRatio = ratios.reduce((sum, r) => sum + r, 0)
if (totalRatio === 0) return ratios.map(() => Money.zero(money.currency))
const amounts = ratios.map((ratio) =>
Math.floor((money.amountInMinorUnits * ratio) / totalRatio)
)
// Распределяем остаток
const distributed = amounts.reduce((sum, a) => sum + a, 0)
let remainder = money.amountInMinorUnits - distributed
// Отдаём остаток элементам с наибольшими дробными частями
const fractional = ratios.map((ratio, i) => ({
index: i,
frac: ((money.amountInMinorUnits * ratio) / totalRatio) - amounts[i]!
}))
fractional.sort((a, b) => b.frac - a.frac)
for (const { index } of fractional) {
if (remainder <= 0) break
amounts[index]!++
remainder--
}
return amounts.map((amount) =>
new Money({ amountInMinorUnits: amount, currency: money.currency })
)
}
// ============================================================
// Сравнение
// ============================================================
/** Сравнение двух Money (только одной валюты) */
export const compareMoney = (
a: Money,
b: Money
): Either.Either<number, CurrencyMismatchError> =>
pipe(
assertSameCurrency(a, b, "compare"),
Either.map(() => a.amountInMinorUnits - b.amountInMinorUnits)
)
export const isGreaterThan = (a: Money, b: Money): boolean =>
a.currency === b.currency && a.amountInMinorUnits > b.amountInMinorUnits
export const isLessThan = (a: Money, b: Money): boolean =>
a.currency === b.currency && a.amountInMinorUnits < b.amountInMinorUnits
export const isZero = (money: Money): boolean =>
money.amountInMinorUnits === 0
export const isPositive = (money: Money): boolean =>
money.amountInMinorUnits > 0
// ============================================================
// Конвертация и форматирование
// ============================================================
/** Получить сумму в major units (доллары, рубли) */
export const toMajorUnits = (money: Money): number =>
money.amountInMinorUnits / getCurrencyFactor(money.currency)
/** Форматирование для отображения */
export const formatMoney = (money: Money): string => {
const major = toMajorUnits(money)
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: money.currency,
minimumFractionDigits: getCurrencyExponent(money.currency),
maximumFractionDigits: getCurrencyExponent(money.currency)
}).format(major)
}
/** Форматирование с кастомной локалью */
export const formatMoneyLocale = (
money: Money,
locale: string
): string => {
const major = toMajorUnits(money)
return new Intl.NumberFormat(locale, {
style: "currency",
currency: money.currency
}).format(major)
}
// ============================================================
// Schema для сериализации/десериализации
// ============================================================
/**
* Schema для трансформации Money ↔ JSON
*
* В JSON: { amount: 10.50, currency: "USD" }
* В домене: Money { amountInMinorUnits: 1050, currency: "USD" }
*
* Автоматическая конвертация major ↔ minor units при encode/decode
*/
export const MoneyFromJson = Schema.transform(
// JSON формат (то, что приходит снаружи)
Schema.Struct({
amount: Schema.Number.pipe(Schema.nonNegative()),
currency: CurrencyCode
}),
// Доменный формат
Money,
{
strict: true,
decode: ({ amount, currency }) => Money.of(amount, currency),
encode: (money) => ({
amount: toMajorUnits(money),
currency: money.currency
})
}
)
Использование Money в реальном коде
В доменной модели
import { Schema } from "effect"
import { Money, MoneyFromJson, Priority } from "../value-objects"
class OrderLine extends Schema.Class<OrderLine>("OrderLine")({
productId: Schema.String.pipe(Schema.brand("ProductId")),
quantity: Schema.Number.pipe(Schema.int(), Schema.positive()),
unitPrice: Money,
discount: Money
}) {}
// Чистая функция для вычисления стоимости строки заказа
const orderLineTotal = (line: OrderLine): Either.Either<Money, CurrencyMismatchError> =>
pipe(
subtractMoney(
multiplyMoney(line.unitPrice, line.quantity),
line.discount
)
)
В порте
import { Context, Effect } from "effect"
import { Money, CurrencyCode } from "../value-objects"
class PaymentPort extends Context.Tag("PaymentPort")<
PaymentPort,
{
readonly charge: (amount: Money) => Effect.Effect<PaymentResult, PaymentError>
readonly refund: (amount: Money) => Effect.Effect<RefundResult, RefundError>
}
>() {}
class CurrencyExchangePort extends Context.Tag("CurrencyExchangePort")<
CurrencyExchangePort,
{
readonly getRate: (
from: CurrencyCode,
to: CurrencyCode
) => Effect.Effect<number, ExchangeRateError>
readonly convert: (
money: Money,
targetCurrency: CurrencyCode
) => Effect.Effect<Money, ExchangeRateError>
}
>() {}
В HTTP-адаптере
import { Schema } from "effect"
import { MoneyFromJson } from "../value-objects"
// На вход приходит JSON с amount в major units
const CreateOrderRequest = Schema.Struct({
items: Schema.Array(Schema.Struct({
productId: Schema.String,
quantity: Schema.Number.pipe(Schema.int(), Schema.positive()),
unitPrice: MoneyFromJson // { amount: 10.50, currency: "USD" } → Money(1050, "USD")
}))
})
// На выход отдаём JSON с amount в major units
const OrderResponse = Schema.Struct({
id: Schema.String,
total: MoneyFromJson, // Money(1050, "USD") → { amount: 10.50, currency: "USD" }
items: Schema.Array(Schema.Struct({
productId: Schema.String,
subtotal: MoneyFromJson
}))
})
Тестирование Money VO
import { describe, it, expect } from "bun:test"
import { Either, pipe } from "effect"
import {
Money, addMoney, subtractMoney, multiplyMoney,
divideMoney, allocateMoney, allocateByRatios,
formatMoney, toMajorUnits, isZero, isPositive,
compareMoney
} from "./Money"
describe("Money Value Object", () => {
// === Создание ===
describe("creation", () => {
it("creates from cents", () => {
const money = Money.cents(1050, "USD")
expect(money.amountInMinorUnits).toBe(1050)
expect(money.currency).toBe("USD")
})
it("creates from major units", () => {
const money = Money.of(10.50, "USD")
expect(money.amountInMinorUnits).toBe(1050)
})
it("creates zero", () => {
const money = Money.zero("EUR")
expect(money.amountInMinorUnits).toBe(0)
expect(money.currency).toBe("EUR")
})
it("handles JPY (exponent 0)", () => {
const money = Money.of(1000, "JPY")
expect(money.amountInMinorUnits).toBe(1000)
})
it("rounds to nearest cent", () => {
const money = Money.of(10.555, "USD")
expect(money.amountInMinorUnits).toBe(1056) // rounded
})
})
// === Арифметика ===
describe("arithmetic", () => {
const usd10 = Money.of(10, "USD")
const usd5 = Money.of(5, "USD")
const eur10 = Money.of(10, "EUR")
it("adds same currency", () => {
const result = addMoney(usd10, usd5)
expect(Either.isRight(result)).toBe(true)
expect(pipe(result, Either.map(toMajorUnits), Either.getOrThrow)).toBe(15)
})
it("rejects adding different currencies", () => {
const result = addMoney(usd10, eur10)
expect(Either.isLeft(result)).toBe(true)
})
it("subtracts same currency", () => {
const result = subtractMoney(usd10, usd5)
expect(Either.isRight(result)).toBe(true)
expect(pipe(result, Either.map(toMajorUnits), Either.getOrThrow)).toBe(5)
})
it("rejects negative subtraction", () => {
const result = subtractMoney(usd5, usd10)
expect(Either.isLeft(result)).toBe(true)
})
it("multiplies by factor", () => {
const result = multiplyMoney(usd10, 2.5)
expect(toMajorUnits(result)).toBe(25)
})
it("divides by number", () => {
const result = divideMoney(usd10, 3)
expect(Either.isRight(result)).toBe(true)
// $10.00 / 3 = 333 cents (~$3.33)
expect(
pipe(result, Either.map((m) => m.amountInMinorUnits), Either.getOrThrow)
).toBe(333)
})
it("rejects division by zero", () => {
const result = divideMoney(usd10, 0)
expect(Either.isLeft(result)).toBe(true)
})
})
// === Аллокация ===
describe("allocation", () => {
it("allocates equally with no remainder", () => {
const money = Money.cents(100, "USD")
const parts = allocateMoney(money, 4)
expect(parts).toHaveLength(4)
expect(parts.every((p) => p.amountInMinorUnits === 25)).toBe(true)
})
it("distributes remainder correctly", () => {
const money = Money.cents(100, "USD")
const parts = allocateMoney(money, 3)
expect(parts).toHaveLength(3)
// 100 / 3 = 33 remainder 1
// First part gets extra cent: [34, 33, 33]
expect(parts[0]!.amountInMinorUnits).toBe(34)
expect(parts[1]!.amountInMinorUnits).toBe(33)
expect(parts[2]!.amountInMinorUnits).toBe(33)
// Sum is preserved
const total = parts.reduce((s, p) => s + p.amountInMinorUnits, 0)
expect(total).toBe(100)
})
it("allocates by ratios", () => {
const money = Money.cents(100, "USD")
const parts = allocateByRatios(money, [70, 30])
expect(parts[0]!.amountInMinorUnits).toBe(70)
expect(parts[1]!.amountInMinorUnits).toBe(30)
})
it("preserves total in ratio allocation", () => {
const money = Money.cents(100, "USD")
const parts = allocateByRatios(money, [33, 33, 34])
const total = parts.reduce((s, p) => s + p.amountInMinorUnits, 0)
expect(total).toBe(100)
})
})
// === Форматирование ===
describe("formatting", () => {
it("formats USD", () => {
const money = Money.of(1234.56, "USD")
expect(formatMoney(money)).toBe("$1,234.56")
})
it("formats zero", () => {
const money = Money.zero("EUR")
expect(formatMoney(money)).toBe("€0.00")
})
it("formats JPY (no decimals)", () => {
const money = Money.of(1000, "JPY")
expect(formatMoney(money)).toBe("¥1,000")
})
})
// === Сравнение и проверки ===
describe("comparison", () => {
it("compares same currency", () => {
const a = Money.of(10, "USD")
const b = Money.of(5, "USD")
const result = compareMoney(a, b)
expect(pipe(result, Either.map((n) => n > 0), Either.getOrThrow)).toBe(true)
})
it("detects zero", () => {
expect(isZero(Money.zero("USD"))).toBe(true)
expect(isZero(Money.of(1, "USD"))).toBe(false)
})
it("detects positive", () => {
expect(isPositive(Money.of(1, "USD"))).toBe(true)
expect(isPositive(Money.zero("USD"))).toBe(false)
})
})
})
Продвинутые паттерны
Паттерн: Percentage и Money
import { Schema } from "effect"
const Percentage = Schema.Number.pipe(
Schema.greaterThanOrEqualTo(0),
Schema.lessThanOrEqualTo(100),
Schema.brand("Percentage")
)
type Percentage = typeof Percentage.Type
/** Применить процент к сумме */
export const applyPercentage = (money: Money, percentage: Percentage): Money =>
new Money({
amountInMinorUnits: Math.round(
money.amountInMinorUnits * (percentage as number) / 100
),
currency: money.currency
})
// Использование
const price = Money.of(100, "USD")
const taxRate = Schema.decodeUnknownSync(Percentage)(8.5)
const tax = applyPercentage(price, taxRate)
// tax = $8.50
Паттерн: Price vs Cost vs Revenue
В сложных доменах одна и та же сумма может иметь разные семантические значения. Branded types помогают их различать:
import { Schema, Brand } from "effect"
// Семантические обёртки над Money
const Price = Money.pipe(Schema.brand("Price"))
type Price = Money & Brand.Brand<"Price">
const Cost = Money.pipe(Schema.brand("Cost"))
type Cost = Money & Brand.Brand<"Cost">
const Revenue = Money.pipe(Schema.brand("Revenue"))
type Revenue = Money & Brand.Brand<"Revenue">
// Бизнес-правило: маржа = цена - себестоимость
const calculateMargin = (price: Price, cost: Cost): Money =>
// Работает, потому что оба наследуют Money
pipe(
subtractMoney(price, cost),
Either.getOrThrow
)
// Нельзя перепутать:
// calculateMargin(cost, price) // ❌ Type error!
Резюме
Money — образцовый составной Value Object, который демонстрирует:
- Minor units — хранение в наименьших единицах валюты для точных вычислений.
- Валюта как часть значения — сумма без валюты бессмысленна.
- Типобезопасные операции — сложение разных валют невозможно на уровне типов.
- Аллокация — корректное распределение с учётом остатка (ни один цент не теряется).
- Schema-трансформация — автоматическая конвертация major ↔ minor units на границах слоёв.
- Ошибки как данные —
CurrencyMismatchError,NegativeResultError— типизированные доменные ошибки.
В следующей статье мы разберём, как работает равенство Value Objects через Equal trait из Effect.