В классическом объектно-ориентированном подходе к Domain-Driven Design (DDD) сущности домена часто моделируются как классы с внутренним мутабельным состоянием, геттерами, сеттерами и скрытыми побочными эффектами.
Этот подход порождает критические уязвимости бизнес-логики:
Shipped, но без даты отправки и адреса доставки).string и number для идентификаторов, денежных сумм и email-адресов приводит к случайной передаче userId вместо orderId в параметры функций.Функциональная архитектура решает эту проблему за счет концепции алгебраических типов данных (Algebraic Data Types, ADT) и принципа Make Impossible States Impossible (Сделай невозможные состояния невыразимыми в типах).
В сочетании с паттерном Smart Constructors (Умные конструкторы) доменная логика становится абсолютно чистой, изолированной от I/O и гарантированно корректной на этапе статического анализа.
В функциональном моделировании домен разделен на типы-произведения (И) и типы-суммы (ИЛИ):
+-------------------------------------------------------------------------+
| АЛГЕБРАИЧЕСКАЯ СИСТЕМА ТИПОВ ДОМЕНА (ADT) |
+-------------------------------------------------------------------------+
| 1. PRODUCT TYPE (Тип-произведение): Заказ содержит ID И User И Items |
| type OrderHeader = { readonly id: OrderId; readonly user: UserId } |
+-------------------------------------------------------------------------+
|
+---> Композиция
|
+-------------------------------------------------------------------------+
| 2. SUM TYPE (Тип-сумма): Заказ находится в ОДНОМ из взаимоисключающих |
| состояний: |
| type OrderState = |
| | DraftOrder (Items, Total) |
| | PaidOrder (Items, Total, PaymentId, PaidAt) |
| | ShippedOrder (Items, Total, TrackingNumber, ShippedAt) |
| | CancelledOrder (Reason, CancelledAt) |
+-------------------------------------------------------------------------+Недопустимое состояние невозможно создать физически: в состоянии DraftOrder на уровне типов отсутствуют поля trackingNumber или paidAt, что исключает появление логических багов в коде интерфейса и бизнес-сервисах.
Определить все бизнес-идентификаторы (UserId, OrderId, Money) и защитить их через Brand.
Написать функции валидации make* с возвратом Either<ValidationError, BrandedType>.
Описать каждое жизненное состояние сущности отдельным Data.TaggedClass без опциональных полей.
Реализовать бизнес-правила как детерминированные функции (State, Event) => Either<Error, NewState>.
Связать чистые доменные функции с базой данных и сетью через Application Services на Effect-TS.
Каким образом архитектурный паттерн ADT (типы-суммы и типы-произведения) устраняет ошибки несогласованности данных (Illegal States)?
EmailAddress, IBAN, PositiveInteger) на границе контроллера.OrderEvent), гарантирующее детерминированное восстановление состояния агрегата.Для защиты от путаницы примитивных строк и чисел используется паттерн номинальной типизации (Branded / Tagged Types) с утилитой Brand из Effect-TS:
import { Brand, Either } from 'effect';
// 1. Определение брендированных типов
export type Email = string & Brand.Brand<'Email'>;
export const Email = Brand.refined<Email>(
(value) => value.includes('@') && value.length > 5,
(value) => Brand.error(`Строка "${value}" не является валидным адресом email`)
);
export type OrderId = string & Brand.Brand<'OrderId'>;
export const OrderId = Brand.nominal<OrderId>();
export type Cents = number & Brand.Brand<'Cents'>;
export const Cents = Brand.refined<Cents>(
(value) => Number.isInteger(value) && value >= 0,
(value) => Brand.error(`Сумма ${value} должна быть неотрицательным целым числом`)
);
// 2. Умный конструктор (Smart Constructor) с возвратом Either
export class InvalidEmailError {
readonly _tag = 'InvalidEmailError';
constructor(readonly message: string) {}
}
export function makeEmail(rawString: string): Either.Either<InvalidEmailError, Email> {
return Email.either(rawString).pipe(
Either.mapLeft((brandErrors) => new InvalidEmailError(brandErrors[0].message))
);
}Теперь невозможно случайно передать сырую строку туда, где требуется Email: компилятор TypeScript выдаст ошибку Type 'string' is not assignable to type 'Email'.
Спроектируем жизненный цикл заказа так, чтобы невозможные комбинации полей не могли скомпилироваться:
import { Data } from 'effect';
import type { OrderId, Email, Cents } from './primitives';
export interface OrderItem {
readonly sku: string;
readonly quantity: number;
readonly price: Cents;
}
// 1. Состояния заказа как тегированные структуры Data.TaggedClass
export class DraftOrder extends Data.TaggedClass('DraftOrder')<{
readonly id: OrderId;
readonly customerEmail: Email;
readonly items: readonly OrderItem[];
readonly total: Cents;
}> {}
export class PaidOrder extends Data.TaggedClass('PaidOrder')<{
readonly id: OrderId;
readonly customerEmail: Email;
readonly items: readonly OrderItem[];
readonly total: Cents;
readonly paymentTransactionId: string;
readonly paidAt: Date;
}> {}
export class ShippedOrder extends Data.TaggedClass('ShippedOrder')<{
readonly id: OrderId;
readonly customerEmail: Email;
readonly items: readonly OrderItem[];
readonly total: Cents;
readonly paymentTransactionId: string;
readonly trackingNumber: string;
readonly shippedAt: Date;
}> {}
export class CancelledOrder extends Data.TaggedClass('CancelledOrder')<{
readonly id: OrderId;
readonly reason: string;
readonly cancelledAt: Date;
}> {}
// Алгебраический тип-сумма всех возможных состояний
export type Order = DraftOrder | PaidOrder | ShippedOrder | CancelledOrder;Бизнес-логика выражается в виде чистых функций, принимающих текущее состояние и возвращающих новое состояние либо доменную ошибку:
import { Either } from 'effect';
import {
type Order,
DraftOrder,
PaidOrder,
ShippedOrder,
CancelledOrder,
} from './order';
export class OrderTransitionError {
readonly _tag = 'OrderTransitionError';
constructor(readonly message: string) {}
}
// 1. Оплата заказа: допустима ТОЛЬКО из состояния DraftOrder
export function payOrder(
order: Order,
paymentTransactionId: string
): Either.Either<OrderTransitionError, PaidOrder> {
switch (order._tag) {
case 'DraftOrder':
return Either.right(
new PaidOrder({
id: order.id,
customerEmail: order.customerEmail,
items: order.items,
total: order.total,
paymentTransactionId,
paidAt: new Date(),
})
);
case 'PaidOrder':
return Either.left(new OrderTransitionError('Заказ уже оплачен'));
case 'ShippedOrder':
return Either.left(new OrderTransitionError('Заказ уже отправлен'));
case 'CancelledOrder':
return Either.left(new OrderTransitionError('Невозможно оплатить отмененный заказ'));
}
}
// 2. Отправка заказа: допустима ТОЛЬКО из состояния PaidOrder
export function shipOrder(
order: Order,
trackingNumber: string
): Either.Either<OrderTransitionError, ShippedOrder> {
if (order._tag !== 'PaidOrder') {
return Either.left(
new OrderTransitionError(
`Отправка невозможна: заказ находится в статусе ${order._tag}, а не PaidOrder`
)
);
}
return Either.right(
new ShippedOrder({
id: order.id,
customerEmail: order.customerEmail,
items: order.items,
total: order.total,
paymentTransactionId: order.paymentTransactionId,
trackingNumber,
shippedAt: new Date(),
})
);
}Преобразование строк в номинальные типы Email, Cents. Блокировка некорректных значений.
Инициализация неизменяемой сущности DraftOrder с полным набором обязательных атрибутов.
Вычисление нового состояния PaidOrder. Исходный объект draft остается неизменным (Referential Transparency).
TypeScript проверяет, что логика обработала абсолютно все ветки алгебраического объединения Order.
Ниже представлен модуль сравнения и манипуляции неизменяемыми значениями на базе утилиты Data из Effect-TS:
import { Data, Equal } from 'effect';
import type { Cents } from './primitives';
export interface MoneyProps {
readonly amount: Cents;
readonly currency: 'USD' | 'EUR' | 'RUB';
}
// Класс Money с автоматической поддержкой структурного равенства по значению (Value Object)
export class Money extends Data.Class<MoneyProps> {
public add(other: Money): Money {
if (this.currency !== other.currency) {
throw new Error(`Несовпадение валют: ${this.currency} и ${other.currency}`);
}
return new Money({
amount: ((this.amount + other.amount) as unknown) as Cents,
currency: this.currency,
});
}
}
// Демонстрация структурного равенства:
// Два разных экземпляра в памяти с идентичными полями равны через Equal.equals
export function compareMoney(m1: Money, m2: Money): boolean {
// В стандартном JS: m1 === m2 вернет false (разные ссылки в куче)
// В Effect: Equal.equals(m1, m2) вернет true (структурное равенство)
return Equal.equals(m1, m2);
}Какую роль выполняет умный конструктор (Smart Constructor) в связке с Branded Types?
Моделирование всех состояний сущности в одном объекте с десятком опциональных полей приводит к тому, что разработчик вынужден расставлять небезопасные операторы ! (non-null assertion).
// ❌ Антипаттерн: 'Optional Hell' допускает невозможные состояния
interface FlawedOrder {
status: 'draft' | 'paid' | 'shipped';
paymentTransactionId?: string; // Забыли заполнить при 'paid'? Компилятор промолчит!
trackingNumber?: string; // Заполнили при 'draft'? Ошибка рантайма!
}
// ✅ Правильное решение: тип-сумма (DraftOrder | PaidOrder | ShippedOrder)Использование явного приведения типов const email = "invalid" as Email полностью уничтожает гарантии валидации, ради которых создавался умный конструктор.
Решение: запретить прямой кастинг через линтер (@typescript-eslint/consistent-type-assertions) и экспортировать только функции make*.
Попытка отправить email прямо из функции payOrder разрушает чистоту доменной модели, усложняет тестирование и лишает функцию детерминизма.
Решение: доменная функция только вычисляет новое состояние. Отправка email выполняется на уровне оркестратора эффектов (Application Service Layer).