In conventional object-oriented approaches to Domain-Driven Design (DDD), domain entities are frequently modeled as classes containing mutable internal state, getters, setters, and concealed side effects.
This pattern introduces critical vulnerabilities into domain logic:
Shipped lacking a tracking number or dispatch timestamp).string and number types for identity, monetary quantities, and email addresses leads to catastrophic errors like swapping userId with orderId in function arguments.Functional architecture eliminates these liabilities via Algebraic Data Types (ADTs) and the core architectural mantra: Make Impossible States Impossible.
Coupled with Smart Constructors, domain logic remains purely deterministic, strictly isolated from I/O, and formally verified by the static type checker.
In functional domain modeling, structures are divided into product types (AND) and sum types (OR):
+-------------------------------------------------------------------------+
| ALGEBRAIC DOMAIN TYPE SYSTEM (ADT) |
+-------------------------------------------------------------------------+
| 1. PRODUCT TYPE: An Order contains an ID AND a User AND Line Items |
| type OrderHeader = { readonly id: OrderId; readonly user: UserId } |
+-------------------------------------------------------------------------+
|
+---> Composition
|
+-------------------------------------------------------------------------+
| 2. SUM TYPE: An Order exists in ONE mutually exclusive state: |
| type OrderState = |
| | DraftOrder (Items, Total) |
| | PaidOrder (Items, Total, PaymentId, PaidAt) |
| | ShippedOrder (Items, Total, TrackingNumber, ShippedAt) |
| | CancelledOrder (Reason, CancelledAt) |
+-------------------------------------------------------------------------+An illegal state cannot physically compile: within DraftOrder, fields such as trackingNumber or paidAt do not exist in the type definition, eliminating runtime undefined bugs across UI and backend services.
EmailAddress, IBAN, PositiveInteger) at the API controller boundary.OrderEvent), ensuring deterministic replay and state reconstruction.To prevent mixing up primitive strings and numbers, we employ nominal typing via the Brand module from Effect-TS:
import { Brand, Either } from 'effect';
// 1. Defining branded types
export type Email = string & Brand.Brand<'Email'>;
export const Email = Brand.refined<Email>(
(value) => value.includes('@') && value.length > 5,
(value) => Brand.error(`String "${value}" is not a valid email address`)
);
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(`Amount ${value} must be a non-negative integer`)
);
// 2. Smart Constructor returning 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))
);
}Now it is impossible to accidentally pass an arbitrary string where an Email is expected: TypeScript yields Type 'string' is not assignable to type 'Email'.
We design the lifecycle of an order so that incompatible field combinations cannot compile:
import { Data } from 'effect';
import type { OrderId, Email, Cents } from './primitives';
export interface OrderItem {
readonly sku: string;
readonly quantity: number;
readonly price: Cents;
}
// 1. Order states modeled via 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;
}> {}
// Algebraic sum type of all possible states
export type Order = DraftOrder | PaidOrder | ShippedOrder | CancelledOrder;Business logic is expressed as pure functions that receive the current state and return the next valid state or a domain error:
import { Either } from 'effect';
import {
type Order,
DraftOrder,
PaidOrder,
ShippedOrder,
CancelledOrder,
} from './order';
export class OrderTransitionError {
readonly _tag = 'OrderTransitionError';
constructor(readonly message: string) {}
}
// 1. Pay order: valid ONLY from 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('Order is already paid'));
case 'ShippedOrder':
return Either.left(new OrderTransitionError('Order has already been shipped'));
case 'CancelledOrder':
return Either.left(new OrderTransitionError('Cannot pay a cancelled order'));
}
}
// 2. Ship order: valid ONLY from PaidOrder
export function shipOrder(
order: Order,
trackingNumber: string
): Either.Either<OrderTransitionError, ShippedOrder> {
if (order._tag !== 'PaidOrder') {
return Either.left(
new OrderTransitionError(
`Fulfillment rejected: Order status is ${order._tag}, expected 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(),
})
);
}Convert raw primitives into branded nominal types Email, Cents. Reject malformed values.
Instantiate an immutable DraftOrder with all non-negotiable initial attributes.
Compute new PaidOrder state. The input draft remains immutable (Referential Transparency).
TypeScript verifies that all variant branches of the Order ADT are exhaustively handled.
Here is a value object implementation leveraging Data.Class and Equal.equals from Effect-TS:
import { Data, Equal } from 'effect';
import type { Cents } from './primitives';
export interface MoneyProps {
readonly amount: Cents;
readonly currency: 'USD' | 'EUR' | 'GBP';
}
// Money class featuring automatic structural value equality
export class Money extends Data.Class<MoneyProps> {
public add(other: Money): Money {
if (this.currency !== other.currency) {
throw new Error(`Currency mismatch: ${this.currency} vs ${other.currency}`);
}
return new Money({
amount: ((this.amount + other.amount) as unknown) as Cents,
currency: this.currency,
});
}
}
// Demonstrating structural value equality:
// Two distinct heap instances with identical properties evaluate to equal via Equal.equals
export function compareMoney(m1: Money, m2: Money): boolean {
// In native JS: m1 === m2 yields false (distinct references)
// In Effect: Equal.equals(m1, m2) yields true (structural value equality)
return Equal.equals(m1, m2);
}Modeling all entity phases in a single object with dozens of optional properties forces developers to litter code with dangerous non-null assertions (!).
// ❌ Anti-pattern: Optional Hell allows illegal states
interface FlawedOrder {
status: 'draft' | 'paid' | 'shipped';
paymentTransactionId?: string; // Forgotten when paid? Compiler remains silent!
trackingNumber?: string; // Populated when draft? Runtime corruption!
}
// ✅ Correct approach: Algebraic sum type (DraftOrder | PaidOrder | ShippedOrder)Using type assertions like const email = "invalid" as Email destroys validation guarantees.
Solution: Forbid direct casting via linter rules (@typescript-eslint/consistent-type-assertions) and only export guarded make* constructors.
Attempting to send an email directly inside payOrder ruins purity, impedes testing, and violates referential transparency.
Solution: The domain transition only computes the new state. Email dispatch belongs to the outer application orchestration layer.
Isolate domain identifiers (UserId, OrderId, Money) and enforce nominal typing via Brand.
Write make* validation functions returning Either<ValidationError, BrandedType>.
Define each entity state with a dedicated Data.TaggedClass without optional fields.
Implement business rules as deterministic functions: (State, Event) => Either<Error, NewState>.
Connect pure domain rules to persistence and network I/O via Effect application services.