En el enfoque clásico orientado a objetos de Domain-Driven Design (DDD), las entidades de dominio suelen modelarse como clases con estado interno mutable, getters, setters y efectos secundarios encubiertos.
Este patrón origina vulnerabilidades críticas en la lógica de negocio:
Shipped sin fecha de expedición ni código de seguimiento).string y number para identificadores, importes monetarios y emails conduce a traspapelar accidentalmente userId en lugar de orderId al invocar funciones.La arquitectura funcional resuelve estas carencias mediante tipos de datos algebraicos (Algebraic Data Types, ADT) y la máxima: Make Impossible States Impossible (Haz que los estados inválidos sean irrepresentables).
En combinación con el patrón Smart Constructors (Constructores inteligentes), la lógica de dominio se vuelve puramente determinista, aislada de I/O y formalmente verificada por el compilador estático.
En el modelado funcional, las estructuras se dividen en tipos producto (Y) y tipos suma (O):
+-------------------------------------------------------------------------+
| SISTEMA DE TIPOS ALGEBRAICO DEL DOMINIO (ADT) |
+-------------------------------------------------------------------------+
| 1. PRODUCT TYPE (Tipo producto): El pedido contiene ID Y User Y Items |
| type OrderHeader = { readonly id: OrderId; readonly user: UserId } |
+-------------------------------------------------------------------------+
|
+---> Composición
|
+-------------------------------------------------------------------------+
| 2. SUM TYPE (Tipo suma): El pedido reside en UNO de los estados |
| mutuamente excluyentes: |
| type OrderState = |
| | DraftOrder (Items, Total) |
| | PaidOrder (Items, Total, PaymentId, PaidAt) |
| | ShippedOrder (Items, Total, TrackingNumber, ShippedAt) |
| | CancelledOrder (Reason, CancelledAt) |
+-------------------------------------------------------------------------+Un estado no válido resulta físicamente imposible de compilar: en DraftOrder, atributos como trackingNumber o paidAt no existen a nivel de tipos, suprimiendo de raíz errores lógicos en UI y servicios de backend.
EmailAddress, IBAN, PositiveInteger) en la frontera del controlador.OrderEvent), asegurando la reconstrucción determinista del agregado.Para evitar confusiones entre primitivos idénticos empleamos tipado nominal mediante el módulo Brand de Effect-TS:
import { Brand, Either } from 'effect';
// 1. Declaración de tipos nominales (Branded Types)
export type Email = string & Brand.Brand<'Email'>;
export const Email = Brand.refined<Email>(
(value) => value.includes('@') && value.length > 5,
(value) => Brand.error(`La cadena "${value}" no constituye un correo válido`)
);
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(`El importe ${value} debe ser un entero no negativo`)
);
// 2. Smart Constructor devolviendo 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))
);
}Ahora es inviable suministrar un string ordinario donde se exige Email: TypeScript emitirá Type 'string' is not assignable to type 'Email'.
Diseñamos el ciclo de vida de un pedido de modo que las combinaciones discordantes de campos no compilen:
import { Data } from 'effect';
import type { OrderId, Email, Cents } from './primitives';
export interface OrderItem {
readonly sku: string;
readonly quantity: number;
readonly price: Cents;
}
// 1. Estados del pedido modelados con 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;
}> {}
// Tipo suma algebraico de todos los estados admisibles
export type Order = DraftOrder | PaidOrder | ShippedOrder | CancelledOrder;La lógica de negocio se escribe como funciones puras que reciben el estado vigente y devuelven el estado subsiguiente o un error tipado:
import { Either } from 'effect';
import {
type Order,
DraftOrder,
PaidOrder,
ShippedOrder,
CancelledOrder,
} from './order';
export class OrderTransitionError {
readonly _tag = 'OrderTransitionError';
constructor(readonly message: string) {}
}
// 1. Pagar pedido: admisible ÚNICAMENTE desde 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('El pedido ya ha sido pagado'));
case 'ShippedOrder':
return Either.left(new OrderTransitionError('El pedido ya ha sido enviado'));
case 'CancelledOrder':
return Either.left(new OrderTransitionError('No se puede pagar un pedido cancelado'));
}
}
// 2. Enviar pedido: admisible ÚNICAMENTE desde PaidOrder
export function shipOrder(
order: Order,
trackingNumber: string
): Either.Either<OrderTransitionError, ShippedOrder> {
if (order._tag !== 'PaidOrder') {
return Either.left(
new OrderTransitionError(
`Despacho denegado: el pedido se halla en estado ${order._tag}, no en 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(),
})
);
}Conversión de primitivos en tipos nominales Email, Cents. Bloqueo de entradas malformadas.
Instanciación de la entidad inmutable DraftOrder con atributos requeridos completos.
Cálculo del nuevo estado PaidOrder preservando la inmutabilidad del draft previo (Transparencia referencial).
TypeScript verifica que todas las ramas del ADT Order hayan sido tratadas.
A continuación se muestra un Value Object con igualdad por contenido empleando Data.Class y Equal.equals de Effect-TS:
import { Data, Equal } from 'effect';
import type { Cents } from './primitives';
export interface MoneyProps {
readonly amount: Cents;
readonly currency: 'USD' | 'EUR' | 'GBP';
}
// Clase Money con igualdad estructural automática por valor (Value Object)
export class Money extends Data.Class<MoneyProps> {
public add(other: Money): Money {
if (this.currency !== other.currency) {
throw new Error(`Discrepancia de monedas: ${this.currency} y ${other.currency}`);
}
return new Money({
amount: ((this.amount + other.amount) as unknown) as Cents,
currency: this.currency,
});
}
}
// Demostración de igualdad estructural:
// Dos instancias independientes en memoria con atributos idénticos resultan iguales
export function compareMoney(m1: Money, m2: Money): boolean {
// En JS estándar: m1 === m2 retorna false (punteros de memoria distintos)
// En Effect: Equal.equals(m1, m2) retorna true (igualdad estructural de valor)
return Equal.equals(m1, m2);
}Agrupar todas las etapas de una entidad en una sola interfaz colmada de campos opcionales fuerza el uso sistemático de aserciones no nulas inseguras (!).
// ❌ Antipatrón: campos opcionales permiten estados absurdos
interface FlawedOrder {
status: 'draft' | 'paid' | 'shipped';
paymentTransactionId?: string; // ¿Olvidado al marcar 'paid'? ¡El compilador no avisa!
trackingNumber?: string; // ¿Informado en estado 'draft'? ¡Corrupción en runtime!
}
// ✅ Solución correcta: tipo suma algebraico (DraftOrder | PaidOrder | ShippedOrder)Forzar el tipo con const email = "invalido" as Email subvierte los mecanismos de validación instituidos en el constructor inteligente.
Solución: vetar aserciones directas con el linter (@typescript-eslint/consistent-type-assertions) y publicar exclusivamente constructores make*.
Despachar correos o consultar bases de datos desde payOrder contamina la pureza del modelo, lastra los tests y menoscaba el determinismo.
Solución: la función de dominio solo computa el nuevo estado; la orquestación I/O compete a la capa exterior de servicios de aplicación.
Identificar claves de negocio (UserId, OrderId, Money) y blindarlas con tipos nominales Brand.
Elaborar factorías make* que retornen Either<ValidationError, BrandedType>.
Tipar cada estado de ciclo de vida con un Data.TaggedClass desprovisto de campos opcionales.
Escribir reglas de negocio como funciones deterministas: (State, Event) => Either<Error, NewState>.
Integrar el dominio puro con la persistencia y la red mediante Application Services en Effect-TS.