There is a software-design phrase that can sound abstract until a system starts accumulating retries, partial failures, and concurrency:

Make invalid states impossible to represent.

The idea is especially useful for orders, payments, pipelines, workers, and agents. All of them invite the same tempting model: describe execution with a collection of flags.

bool Started;
bool Finished;
bool Failed;
bool Retrying;

It looks simple. The problem is that four booleans do not represent four states. They represent 16 possible combinations.

Most of them probably mean nothing.

The real problem is not bools: it is the state space

With four binary variables we get:

2 × 2 × 2 × 2 = 16

Some combinations look reasonable:

Started=true
Finished=false
Failed=false
Retrying=false

We can interpret that as “running.”

But we can also represent:

Started=false
Finished=true
Failed=false
Retrying=true

What is a job that never started, has finished, and is retrying at the same time?

Or:

Started=true
Finished=true
Failed=true
Retrying=true

The compiler sees no problem. Every field contains a valid value for its own type.

The bug exists at a higher level: the combination is invalid for the domain.

That is the architectural smell. The model allows impossible situations and forces us to chase them with validation later.

First improvement: an enum

If an execution only needs to know which state it is in, an enum already removes most of the problem:

public enum ExecutionStatus
{
    Pending,
    Running,
    Retrying,
    Succeeded,
    Failed
}

public sealed class Execution
{
    public ExecutionStatus Status { get; private set; }
        = ExecutionStatus.Pending;
}

Now an instance cannot be Succeeded and Retrying at the same time.

The number of representable alternatives is much closer to the actual domain.

That is already a major improvement.

But real systems usually need different data for different states.

When the enum starts to run out of room

Suppose we need:

  • Running: attempt number and start time.
  • Retrying: next attempt, retry instant, and last error.
  • Succeeded: finish time and result.
  • Failed: finish time and final error.

An enum-centered implementation often drifts into this:

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; }
}

We removed the four booleans, but introduced another bag of implicit states.

Now there are invisible rules:

if Status == Running:
    Attempt != null
    StartedAt != null
    RetryAt == null
    FinishedAt == null

if Status == Retrying:
    NextAttempt != null
    RetryAt != null
    Error != null

if Status == Succeeded:
    FinishedAt != null
    Result != null
    Error == null

The type does not express those rules. The programmer must remember them.

Second step: make every alternative a type

A practical technique in stable C# is to model each state with a different record:

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;

Notice what disappeared:

DateTimeOffset? RetryAt;
string? Error;
string? Result;

We no longer need global optional properties.

RetryAt only exists where it makes sense: inside Retrying.

Result only exists inside Succeeded.

The model itself starts documenting the domain.

Complete example: an execution with retries

Now let us encapsulate the transitions too.

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);
    }
}

This example assumes a single source of mutation for each JobExecution instance. If multiple threads or consumers can invoke transitions on the same in-memory instance, the caller must serialize those operations —for example, Complete and MarkAttemptFailed— or protect state changes with appropriate synchronization.

The allowed graph is now easy to see:

Pending
   |
   v
Running ---------> Succeeded
   |
   +-------------> Failed
   |
   v
Retrying
   |
   v
Running

What matters is that there is no public transition such as:

Succeeded -> Retrying

or:

Failed -> Succeeded

unless the domain explicitly decides to support one.

Run the example

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));

We can describe any state with 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# supports pattern matching in is, switch statements, and switch expressions. Records fit this model well because each case can carry exactly the data it needs.

The important caveat: the classic hierarchy is not truly closed

In stable C#, this base type:

public abstract record ExecutionState;

does not tell the compiler that those five records are all the possible alternatives.

That is why we usually end with:

_ => throw ...

The hierarchy may be conceptually closed by our architecture, but the compiler cannot assume it.

And this is where a major 2026 language change becomes relevant.

C# 15 brings union types

As of September 2026, C# 15 is available in preview with .NET 11 preview. Two of its notable additions are union types and closed hierarchies.

