La lógica de dominio pura (Pure Domain Logic) constituye el núcleo esencial de cualquier aplicación empresarial, completamente aislada de dependencias de infraestructura, capas de persistencia, sockets de red y canalizaciones de renderizado de interfaz de usuario. En concordancia con los principios de Clean Architecture y Domain-Driven Design (DDD), el núcleo de dominio debe expresarse mediante funciones puras y deterministas o agregados inmutables.
Determinismo convierte a la lógica de negocio pura en el entorno idóneo para Test-Driven Development. La ausencia de operaciones de entrada/salida (I/O) permite ejecutar miles de especificaciones en milisegundos en memoria, verificando instantáneamente los invariantes del modelo de negocio.
En sistemas corporativos reales, las políticas de fijación de precios, liquidación de tarifas y reglas de cualificación de descuentos se vuelven progresivamente complejas. Sin una disciplina rigurosa de TDD, un motor de cálculo degenera velozmente en un entramado inmanejable de sentencias if-else y switch-case anidadas, donde incorporar una nueva regla comercial rompe invariantes límite previamente garantizados. TDD facilita la emergencia evolutiva de arquitecturas modulares y desacopladas (como los patrones Strategy o Chain of Responsibility) bajo la protección continua de especificaciones ejecutables automatizadas.
El desarrollo de algoritmos de dominio mediante TDD sigue una trayectoria de diseño evolutivo: transiciona desde cálculos aritméticos elementales hacia una arquitectura desacoplada y componible de evaluación de reglas.
+-----------------------------------------------------------------------+
| PATRÓN DE EVOLUCIÓN DEL NÚCLEO DE DOMINIO |
+-----------------------------------------------------------------------+
| |
| [Contexto de Entrada: Artículos del Carrito, Promociones, Reloj] |
| │ |
| ▼ |
| +-------------------------------------+ |
| | 1. Validación de Invariantes | |
| +-------------------------------------+ |
| │ |
| ▼ |
| +-------------------------------------+ |
| | 2. Cálculo del Subtotal Base | |
| +-------------------------------------+ |
| │ |
| ▼ |
| +-------------------------------------+ |
| | 3. Canalización de Estrategias | |
| | (Reglas: Fixed -> Percent -> Cap) | |
| +-------------------------------------+ |
| │ |
| ▼ |
| [Liquidación Final: Total en Centavos, Lista de Descuentos] |
| |
+-----------------------------------------------------------------------+Al diseñar algoritmos monetarios, erradicar imprecisiones de coma flotante (estándar IEEE 754) es un imperativo categórico:
Clock), prohibiendo llamadas no deterministas directas a new Date().Abordemos un reto de ingeniería real: implementar de forma incremental un motor de cálculo de pedidos robusto, PricingEngine, capaz de procesar subtotales de artículos, cupones porcentuales, descuentos fijos, expiración temporal y un tope defensivo de deducción sobre el total de la cesta.
Iniciamos con nuestra prueba fallida inicial (RED): el motor debe computar con precisión la suma de los artículos válidos del carrito y rechazar inmediatamente precios unitarios negativos o cantidades iguales o inferiores a cero.
import { describe, it, expect } from 'vitest';
import { PricingEngine, PricingError } from './pricing-engine';
describe('PricingEngine - Step 1: Base Calculation', () => {
const defaultClock = () => new Date('2026-03-01T10:00:00Z');
it('debe calcular con precisión el costo base para artículos válidos', () => {
const engine = new PricingEngine(defaultClock);
const result = engine.calculate({
items: [
{ productId: 'item-1', unitPriceCents: 1000, quantity: 2 }, // 2000 centavos
{ productId: 'item-2', unitPriceCents: 500, quantity: 1 }, // 500 centavos
],
promotions: [],
});
expect(result.subtotalCents).toBe(2500);
expect(result.totalCents).toBe(2500);
expect(result.discountCents).toBe(0);
});
it('debe lanzar PricingError ante precios negativos o cantidades no positivas', () => {
const engine = new PricingEngine(defaultClock);
expect(() =>
engine.calculate({
items: [{ productId: 'item-1', unitPriceCents: -100, quantity: 1 }],
promotions: [],
})
).toThrow(PricingError);
});
});Implementamos el código mínimo imprescindible para satisfacer las pruebas (GREEN).
export class PricingError extends Error {
constructor(message: string) {
super(message);
this.name = 'PricingError';
}
}
export interface CartItem {
readonly productId: string;
readonly unitPriceCents: number;
readonly quantity: number;
}
export interface PricingCalculationResult {
readonly subtotalCents: number;
readonly discountCents: number;
readonly totalCents: number;
}
export interface PricingContext {
readonly items: readonly CartItem[];
readonly promotions: readonly unknown[];
}
export class PricingEngine {
constructor(private readonly clock: () => Date) {}
public calculate(context: PricingContext): PricingCalculationResult {
let subtotalCents = 0;
for (const item of context.items) {
if (item.unitPriceCents < 0 || item.quantity <= 0) {
throw new PricingError('Invalid item price or quantity');
}
subtotalCents += item.unitPriceCents * item.quantity;
}
return {
subtotalCents,
discountCents: 0,
totalCents: subtotalCents,
};
}
}La suite de pruebas del Paso 1 se ejecuta satisfactoriamente (PASS).
Formulamos los requisitos de las promociones comerciales:
validUntil.describe('PricingEngine - Step 2: Promotions & Date Validation', () => {
const currentDate = new Date('2026-03-01T12:00:00Z');
const clock = () => currentDate;
it('debe aplicar correctamente promociones de porcentaje válidas', () => {
const engine = new PricingEngine(clock);
const result = engine.calculate({
items: [{ productId: 'item-1', unitPriceCents: 10000, quantity: 1 }],
promotions: [
{
code: 'SPRING10',
type: 'PERCENTAGE',
value: 10,
validUntil: new Date('2026-04-01T00:00:00Z'),
},
],
});
expect(result.subtotalCents).toBe(10000);
expect(result.discountCents).toBe(1000);
expect(result.totalCents).toBe(9000);
});
it('debe ignorar promociones cuya fecha de vigencia haya expirado', () => {
const engine = new PricingEngine(clock);
const result = engine.calculate({
items: [{ productId: 'item-1', unitPriceCents: 10000, quantity: 1 }],
promotions: [
{
code: 'EXPIRED',
type: 'FIXED',
value: 2000,
validUntil: new Date('2026-02-01T00:00:00Z'), // Expiró hace un mes
},
],
});
expect(result.discountCents).toBe(0);
expect(result.totalCents).toBe(10000);
});
});La prueba falla (FAIL). Actualizamos el modelo de dominio para alcanzar la fase GREEN.
export interface PercentagePromotion {
readonly code: string;
readonly type: 'PERCENTAGE';
readonly value: number; // 0 - 100
readonly validUntil: Date;
}
export interface FixedPromotion {
readonly code: string;
readonly type: 'FIXED';
readonly value: number; // En centavos
readonly validUntil: Date;
}
export type Promotion = PercentagePromotion | FixedPromotion;
export interface PricingContext {
readonly items: readonly CartItem[];
readonly promotions: readonly Promotion[];
}
export class PricingEngine {
constructor(private readonly clock: () => Date) {}
public calculate(context: PricingContext): PricingCalculationResult {
let subtotalCents = 0;
for (const item of context.items) {
if (item.unitPriceCents < 0 || item.quantity <= 0) {
throw new PricingError('Invalid item price or quantity');
}
subtotalCents += item.unitPriceCents * item.quantity;
}
const now = this.clock();
let totalDiscountCents = 0;
for (const promo of context.promotions) {
if (promo.validUntil.getTime() < now.getTime()) {
continue; // Cupón vencido
}
if (promo.type === 'PERCENTAGE') {
const discount = Math.round((subtotalCents * promo.value) / 100);
totalDiscountCents += discount;
} else if (promo.type === 'FIXED') {
totalDiscountCents += promo.value;
}
}
const totalCents = Math.max(0, subtotalCents - totalDiscountCents);
return {
subtotalCents,
discountCents: totalDiscountCents,
totalCents,
};
}
}Las cuatro especificaciones de los Pasos 1 y 2 concluyen con éxito (PASS).
Introducimos restricciones de negocio más rigurosas:
PricingError).describe('PricingEngine - Step 3: Complex Rules & Caps', () => {
const now = new Date('2026-03-01T12:00:00Z');
const clock = () => now;
it('debe restringir el descuento total al umbral defensivo del 50% del subtotal', () => {
const engine = new PricingEngine(clock);
const result = engine.calculate({
items: [{ productId: 'item-1', unitPriceCents: 10000, quantity: 1 }], // 10000 centavos
promotions: [
{
code: 'SUPERFIXED',
type: 'FIXED',
value: 8000, // Pretende deducir el 80%
validUntil: new Date('2026-04-01T00:00:00Z'),
},
],
});
expect(result.subtotalCents).toBe(10000);
expect(result.discountCents).toBe(5000); // Limitado al 50%
expect(result.totalCents).toBe(5000);
});
it('debe rechazar la presencia simultánea de múltiples promociones porcentuales', () => {
const engine = new PricingEngine(clock);
expect(() =>
engine.calculate({
items: [{ productId: 'item-1', unitPriceCents: 10000, quantity: 1 }],
promotions: [
{
code: 'SALE10',
type: 'PERCENTAGE',
value: 10,
validUntil: new Date('2026-04-01T00:00:00Z'),
},
{
code: 'SALE20',
type: 'PERCENTAGE',
value: 20,
validUntil: new Date('2026-04-01T00:00:00Z'),
},
],
})
).toThrow('Multiple percentage promotions are not allowed');
});
});Las pruebas fallan. Incorporamos la lógica correspondiente en el motor:
public calculate(context: PricingContext): PricingCalculationResult {
let subtotalCents = 0;
for (const item of context.items) {
if (item.unitPriceCents < 0 || item.quantity <= 0) {
throw new PricingError('Invalid item price or quantity');
}
subtotalCents += item.unitPriceCents * item.quantity;
}
const now = this.clock();
let percentagePromosCount = 0;
for (const promo of context.promotions) {
if (promo.type === 'PERCENTAGE') {
percentagePromosCount++;
}
}
if (percentagePromosCount > 1) {
throw new PricingError('Multiple percentage promotions are not allowed');
}
let rawDiscountCents = 0;
for (const promo of context.promotions) {
if (promo.validUntil.getTime() < now.getTime()) {
continue;
}
if (promo.type === 'PERCENTAGE') {
rawDiscountCents += Math.round((subtotalCents * promo.value) / 100);
} else if (promo.type === 'FIXED') {
rawDiscountCents += promo.value;
}
}
// Límite de seguridad: máximo 50% del subtotal
const maxAllowedDiscount = Math.floor(subtotalCents * 0.5);
const finalDiscountCents = Math.min(rawDiscountCents, maxAllowedDiscount);
const totalCents = subtotalCents - finalDiscountCents;
return {
subtotalCents,
discountCents: finalDiscountCents,
totalCents,
};
}Las 6 pruebas de los Pasos 1 a 3 están en verde (PASS).
El código actual cumple con todas las expectativas funcionales, pero transgrede el Principio de Abierto/Cerrado (OCP): añadir un nuevo tipo de descuento requeriría editar directamente el método calculate. Llevamos a cabo una refactorización hacia una arquitectura polimórfica basada en el patrón Strategy bajo la protección de nuestros tests automatizados.
export class PricingError extends Error {
constructor(message: string) {
super(message);
this.name = 'PricingError';
}
}
export interface CartItem {
readonly productId: string;
readonly unitPriceCents: number;
readonly quantity: number;
}
export interface PercentagePromotion {
readonly code: string;
readonly type: 'PERCENTAGE';
readonly value: number;
readonly validUntil: Date;
}
export interface FixedPromotion {
readonly code: string;
readonly type: 'FIXED';
readonly value: number;
readonly validUntil: Date;
}
export type Promotion = PercentagePromotion | FixedPromotion;
export interface PricingCalculationResult {
readonly subtotalCents: number;
readonly discountCents: number;
readonly totalCents: number;
}
export interface PricingContext {
readonly items: readonly CartItem[];
readonly promotions: readonly Promotion[];
}
// 1. Contrato de la Estrategia de Descuento
export interface DiscountStrategy {
calculateDiscount(subtotalCents: number, promo: Promotion, now: Date): number;
}
class PercentageDiscountStrategy implements DiscountStrategy {
calculateDiscount(subtotalCents: number, promo: Promotion, now: Date): number {
if (promo.type !== 'PERCENTAGE' || promo.validUntil.getTime() < now.getTime()) {
return 0;
}
return Math.round((subtotalCents * promo.value) / 100);
}
}
class FixedDiscountStrategy implements DiscountStrategy {
calculateDiscount(subtotalCents: number, promo: Promotion, now: Date): number {
if (promo.type !== 'FIXED' || promo.validUntil.getTime() < now.getTime()) {
return 0;
}
return promo.value;
}
}
// 2. Validador de Invariantes de Dominio
class PricingValidator {
public static validate(context: PricingContext): void {
for (const item of context.items) {
if (item.unitPriceCents < 0 || item.quantity <= 0) {
throw new PricingError('Invalid item price or quantity');
}
}
const percentageCount = context.promotions.filter(
(p) => p.type === 'PERCENTAGE'
).length;
if (percentageCount > 1) {
throw new PricingError('Multiple percentage promotions are not allowed');
}
}
}
// 3. Fachada del Motor de Cálculo
export class PricingEngine {
private readonly strategies: ReadonlyMap<Promotion['type'], DiscountStrategy>;
private static readonly MAX_DISCOUNT_RATIO = 0.5; // Tope del 50%
constructor(private readonly clock: () => Date) {
this.strategies = new Map<Promotion['type'], DiscountStrategy>([
['PERCENTAGE', new PercentageDiscountStrategy()],
['FIXED', new FixedDiscountStrategy()],
]);
}
public calculate(context: PricingContext): PricingCalculationResult {
PricingValidator.validate(context);
const subtotalCents = context.items.reduce(
(sum, item) => sum + item.unitPriceCents * item.quantity,
0
);
const now = this.clock();
let accumulatedDiscountCents = 0;
for (const promo of context.promotions) {
const strategy = this.strategies.get(promo.type);
if (strategy) {
accumulatedDiscountCents += strategy.calculateDiscount(subtotalCents, promo, now);
}
}
// Aplicación del tope de seguridad
const maxDiscountAllowed = Math.floor(subtotalCents * PricingEngine.MAX_DISCOUNT_RATIO);
const finalDiscountCents = Math.min(accumulatedDiscountCents, maxDiscountAllowed);
const totalCents = Math.max(0, subtotalCents - finalDiscountCents);
return {
subtotalCents,
discountCents: finalDiscountCents,
totalCents,
};
}
}La ejecución de Vitest certifica: el 100% de las pruebas se mantienen en verde. La estructura interna se ha modularizado, la validación se ha encapsulado y el polimorfismo se ha establecido sin necesidad de modificar ni una sola línea en las especificaciones existentes.
Almacenar valores monetarios como números fraccionarios (19.99). La evaluación de 0.1 + 0.2 en JavaScript devuelve 0.30000000000000004. En sistemas financieros, este desfase causa acumulación de errores de redondeo e incongruencias contables. Utilice siempre unidades enteras menores (cents, minor units).
Llamar a const now = new Date() dentro de las rutinas de cálculo despoja a las pruebas de su determinismo. Un test que valide cupones por fecha de vigencia pasará hoy pero fallará de forma inesperada cuando expire la fecha objetivo. Inyecte invariablemente un proveedor de reloj clock: () => Date.
Modificar directamente los objetos del carrito recibidos (por ejemplo, asignando item.discountApplied = true). Esto induce efectos secundarios opacos en el árbol de componentes de React y arruina la predictibilidad del renderizado. Los métodos del dominio deben ser funciones puras que retornen instancias nuevas e inmutables.
Intentar construir un interpretador abstracto de reglas basado en árboles AST desde la primera especificación. Escriba el código más simple y directo posible hasta que los requisitos de las pruebas evidencien duplicación y complejidad reales. La abstracción arquitectónica debe florecer de manera natural en la etapa de Refactor.
Redactar la prueba determinista más sencilla libre de efectos secundarios. Verificar límites de entrada (cantidades negativas, colecciones vacías).
Escribir lógica secuencial directa en una función hasta que las pruebas adquieran estado verde.
Incorporar casos de prueba que cubran intervalos temporales, topes máximos (Caps) e incompatibilidad entre modificadores.
Aislar bifurcaciones condicionales en clases de estrategia o funciones reductoras amparadas por la cobertura de pruebas.
Examinar divisiones por cero, desbordamiento de enteros y exactitud estricta en el redondeo al centavo.