Advanced Defensive Programming Techniques, de Zoran Horvat, parte de una idea provocadora: buena parte del código defensivo existe porque el diseño permite representar estados que nunca deberían haber sido posibles.
La página actual de Pluralsight describe el curso como una transición desde el defensive coding explícito hacia prácticas de defensive design. El recorrido pasa por construcción de objetos consistentes, transiciones válidas, eliminación de primitive obsession, dominios de funciones, nulls, modelos ricos y flujos alternativos de error.
Después de revisar los nueve módulos y los demos en C#, la conclusión es interesante: la tesis central ha envejecido muy bien; parte del código, no tanto.
De hecho, algunos ejemplos contienen precisamente el tipo de bugs que el curso intenta enseñarnos a evitar.
Este artículo no repite nuestro análisis anterior de estas ideas dentro del codebase de OpenAI Codex. Aquí el foco es otro: examinar el curso completo, identificar qué conservar en 2026, qué reinterpretar y qué detalles de implementación no conviene copiar.
La tesis: mover la defensa al diseño
El curso utiliza una frase recurrente:
“When you have to defend, you have already lost.”
Tomada literalmente sería demasiado absoluta. Pero como heurística de diseño es excelente.
No significa que debamos dejar de validar JSON, input de usuario, respuestas HTTP o configuración. Significa que una vez que los datos han cruzado la frontera del sistema, no deberíamos obligar a cada método interno a volver a descubrir las mismas invariantes.
El patrón es:
INPUT NO CONFIABLE
|
v
parse / validate / normalize
|
v
TIPOS DEL DOMINIO
|
v
CORE CON INVARIANTES
|
v
OUTPUT
Cuanto más pequeño sea el perímetro donde los datos son inciertos, menor será la cantidad de defensive code que necesitamos distribuir por todo el programa.
El curso como una progresión de restricciones
Los nueve módulos pueden verse como una sola transformación gradual.
| Paso | Problema | Respuesta de diseño |
|---|---|---|
| 1 | if, try/catch y guards repetidos | reconocer límites del defensive coding |
| 2 | objetos que nacen inválidos | construir solo objetos consistentes |
| 3 | mutaciones que rompen invariantes | permitir solo transiciones válidas |
| 4 | primitive obsession | introducir tipos del dominio |
| 5 | funciones que aceptan demasiado | reducir su dominio |
| 6 | reglas dispersas | encapsular comportamiento |
| 7 | null como estado implícito | hacer explícita la ausencia |
| 8 | modelos anémicos | enriquecer el dominio |
| 9 | excepciones para resultados normales | modelar flujos alternativos |
La idea acumulativa puede expresarse así:
Primitive
|
v
Value Object
|
v
Valid Object
|
v
Valid State Machine
|
v
Restricted Function Domain
|
v
Encapsulated Behavior
|
v
Explicit Optionality
|
v
Explicit Success / Failure
Cada paso intenta mover conocimiento desde comprobaciones procedurales hacia la estructura del programa.
1. Hacer estados inválidos más difíciles de representar
Imaginemos:
void Deposit(decimal amount)
{
if (amount <= 0)
throw new ArgumentException();
}
La función recibe todos los valores de decimal, aunque su dominio real sea mucho menor.
Un diseño alternativo sería conceptualmente:
void Deposit(PositiveMoney amount)
{
// amount ya pertenece al dominio permitido
}
La comprobación no desaparece mágicamente. Se mueve al lugar donde se crea PositiveMoney.
Esto es importante porque una validación ejecutada una vez en una frontera puede sustituir docenas de guards internos.
Es una formulación práctica de una máxima muy conocida:
Make illegal states unrepresentable.
En C# no siempre podemos conseguirlo de forma perfecta, pero podemos acercarnos bastante mediante tipos pequeños, constructores privados, factories y APIs que no expongan estados intermedios inválidos.
2. Primitive obsession: uno de los puntos más vigentes
Un método como:
CreateUser(
string email,
string country,
decimal amount)
acepta tipos sintácticamente válidos pero semánticamente enormes.
El compilador no sabe que:
- email debe ser una dirección válida;
- country debe pertenecer a un conjunto concreto;
- amount debe respetar reglas monetarias.
Una interfaz más expresiva sería:
CreateUser(
EmailAddress email,
CountryCode country,
PositiveMoney amount)
El cambio parece pequeño, pero altera la arquitectura.
Antes, cualquier consumidor puede introducir basura y cada capa debe protegerse.
Después, el sistema puede establecer una frontera:
string
|
v
EmailAddress.TryCreate(...)
|
+---- error
|
v
EmailAddress
Dentro del core ya no trabajamos con una cadena cualquiera.
3. Los dominios de funciones son una forma de defensa
Una de las mejores ideas del curso es mirar las funciones matemáticamente.
Si una función solo tiene sentido para enteros positivos, entonces:
Process(int value)
miente sobre su dominio.
La firma permite valores que conceptualmente no pertenecen a la operación.
Ese error de diseño produce después:
if (value <= 0)
...
Horvat plantea invertir el problema: hacer que la firma sea más estrecha.
Esto resulta especialmente útil durante code review. Una pregunta poderosa es:
¿Este guard clause protege una frontera real, o compensa un tipo demasiado permisivo?
Si valida datos externos, probablemente está en el lugar correcto.
Si diez métodos internos comprueban que el mismo ID no esté vacío, probablemente falta un tipo.
4. Construcción válida y transiciones válidas son problemas diferentes
Un objeto puede nacer válido y corromperse después.
Por eso el curso separa dos preguntas:
- ¿puedo crear una instancia inválida?
- ¿puedo convertir una instancia válida en otra inválida?
La segunda pregunta conduce a state machines y mutabilidad restringida.
En lugar de permitir cambios arbitrarios:
A <-> B <-> C <-> D
podemos restringir:
A -> B -> C -> D
Esto es constrained mutability: el objeto puede cambiar, pero solo a través de operaciones que preservan las invariantes.
La idea sigue siendo valiosa en workflows, órdenes, pagos, pipelines y agentes.
Un estado de ejecución no debería ser una colección accidental de booleanos:
bool Started;
bool Finished;
bool Failed;
bool Retrying;
porque aparecen combinaciones que no tienen significado.
Es mejor representar alternativas explícitas.
5. Option y Either: buenas ideas, pero hoy tenemos más herramientas
El curso dedica un módulo a reemplazar null con un Option
Conceptualmente, ambas ideas siguen siendo sólidas:
Option<T>
= Some(T)
| None
Result<T, E>
= Success(T)
| Failure(E)
La ausencia y el fallo dejan de esconderse en control flow implícito.
Pero el contexto de C# cambió mucho.
Microsoft documenta hoy Nullable Reference Types como una característica de análisis estático que permite expresar intención mediante string frente a string? y hace que el compilador avise sobre posibles dereferencias nulas.
Eso reduce enormemente la necesidad de inventar Option
Option sigue teniendo valor cuando la ausencia es parte semántica del dominio, no simplemente cuando queremos evitar NullReferenceException.
Algo similar ocurre con records y record structs. C# genera igualdad por valor para records, lo que elimina una cantidad significativa de código ceremonial que los ejemplos antiguos tenían que implementar manualmente.
Cuatro problemas concretos en los demos
Aquí el material se vuelve todavía más útil, porque sus propias imperfecciones permiten ver por qué estas ideas son difíciles de aplicar correctamente.
Bug 1: Grade parece value object, pero conserva igualdad por referencia
Uno de los demos transforma las notas en una clase parecida a:
public class Grade
{
private Grade(double numericEquivalent)
{
NumericEquivalent = numericEquivalent;
}
public static Grade A => new Grade(4);
public static Grade B => new Grade(3);
public static Grade F => new Grade(0);
}
Más adelante existe lógica que compara una nota con Grade.F.
El problema es que cada acceso a Grade.F crea una instancia nueva.
Si la clase no sobrescribe correctamente Equals y GetHashCode, una comparación como:
Grade.F == Grade.F
compara referencias y no valores.
Dos objetos que conceptualmente representan la misma nota pueden resultar diferentes.
En C# moderno, un pequeño value object podría expresarse de manera mucho más segura mediante un record o readonly record struct, siempre que también controlemos qué valores pueden construirse.
public readonly record struct Grade
{
public int Value { get; }
private Grade(int value) => Value = value;
public static Grade A => new(4);
public static Grade B => new(3);
public static Grade F => new(0);
}
La lección no es “usa record siempre”.
La lección es que un value object necesita semántica de valor completa, no solo una clase con un constructor privado.
Bug 2: PersonalName rompe el contrato entre Equals y GetHashCode
Otro demo implementa igualdad de nombres ignorando mayúsculas y minúsculas, pero calcula el hash usando los strings de forma que puede no respetar exactamente la misma semántica.
Eso puede provocar:
a.Equals(b) == true
pero
a.GetHashCode() != b.GetHashCode()
Ese contrato es crítico en Dictionary y HashSet.
Este bug es especialmente irónico en un curso de defensive design: encapsular un string dentro de un tipo no es suficiente. El tipo debe preservar correctamente todas las leyes que promete.
En C# moderno conviene usar el mismo StringComparer para igualdad y hashing.
private static readonly StringComparer Comparer =
StringComparer.OrdinalIgnoreCase;
public bool Equals(PersonalName? other) =>
other is not null &&
Comparer.Equals(FirstName, other.FirstName) &&
Comparer.Equals(LastName, other.LastName);
public override int GetHashCode() =>
HashCode.Combine(
Comparer.GetHashCode(FirstName),
Comparer.GetHashCode(LastName));
Bug 3: Option.Some(null) vuelve a introducir el estado que pretendíamos eliminar
El Option
Option.Some<string>(null)
Eso deja tres estados posibles:
null
None<string>
Some<string>(null)
En lugar de simplificar el modelo, lo hemos complicado.
Si construimos un Option propio, Some debe garantizar que contiene un valor válido para las reglas de ese Option.
El lesson learned es general:
Un wrapper no crea una invariante por el simple hecho de existir.
La invariante aparece cuando todas las rutas de construcción la preservan.
Bug 4: algunas APIs internas vuelven a saltarse las garantías
Un diseño puede tener un constructor público seguro y aun así reintroducir corrupción mediante factories, métodos de copia o métodos internos.
Por ejemplo, una operación tipo:
application.With(grade)
puede volver a introducir Some(null) si no respeta exactamente las mismas reglas que la construcción inicial.
Este es un error común en domain models reales.
No basta con auditar el constructor.
Hay que auditar:
constructor
factory
builder
With(...)
copy
deserialization
ORM hydration
mapping
event replay
Toda ruta que pueda producir una instancia forma parte del boundary de consistencia.
Qué ha envejecido del código
Los demos están construidos sobre un stack anterior al .NET moderno: .NET Framework 4.5.2, ASP.NET MVC clásico y APIs como WebRequest.
No usaría ese código como plantilla arquitectónica actual.
Pero separar ideas de mecanismos es precisamente lo interesante.
Varias herramientas actuales hacen más fácil ejecutar la filosofía original:
- Nullable Reference Types;
- records y record structs;
- init-only properties;
- required members;
- pattern matching;
- switch expressions;
- mejores analyzers;
- APIs modernas para HTTP y persistencia.
Microsoft describe los records como tipos con igualdad por valor generada por el compilador, mientras que Nullable Reference Types permiten expresar intención de nullabilidad y obtener diagnósticos del compilador.
Es decir: hoy el lenguaje puede asumir una porción mayor del trabajo defensivo que antes recaía en convenciones manuales.
Qué no convertiría en dogma
El curso utiliza formulaciones muy fuertes. Son útiles para enseñar, pero no deberían convertirse en leyes universales.
“No exceptions”
Una distinción más práctica es:
resultado esperado del dominio
|
v
Result / Either / union
fallo excepcional, bug o infraestructura
|
v
Exception
“Tarjeta rechazada” puede ser un resultado esperado.
“Un stream se corrompió internamente de una forma que viola nuestras invariantes” puede seguir siendo excepcional.
“Never null”
Con Nullable Reference Types, null puede ser una representación perfectamente explícita de ausencia cuando la API lo comunica correctamente.
El problema no es la secuencia de cuatro letras n-u-l-l.
El problema es una ausencia implícita o mal modelada.
“Un solo constructor”
A veces una única ruta de construcción simplifica las invariantes. En otros dominios, varias factories con nombres claros hacen el contrato más expresivo.
La regla importante es otra:
Todas las rutas de construcción deben producir objetos válidos.
La parte más avanzada: historial en lugar de mutación destructiva
El módulo sobre rich domain models termina acercándose a historical modeling y event sourcing.
En lugar de considerar que el estado actual es la única verdad:
State = D
podemos verlo como la consecuencia de hechos:
E1 -> E2 -> E3 -> E4
|
v
State D
Esta idea es potente porque los hechos pueden ser append-only.
Pero no adoptaría Event Sourcing solamente para eliminar defensive code. Introduce costes reales: almacenamiento, versionado de eventos, proyecciones, replay, observabilidad y operaciones.
Lo que sí rescataría casi siempre es la intuición de monotonicidad:
si una operación representa un hecho irreversible del dominio, modelarla como algo que “ocurrió” suele ser más seguro que permitir editar arbitrariamente el estado anterior.
Cómo aplicaría el curso hoy
En un proyecto moderno usaría esta secuencia:
1. Validar en fronteras
HTTP / CLI / queue / DB / external APIs
2. Convertir primitives a tipos del dominio
EmailAddress, OrderId, Money, Percentage...
3. Construir entidades solo desde estados consistentes
4. Modelar workflows como estados explícitos
5. Hacer nullable únicamente lo que semánticamente puede faltar
6. Representar resultados esperados explícitamente
7. Reservar exceptions para condiciones realmente excepcionales
8. Probar invariantes, no solo ejemplos
El último punto es particularmente importante.
Estas ideas combinan muy bien con property-based testing.
Si decimos:
Todo Grade válido tiene Value entre 0 y 4
esa es una propiedad.
Si decimos:
Después de crear OrderId, nunca es vacío
también.
Si decimos:
Una transición Completed -> Processing no existe
otra propiedad.
El defensive design es mucho más fuerte cuando sus invariantes pueden ejecutarse como tests.
Una heurística para code review
Cuando aparezca un guard clause, no lo elimines automáticamente.
Pregúntate:
- ¿Estoy en una frontera externa?
- ¿La condición representa una invariante del dominio?
- ¿Podría un tipo representar esa invariante?
- ¿Podría la construcción garantizarla una sola vez?
- ¿Puede el objeto violarla posteriormente?
- ¿Existe alguna ruta alternativa de construcción que la salte?
Si las respuestas apuntan al modelo, arreglar el diseño suele tener más valor que agregar otro if.
Conclusión
Advanced Defensive Programming Techniques sigue siendo valioso porque enseña a cambiar la pregunta.
En vez de preguntar:
¿Dónde debo añadir otra comprobación?
pregunta:
¿Por qué este estado inválido puede representarse aquí?
Ese giro conduce naturalmente a value objects, constructores que establecen invariantes, state machines, optionality explícita y resultados tipados.
El código del curso necesita modernización y contiene algunos fallos que no conviene copiar. Pero eso no invalida su tesis; de hecho, la refuerza.
Diseñar defensivamente no significa esconder los errores. Significa reducir sistemáticamente la cantidad de estados incorrectos que el programa es capaz de construir.
Y en 2026 tenemos un C# considerablemente mejor equipado para hacerlo.
Referencias
- Zoran Horvat, Advanced Defensive Programming Techniques — Pluralsight.
- Zoran Horvat, Substituting the Builder with the Sequence of Factory Methods.
- Microsoft Learn, Nullable reference types.
- Microsoft Learn, C# record types.
- Capital de Tokens, Defensive design en código real: tres ideas de Zoran Horvat dentro de OpenAI Codex.