With union types we can declare that a value is exactly one of a fixed set of types:

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);

The alternatives are now explicitly closed.

The compiler can check exhaustiveness:

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}"
    };

We do not need a catch-all arm only to satisfy the compiler.

If we later add:

public record class Cancelled(string Reason);

and include it in the union, code paths that fail to handle it can be surfaced by the compiler.

That materially improves state modeling ergonomics in C#.

Another C# 15 option: closed hierarchies

If inheritance is a better fit, C# 15 also introduces 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;

A closed hierarchy fixes the set of direct descendants the compiler considers for exhaustiveness.

That lets us keep an object-oriented shape while still getting a closed set of alternatives.

These C# 15 features are still preview, so production teams should weigh the stability requirements of their project. The abstract-record pattern remains useful today even without full compile-time exhaustiveness.

The mathematical difference: product vs sum

There is another useful way to understand the problem.

Four booleans conceptually form a product:

Execution =
    Started
  × Finished
  × Failed
  × Retrying

Every field can vary independently, which creates the full Cartesian set of combinations.

What the domain actually wanted was a sum of alternatives:

ExecutionState =
      Pending
    | Running(attempt, startedAt)
    | Retrying(nextAttempt, retryAt, error)
    | Succeeded(finishedAt, result)
    | Failed(finishedAt, error)

Read the | as “or.”

The execution is Pending or Running or Retrying or Succeeded or Failed.

Not several of them at once.

That is the intuition behind the name sum type.

This matters even more in distributed systems

In a small program, an inconsistent state may cause an exception.

In a worker system it can do much more:

  • process a job twice;
  • renew a lease after completion;
  • schedule a retry for a terminal execution;
  • mark an order as both charged and cancelled;
  • let an agent keep calling tools after entering an error state;
  • trigger two incompatible workflow branches.

The more distributed the system becomes, the more expensive informal invariants become.

A worker should be able to write:

if (job.State is Retrying retry)
{
    Schedule(retry.RetryAt);
}

instead of reconstructing the domain every time:

if (!job.Finished &&
    !job.Failed &&
    job.Started &&
    job.RetryAt is not null &&
    ...)

The second version spreads hidden business rules across the codebase.

API contracts improve too

A flag-based model tends to produce JSON like:

{
  "started": true,
  "finished": false,
  "failed": false,
  "retrying": true,
  "attempt": null,
  "retryAt": "2026-09-23T22:00:35Z",
  "error": "timeout",
  "result": null
}

Every client must know which combinations are legal.

A case-oriented contract is clearer:

{
  "state": "retrying",
  "nextAttempt": 2,
  "retryAt": "2026-09-23T22:00:35Z",
  "lastError": "timeout"
}

and later:

{
  "state": "succeeded",
  "finishedAt": "2026-09-23T22:00:50Z",
  "result": {
    "transcript": "..."
  }
}

The payload changes with the case because the valid data changes with the case.

Not everything needs a sophisticated state machine

The practical rule can stay simple.

Use a bool when you truly have an independent yes/no property:

bool IsArchived;

Use an enum when exactly one value from a small set applies and all states need roughly the same data:

OrderStatus.Pending
OrderStatus.Paid
OrderStatus.Shipped

Use typed alternatives when each state carries different data or rules:

Running(attempt, startedAt)
Retrying(retryAt, error)
Succeeded(result)
Failed(error)

And consider an explicit state machine when you also need strict control over which transitions are legal.

The deeper idea

The goal is not to eliminate every bool.

The goal is to move rules out of comments, conventions, and scattered if statements and into the structure of the program.

Instead of asking:

“Which flag combination means retrying?”

we want the code to say:

State is Retrying

Instead of asking:

“Can RetryAt be null here?”

we want RetryAt to exist only inside Retrying.

And instead of discovering an absurd combination in production, we want it to be difficult — or impossible — to construct in the first place.

That small shift in modeling pays off disproportionately once a workflow stops being trivial.

Sources and further reading