У класичному об'єктно-орієнтованому підході до 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, що виключає появу логічних помилок у коді інтерфейсу та бізнес-сервісах.
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' | 'UAH';
}
// Клас 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);
}Моделювання всіх станів сутності в одному об'єкті з десятком опціональних полів призводить до того, що розробник змушений розставляти небезпечні оператори ! (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).
Визначити всі бізнес-ідентифікатори (UserId, OrderId, Money) та захистити їх через Brand.
Написати функції валідації make* з поверненням Either<ValidationError, BrandedType>.
Описати кожен життєвий стан сутності окремим Data.TaggedClass без опціональних полів.
Реалізувати бізнес-правила як детерміновані функції (State, Event) => Either<Error, NewState>.
Зв'язати чисті доменні функції з базою даних та мережею через Application Services на Effect-TS.