Типобезопасный домен: Гексагональная архитектура на базе Effect Пример: Money — составной Value Object
Глава

Пример: 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 — количество десятичных знаков:

ВалютаКодExponent1 major unit =
US DollarUSD2100 cents
EuroEUR2100 cents
Russian RubleRUB2100 kopecks
Japanese YenJPY01 yen
Kuwaiti DinarKWD31000 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, который демонстрирует:

  1. Minor units — хранение в наименьших единицах валюты для точных вычислений.
  2. Валюта как часть значения — сумма без валюты бессмысленна.
  3. Типобезопасные операции — сложение разных валют невозможно на уровне типов.
  4. Аллокация — корректное распределение с учётом остатка (ни один цент не теряется).
  5. Schema-трансформация — автоматическая конвертация major ↔ minor units на границах слоёв.
  6. Ошибки как данныеCurrencyMismatchError, NegativeResultError — типизированные доменные ошибки.

В следующей статье мы разберём, как работает равенство Value Objects через Equal trait из Effect.