Hay una frase de diseño de software que parece abstracta hasta que un sistema empieza a tener reintentos, fallos parciales y concurrencia:
Haz que los estados inválidos sean imposibles de representar.
La idea es especialmente útil en órdenes, pagos, pipelines, workers y agentes. En todos ellos aparece la tentación de describir la ejecución con una colección de banderas:
bool Started;
bool Finished;
bool Failed;
bool Retrying;
A primera vista parece simple. El problema es que cuatro booleanos no representan cuatro estados: representan 16 combinaciones posibles.
La mayoría probablemente no tiene significado.
El problema real no son los bools: es el espacio de estados
Con cuatro variables binarias tenemos:
2 × 2 × 2 × 2 = 16
Algunas combinaciones parecen razonables:
Started=true
Finished=false
Failed=false
Retrying=false
Podríamos interpretarla como “ejecutándose”.
Pero también podemos representar esto:
Started=false
Finished=true
Failed=false
Retrying=true
¿Qué significa una tarea que nunca empezó, terminó y al mismo tiempo está reintentando?
O esto:
Started=true
Finished=true
Failed=true
Retrying=true
El compilador no ve ningún problema. Cada campo contiene un valor válido para su tipo.
El problema aparece en un nivel superior: la combinación no es válida para el dominio.
Ese es el olor arquitectónico. El modelo permite crear situaciones que luego debemos perseguir con validaciones.
Primera mejora: un enum
Si una ejecución solo necesita saber en qué estado está, un enum ya elimina casi todo el problema:
public enum ExecutionStatus
{
Pending,
Running,
Retrying,
Succeeded,
Failed
}
public sealed class Execution
{
public ExecutionStatus Status { get; private set; }
= ExecutionStatus.Pending;
}
Ahora una instancia no puede estar simultáneamente en Succeeded y Retrying.
El número de alternativas del dominio coincide mucho mejor con el número de valores representables.
Eso ya es una gran mejora.
Pero en sistemas reales los estados suelen necesitar datos diferentes.
Cuando el enum empieza a quedarse corto
Supongamos que necesitamos:
Running: número de intento y hora de inicio.Retrying: próximo intento, instante del retry y último error.Succeeded: hora de finalización y resultado.Failed: hora de finalización y error definitivo.
Una implementación centrada en un enum suele acabar así:
public sealed class Execution
{
public ExecutionStatus Status { get; set; }
public int? Attempt { get; set; }
public DateTimeOffset? StartedAt { get; set; }
public int? NextAttempt { get; set; }
public DateTimeOffset? RetryAt { get; set; }
public DateTimeOffset? FinishedAt { get; set; }
public string? Error { get; set; }
public string? Result { get; set; }
}
Hemos eliminado los cuatro bools, pero introdujimos otra bolsa de estados implícitos.
Ahora existen reglas invisibles:
si Status == Running:
Attempt != null
StartedAt != null
RetryAt == null
FinishedAt == null
si Status == Retrying:
NextAttempt != null
RetryAt != null
Error != null
si Status == Succeeded:
FinishedAt != null
Result != null
Error == null
El tipo no expresa esas reglas. El programador tiene que recordarlas.
Segundo paso: cada alternativa es un tipo
Una técnica muy práctica en C# estable es modelar cada estado con un record diferente:
public abstract record ExecutionState;
public sealed record Pending
: ExecutionState;
public sealed record Running(
int Attempt,
DateTimeOffset StartedAt)
: ExecutionState;
public sealed record Retrying(
int NextAttempt,
DateTimeOffset RetryAt,
string LastError)
: ExecutionState;
public sealed record Succeeded(
DateTimeOffset FinishedAt,
string Result)
: ExecutionState;
public sealed record Failed(
DateTimeOffset FinishedAt,
string Error)
: ExecutionState;
Fíjate en lo que desapareció:
DateTimeOffset? RetryAt;
string? Error;
string? Result;
No necesitamos propiedades globales opcionales.
RetryAt solo existe donde tiene sentido: en Retrying.
Result solo existe en Succeeded.
El propio modelo empieza a documentar el dominio.
Ejemplo completo: una ejecución con retries
Vamos a encapsular también las transiciones.
public sealed class JobExecution
{
public ExecutionState State { get; private set; }
= new Pending();
public void Start(DateTimeOffset now)
{
if (State is not Pending)
{
throw new InvalidOperationException(
$"Cannot start from {State.GetType().Name}.");
}
State = new Running(
Attempt: 1,
StartedAt: now);
}
public void MarkAttemptFailed(
string error,
int maxAttempts,
TimeSpan retryDelay,
DateTimeOffset now)
{
if (State is not Running running)
{
throw new InvalidOperationException(
$"Cannot fail an attempt from {State.GetType().Name}.");
}
if (maxAttempts < 1)
throw new ArgumentOutOfRangeException(nameof(maxAttempts));
if (retryDelay < TimeSpan.Zero)
throw new ArgumentOutOfRangeException(nameof(retryDelay));
if (running.Attempt < maxAttempts)
{
State = new Retrying(
NextAttempt: running.Attempt + 1,
RetryAt: now.Add(retryDelay),
LastError: error);
return;
}
State = new Failed(
FinishedAt: now,
Error: error);
}
public void ResumeRetry(DateTimeOffset now)
{
if (State is not Retrying retry)
{
throw new InvalidOperationException(
$"Cannot resume retry from {State.GetType().Name}.");
}
if (now < retry.RetryAt)
{
throw new InvalidOperationException(
$"Retry cannot start before {retry.RetryAt}.");
}
State = new Running(
Attempt: retry.NextAttempt,
StartedAt: now);
}
public void Complete(
string result,
DateTimeOffset now)
{
if (State is not Running)
{
throw new InvalidOperationException(
$"Cannot complete from {State.GetType().Name}.");
}
State = new Succeeded(
FinishedAt: now,
Result: result);
}
}
Este ejemplo asume una sola fuente de mutación por instancia de JobExecution. Si varios hilos o consumidores pueden invocar transiciones sobre la misma instancia en memoria, el llamador debe serializar esas operaciones —por ejemplo, Complete y MarkAttemptFailed— o proteger el cambio de estado con sincronización adecuada.
El grafo permitido queda bastante claro:
Pending
|
v
Running ---------> Succeeded
|
+-------------> Failed
|
v
Retrying
|
v
Running
Y lo importante es que no existe una transición pública como:
Succeeded -> Retrying
ni:
Failed -> Succeeded
salvo que el dominio decida explícitamente soportarla.
Ejecutemos el ejemplo
var job = new JobExecution();
var t0 =
DateTimeOffset.Parse("2026-09-23T18:00:00-04:00");
job.Start(t0);
job.MarkAttemptFailed(
error: "YouTube request timed out",
maxAttempts: 3,
retryDelay: TimeSpan.FromSeconds(30),
now: t0.AddSeconds(5));
job.ResumeRetry(
t0.AddSeconds(35));
job.Complete(
result: "Transcript ready",
now: t0.AddSeconds(50));
Podemos describir cualquier estado con pattern matching:
static string Describe(ExecutionState state) =>
state switch
{
Pending =>
"Pending",
Running r =>
$"Running attempt {r.Attempt}",
Retrying r =>
$"Retry {r.NextAttempt} at {r.RetryAt:T}: {r.LastError}",
Succeeded s =>
$"Succeeded at {s.FinishedAt:T}: {s.Result}",
Failed f =>
$"Failed at {f.FinishedAt:T}: {f.Error}",
_ =>
throw new ArgumentOutOfRangeException(nameof(state))
};
C# usa pattern matching en is, switch statements y switch expressions. Los records, además, encajan bien con este enfoque porque pueden transportar los datos propios de cada caso.
El matiz importante: la jerarquía clásica no está realmente cerrada
En C# estable, la clase base:
public abstract record ExecutionState;
no le dice al compilador que esos cinco records son todas las alternativas posibles.
Por eso normalmente acabamos con:
_ => throw ...
La jerarquía es conceptualmente cerrada por nuestra arquitectura, pero el compilador no puede asumirlo.
Y aquí aparece una novedad importante de 2026.
C# 15 trae union types
En septiembre de 2026, C# 15 está disponible como preview con .NET 11 preview. Entre sus novedades están los union types y las closed hierarchies.
Con union types podemos declarar que un valor es exactamente uno de un conjunto fijo de tipos:
public record class Pending;
public record class Running(
int Attempt,
DateTimeOffset StartedAt);
public record class Retrying(
int NextAttempt,
DateTimeOffset RetryAt,
string LastError);
public record class Succeeded(
DateTimeOffset FinishedAt,
string Result);
public record class Failed(
DateTimeOffset FinishedAt,
string Error);
public union ExecutionState(
Pending,
Running,
Retrying,
Succeeded,
Failed);
Ahora el conjunto de alternativas está declarado de forma explícita.
El compilador puede comprobar exhaustividad:
static string Describe(ExecutionState state) =>
state switch
{
Pending =>
"Pending",
Running r =>
$"Running attempt {r.Attempt}",
Retrying r =>
$"Retry {r.NextAttempt} at {r.RetryAt:T}",
Succeeded s =>
$"Succeeded: {s.Result}",
Failed f =>
$"Failed: {f.Error}"
};
No necesitamos un caso comodín únicamente para convencer al compilador.
Si mañana añadimos:
public record class Cancelled(string Reason);
y lo incorporamos a la union, los lugares que no lo manejen pueden quedar señalados por el compilador.
Esto cambia mucho la ergonomía de modelar estados en C#.
Otra opción de C# 15: closed hierarchies
Si preferimos herencia, C# 15 también introduce closed:
public closed record class ExecutionState;
public record class Pending
: ExecutionState;
public record class Running(int Attempt)
: ExecutionState;
public record class Retrying(int NextAttempt)
: ExecutionState;
public record class Succeeded(string Result)
: ExecutionState;
public record class Failed(string Error)
: ExecutionState;
Una jerarquía closed fija el conjunto de descendientes directos que el compilador considera para exhaustividad.
Así podemos conservar la forma orientada a objetos y, al mismo tiempo, obtener un conjunto cerrado de alternativas.
Estas características siguen siendo preview en C# 15, por lo que para producción conviene valorar el nivel de estabilidad que exige el proyecto. El patrón con records y una base abstracta sigue siendo útil hoy, aunque no tenga exhaustividad completa en compilación.
La diferencia matemática: producto vs suma
Hay otra forma de entender el problema.
Cuatro booleanos forman conceptualmente un producto:
Execution =
Started
× Finished
× Failed
× Retrying
Cada campo puede variar independientemente, por eso aparecen todas las combinaciones.
Lo que el dominio realmente quería era una suma de alternativas:
ExecutionState =
Pending
| Running(attempt, startedAt)
| Retrying(nextAttempt, retryAt, error)
| Succeeded(finishedAt, result)
| Failed(finishedAt, error)
Ese símbolo | se puede leer como “o”.
La ejecución es Pending o Running o Retrying o Succeeded o Failed.
No varias de ellas a la vez.
Ese es el origen del nombre sum type.
Esto importa todavía más en sistemas distribuidos
En un programa pequeño, un estado incoherente puede producir una excepción.
En un sistema con workers puede producir mucho más:
- un job procesado dos veces;
- un lease renovado después de completarse;
- un retry programado para una ejecución terminal;
- una orden cobrada y marcada también como cancelada;
- un agente que sigue ejecutando tools después de entrar en estado de error;
- un workflow que dispara dos ramas incompatibles.
Cuanto más distribuido es el sistema, más caro resulta depender de invariantes informales.
Un worker debería poder preguntar:
if (job.State is Retrying retry)
{
Schedule(retry.RetryAt);
}
sin necesitar además:
if (!job.Finished &&
!job.Failed &&
job.Started &&
job.RetryAt is not null &&
...)
La segunda versión obliga a reconstruir el significado del dominio cada vez.
Y en una API, el contrato también mejora
La versión basada en flags suele terminar produciendo JSON como:
{
"started": true,
"finished": false,
"failed": false,
"retrying": true,
"attempt": null,
"retryAt": "2026-09-23T22:00:35Z",
"error": "timeout",
"result": null
}
El cliente debe conocer qué combinaciones son legales.
Un contrato por alternativas es más claro:
{
"state": "retrying",
"nextAttempt": 2,
"retryAt": "2026-09-23T22:00:35Z",
"lastError": "timeout"
}
y cuando termina:
{
"state": "succeeded",
"finishedAt": "2026-09-23T22:00:50Z",
"result": {
"transcript": "..."
}
}
El payload cambia con el caso, porque los datos válidos cambian con el caso.
No todo necesita una state machine sofisticada
La regla práctica puede ser bastante simple.
Usa un bool cuando realmente tienes una propiedad independiente de sí/no:
bool IsArchived;
Usa un enum cuando existe exactamente un estado de un conjunto pequeño y todos necesitan prácticamente los mismos datos:
OrderStatus.Pending
OrderStatus.Paid
OrderStatus.Shipped
Usa alternativas tipadas cuando cada estado tiene datos o reglas propias:
Running(attempt, startedAt)
Retrying(retryAt, error)
Succeeded(result)
Failed(error)
Y considera una máquina de estados explícita cuando además necesitas controlar rigurosamente qué transiciones son legales.
La idea de fondo
El objetivo no es eliminar todos los bool.
Es trasladar reglas desde comentarios, convenciones y if dispersos hacia la estructura del programa.
En vez de preguntar:
“¿Qué combinación de flags significa que estoy reintentando?”
queremos que el código pueda decir:
State is Retrying
En vez de preguntar:
“¿Puede
RetryAtser null aquí?”
queremos que RetryAt exista únicamente dentro de Retrying.
Y en vez de detectar una combinación absurda en producción, queremos que sea difícil —o directamente imposible— construirla.
Ese cambio parece pequeño, pero es una de las mejoras de modelado que más valor devuelve cuando un workflow deja de ser trivial.