Tipo 1 de 4
Subscription
src/domain/subscription.ts
Encaps.
2
Invariantes
1
Utilidad
3
Cumplim.
1
1. Encapsulación
2/5
Es una
interface pública sin constructor controlado — cualquier código puede crear un objeto Subscription con datos incoherentes.No hay clase ni módulo que proteja la mutación post-creación;
status, endDate y cancelledAt son directamente asignables.El alias
SubscriptionStatus = string no aporta encapsulación alguna; es transparente.
2. Expresión de invariantes
1/5
Estado ilegal representable: una Subscription puede tener
status="active" y endDate != null simultáneamente.Unidad monetaria ambigua:
amount: number puede ser 29.99 (euros) o 2999 (céntimos). Sin documentación en el tipo.Currency sin validar:
currency: string permite "EUR", "eur", "Euro" o cualquier cadena.nextBillingDate ignorado: puede ser null aunque status sea "active" — contradice la lógica de negocio.
3. Utilidad de invariantes
3/5
Los campos
cancelledAt y cancelReason son relevantes para el dominio, pero están desconectados del estado — se rellenan por convención.La separación de
startDate y endDate tiene sentido, pero la ausencia de restricciones permite startDate > endDate.Los campos en sí representan conceptos reales y alineados con el modelo de cobros recurrentes (positivo).
4. Cumplimiento por el sistema de tipos
1/5
Escape hatch total: como todo es primitivo (string, number, Date | null), no hay enforcement en tiempo de compilación.
El compilador no puede detectar transiciones de estado ilegales (ej. active → active, cancelled → active).
El string libre para status hace inútil cualquier switch exhaustivo — se necesitará un default clause siempre.
Valoración global
Subscription es el tipo más crítico del sistema: sus campos son todos primitivos sin restricciones, el estado es un string libre y las relaciones entre campos (status ↔ endDate ↔ nextBillingDate) dependen completamente de convenciones de código no expresadas en el tipo. Cualquier bug en la capa de servicio que asigne un estado incorrecto pasará desapercibido hasta producción.
Antes vs. Después — Refactoring propuesto
✗ Código actual (estados ilegales posibles)
// ❌ string libre — ningún enforcement export type SubscriptionStatus = string; export interface Subscription { id: string; status: SubscriptionStatus; amount: number; // euros? céntimos? currency: string; // "EUR" o "eur" o "Euro"? endDate: Date | null; // null incluso si cancelada cancelledAt: Date | null; nextBillingDate: Date | null; }
✓ Versión mejorada (estados ilegales imposibles)
// ✅ Branded type para céntimos type Cents = number & { readonly _brand: 'Cents' }; type CurrencyCode = 'EUR' | 'USD' | 'GBP'; // ✅ Union discriminada — campos por estado type Subscription = | { status: 'active'; nextBillingDate: Date; endDate?: never; cancelledAt?: never } | { status: 'paused'; resumeDate: Date | null } | { status: 'cancelled'; cancelledAt: Date; cancelReason: string; endDate: Date } | { status: 'pending'; startDate: Date } & { id: SubscriptionId; // branded string amount: Cents; currency: CurrencyCode; };
Sugerencias específicas
Union discriminada por statusCada estado lleva exactamente los campos que necesita. nextBillingDate solo existe en "active"; cancelledAt solo en "cancelled".
Branded type CentsReemplazar
number por Cents previene mezclar euros y céntimos en asignaciones directas.Enum o union literal para currency
'EUR' | 'USD' | 'GBP' elimina las variantes de capitalización y cadenas inválidas.SubscriptionId branded stringEvita pasar un customerId donde se espera un subscriptionId — error frecuente en refactors.
Tipo 2 de 4
Payment
src/domain/payment.ts
Encaps.
2
Invariantes
1
Utilidad
3
Cumplim.
1
1. Encapsulación2/5
Igual que Subscription: interface plana sin constructor controlado. Todos los campos son mutables y asignables directamente.
errorCode y errorMessage existen en todos los estados aunque solo son relevantes en "failed".2. Expresión de invariantes1/5
CRÍTICO — refundAmount > amount: El tipo permite refundos mayores al cobro original. Riesgo financiero real y directo.
gatewayRef nullable en succeeded: Un pago "succeeded" sin gatewayRef imposibilita reconciliación con el banco.
refundedAt sin refundAmount: Se puede marcar como reembolsado sin importe, o tener importe sin fecha.
3. Utilidad de invariantes3/5
Los campos representan conceptos reales (referencia de pasarela, fecha de proceso, error). La utilidad potencial es alta pero no se materializa por falta de restricciones.
La separación entre pago de subscripción y pago puntual mediante
subscriptionId | null es semánticamente válida.4. Cumplimiento1/5
El compilador no detecta que un pago "succeeded" debe tener gatewayRef y processedAt.
No hay constraint que impida
refundAmount > amount — requeriría validación en runtime.status: string permite cualquier cadena, rompiendo switches exhaustivos.
Valoración global
Payment es el tipo con mayor riesgo financiero directo: la posibilidad de que refundAmount supere amount es un bug potencial de pérdidas económicas reales. La falta de union discriminada implica que cada estado debería validar manualmente qué campos son obligatorios, y esa lógica vive dispersa en la capa de servicio en lugar de estar en el tipo.
Antes vs. Después — Refactoring propuesto
✗ Código actual
export interface Payment { status: string; amount: number; errorCode: string | null; errorMessage: string | null; processedAt: Date | null; refundedAt: Date | null; refundAmount: number | null; gatewayRef: string | null; // ❌ refundAmount puede ser > amount // ❌ gatewayRef null en succeeded }
✓ Versión mejorada
type GatewayRef = string & { readonly _brand: 'GatewayRef' }; type Payment = | { status: 'pending'; amount: Cents } | { status: 'processing'; amount: Cents; gatewayRef: GatewayRef } | { status: 'succeeded'; amount: Cents; gatewayRef: GatewayRef; // ✅ requerido processedAt: Date; refund?: { amount: Cents; at: Date } } | { status: 'failed'; amount: Cents; errorCode: string; // ✅ solo en failed errorMessage: string }; // ✅ refund anidado: amount siempre <= parent.amount // validado en constructor de Payment
Sugerencias específicas
Union discriminada por estado de pagosucceeded lleva gatewayRef y processedAt como requeridos. failed lleva errorCode/Message. pending no tiene ninguno de los dos.
Refund como objeto anidado opcionalEn lugar de refundedAt + refundAmount sueltos, un objeto
{ amount, at } garantiza que si hay reembolso, ambos campos existen.Branded type GatewayRefDistingue la referencia de pasarela de otros strings, evitando pasarle un invoiceId por accidente.
Validación de refundAmount en constructorSi se usa una clase, el constructor puede lanzar si
refund.amount > this.amount, eliminando el riesgo financiero a nivel de tipo.Tipo 3 de 4
Invoice
src/domain/invoice.ts
Encaps.
2
Invariantes
1
Utilidad
3
Cumplim.
2
1. Encapsulación2/5
lineItems es un array mutable que puede quedarse vacío post-creación.
total, subtotal y taxAmount son campos independientes — se pueden modificar individualmente rompiendo la aritmética.
2. Expresión de invariantes1/5
Doble convención en taxRate: el proyecto usa 0.21 y 21 indistintamente. El tipo no lo resuelve.
Array vacío de lineItems: Invoice sin líneas es inválido fiscalmente pero representable en el tipo.
total no es subtotal+tax: ningún mecanismo en el tipo garantiza la aritmética correcta.
quantity puede ser negativo: InvoiceLineItem.quantity: number admite 0 y negativos.
3. Utilidad3/5
La estructura lineItems + subtotal + taxAmount + total es el modelo correcto para facturación.
La separación de estados (draft/sent/paid/overdue/void) cubre los casos de uso reales, aunque el tipo no los discrimina.
4. Cumplimiento2/5
El uso de
status: string impide switches exhaustivos.Mejor que Payment/Subscription en que la aritmética podría hacerse readonly en los totales — pero no se hace.
Valoración global
Invoice combina riesgo financiero (totales inconsistentes, taxRate ambiguo) con riesgo legal (factura sin líneas, sin dirección, emitida igualmente). El modelo conceptual es correcto pero necesita NonEmptyArray, tipos opacos para porcentajes y que los totales sean computed readonly en lugar de campos independientes.
Antes vs. Después — Refactoring propuesto
✗ Código actual
type InvoiceLineItem = { quantity: number; // puede ser 0 o negativo unitPrice: number; // euros o céntimos? taxRate: number; // 0.21 o 21? total: number; // ¿calculado o manual? }; interface Invoice { lineItems: InvoiceLineItem[]; // puede estar vacío subtotal: number; taxAmount: number; total: number; // inconsistente posible status: string; }
✓ Versión mejorada
// ✅ Porcentaje explícito type TaxRatePercent = 0 | 4 | 10 | 21; // IVA España type PositiveInt = number & { readonly _brand: 'Pos' }; type LineItem = { readonly quantity: PositiveInt; // ✅ > 0 readonly unitPrice: Cents; readonly taxRate: TaxRatePercent; // total eliminado: se computa en Invoice }; type NonEmptyArray<T> = [T, ...T[]]; type Invoice = { /* campos base */ lineItems: NonEmptyArray<LineItem>; // ✅ min 1 // subtotal/tax/total: computed, no almacenados } & (/* union discriminada por estado */ | { status: 'draft' } | { status: 'sent'; sentAt: Date; dueDate: Date } | { status: 'paid'; paidAt: Date; paymentRef: string } | { status: 'void'; voidedAt: Date; voidReason: string } );
Sugerencias específicas
NonEmptyArray<LineItem>Garantiza que la factura tiene al menos un concepto, haciendo ilegal una factura vacía a nivel de tipo.
TaxRatePercent como union literalLos tipos de IVA en España son 0, 4, 10 y 21. Usar una union literal elimina la ambigüedad 0.21 vs 21 de raíz.
Eliminar total del LineItemtotal = quantity × unitPrice × (1 + taxRate/100) es una función pura. No debe almacenarse; se computa en el método getTotal() de Invoice.
PositiveInt para quantityUn branded type que en su constructor valida que el valor sea > 0 elimina facturas con 0 o -1 unidades.
Tipo 4 de 4
Customer
src/domain/customer.ts
Encaps.
3
Invariantes
2
Utilidad
4
Cumplim.
3
1. Encapsulación3/5
El objeto address está agrupado en un sub-tipo, mejor que campos planos.
paymentMethod también está agrupado.
Pero ambos son mutable objects, y address | null puede romperse si se accede desde fuera.
2. Expresión de invariantes2/5
paymentMethod con IBAN en tarjeta: Si type="card", iban debería ser inexistente. Si type="sepa", last4 no aplica. El tipo actual permite cualquier combinación.
Country sin enum: "ES", "Spain", "spain" o "esp" son todas cadenas válidas.
address null en facturación: La lógica de negocio requiere dirección al emitir facturas, pero el tipo no fuerza esta restricción en ese contexto.
3. Utilidad4/5
El tipo cubre los casos reales de CULTIVA: cliente con/sin VAT, con/sin método de pago.
La opción de vatNumber nullable es correcta para particulares vs. empresas.
La separación de los sub-tipos es conceptualmente adecuada para el dominio.
4. Cumplimiento3/5
Es el tipo más avanzado del sistema: paymentMethod como objeto agrupado es un buen inicio.
La union discriminada en paymentMethod.type no está implementada — es string libre.
email y vatNumber son strings sin formato validado a nivel de tipo.
Valoración global
Customer es el tipo mejor diseñado del sistema: agrupa sub-objetos, los nullables tienen sentido semántico y la estructura refleja el dominio. Sin embargo, la ausencia de union discriminada en paymentMethod (card vs. sepa vs. transfer) permite combinaciones ilegales de campos, y los branded types para email, vatNumber e IBAN evitarían intercambios accidentales en llamadas a servicios.
Antes vs. Después — Refactoring propuesto
✗ Código actual
interface Customer { email: string; // sin validar vatNumber: string | null; address: { country: string; // "ES" o "Spain" } | null; paymentMethod: { type: string; // card|sepa|transfer last4: string | null; iban: string | null; // null si tarjeta, but..? mandateId: string | null; } | null; }
✓ Versión mejorada
type Email = string & { readonly _brand: 'Email' }; type IBAN = string & { readonly _brand: 'IBAN' }; type VatNumber = string & { readonly _brand: 'VatNumber' }; type CountryCode = 'ES' | 'FR' | 'DE' | 'PT' | 'GB'; // ✅ Union discriminada por método de pago type PaymentMethod = | { type: 'card'; last4: string; brand: string } | { type: 'sepa'; iban: IBAN; mandateId: string } | { type: 'transfer'; iban: IBAN }; interface Customer { email: Email; // ✅ branded vatNumber: VatNumber | null; address: BillingAddress | null; // country: CountryCode paymentMethod: PaymentMethod | null; }
Sugerencias específicas
Union discriminada PaymentMethodcard solo tiene last4 y brand. sepa tiene iban y mandateId (obligatorios). transfer tiene solo iban. Imposible confundir.
CountryCode como union literalLimitar a los países de operación (ISO 3166-1 alpha-2) elimina variantes "Spain"/"spain" y cadenas inválidas.
Email branded en creaciónUn smart constructor
Email.parse(s: string): Email valida el formato con regex en un único punto, propagando la garantía por todo el sistema.BillingAddress con requeridosCrear un tipo BillingAddress separado con todos los campos obligatorios (street, postalCode, city, country). Invoice puede requerir Customer con BillingAddress (no null).