Back to Blog
design-patternscsharptypescriptbehavioral-patterns

State Pattern — FixIt Pro Series #19

Draft → Assigned → InProgress → PendingReview → Closed. Learn how the State pattern makes each state responsible for its own valid transitions, and kills the giant switch statement, in C# and TypeScript.

Series: Design Patterns with FixIt Pro  ·  Episode 19 / 22  ·  Behavioral Pattern
Previous: #18 — Observer Pattern


The Scenario

A FixIt Pro job card moves through a defined lifecycle:

Draft → Assigned → InProgress → PendingReview → Closed
                        ↓
                     Paused → InProgress

Not every transition is valid. You can't complete a job that hasn't started. You can't assign a handyman to a closed job. You can't pause a job that's still in draft.

The naive implementation puts every rule in one method:

public void Transition(string action)
{
    if (_status == "Draft" && action == "assign")
        _status = "Assigned";
    else if (_status == "Assigned" && action == "start")
        _status = "InProgress";
    else if (_status == "InProgress" && action == "pause")
        _status = "Paused";
    else if (_status == "InProgress" && action == "complete")
        _status = "PendingReview";
    else if (_status == "Paused" && action == "resume")
        _status = "InProgress";
    else if (_status == "PendingReview" && action == "approve")
        _status = "Closed";
    else
        throw new InvalidOperationException("Invalid transition");
}

Six states. Seven transitions. This conditional already spans 15 lines and it's only going to grow. Add a Cancelled state, a Disputed state, an AwaitingParts state, the method becomes unmaintainable. Worse, the transition rules for a single state are scattered across the whole conditional rather than living together.

The State pattern gives each state its own class, responsible for its own transitions.


What Is the State Pattern?

Allow an object to alter its behaviour when its internal state changes. The object will appear to change its class.

Instead of one object with a status field and a giant conditional, you have a context that delegates to a state object. Each state class knows which transitions are valid from itself, and returns the next state.

The three participants

Role FixIt Pro equivalent
Context JobCard — holds a reference to the current state, delegates to it
State interface IJobState — declares all possible actions
Concrete States DraftState, AssignedState, InProgressState, PausedState, PendingReviewState, ClosedState

C# Implementation

// ── State Interface ────────────────────────────────────────
// Declares every possible action. Each state implements only
// the ones valid from itself; the rest throw or are no-ops.

public interface IJobState
{
    string Name { get; }

    void Assign(JobCard job, string handyman);
    void Start(JobCard job);
    void Pause(JobCard job, string reason);
    void Resume(JobCard job);
    void Complete(JobCard job);
    void Approve(JobCard job);
}

// ── Base State ─────────────────────────────────────────────
// Default behaviour: reject the action. Concrete states
// override only the transitions valid from themselves.

public abstract class BaseJobState : IJobState
{
    public abstract string Name { get; }

    public virtual void Assign(JobCard job, string handyman) => Reject("assign");
    public virtual void Start(JobCard job)                   => Reject("start");
    public virtual void Pause(JobCard job, string reason)     => Reject("pause");
    public virtual void Resume(JobCard job)                   => Reject("resume");
    public virtual void Complete(JobCard job)                 => Reject("complete");
    public virtual void Approve(JobCard job)                  => Reject("approve");

    private void Reject(string action) =>
        throw new InvalidOperationException(
            $"Cannot '{action}' a job in '{Name}' state.");
}

// ── Concrete States ────────────────────────────────────────

public class DraftState : BaseJobState
{
    public override string Name => "Draft";

    public override void Assign(JobCard job, string handyman)
    {
        job.Assignee = handyman;
        job.TransitionTo(new AssignedState());
        Console.WriteLine($"  ✅ Assigned to {handyman}");
    }
}

public class AssignedState : BaseJobState
{
    public override string Name => "Assigned";

    public override void Start(JobCard job)
    {
        job.StartedAt = DateTime.Now;
        job.TransitionTo(new InProgressState());
        Console.WriteLine($"  ▶️  Work started at {job.StartedAt:HH:mm:ss}");
    }

    // Reassignment is allowed while still Assigned
    public override void Assign(JobCard job, string handyman)
    {
        var previous  = job.Assignee;
        job.Assignee  = handyman;
        Console.WriteLine($"  🔄 Reassigned from {previous} to {handyman}");
    }
}

public class InProgressState : BaseJobState
{
    public override string Name => "InProgress";

    public override void Pause(JobCard job, string reason)
    {
        job.PauseReason = reason;
        job.TransitionTo(new PausedState());
        Console.WriteLine($"  ⏸️  Paused — {reason}");
    }

    public override void Complete(JobCard job)
    {
        job.CompletedAt = DateTime.Now;
        job.TransitionTo(new PendingReviewState());
        Console.WriteLine($"  ✔️  Work complete — awaiting review");
    }
}

public class PausedState : BaseJobState
{
    public override string Name => "Paused";

    public override void Resume(JobCard job)
    {
        job.PauseReason = null;
        job.TransitionTo(new InProgressState());
        Console.WriteLine($"  ▶️  Resumed");
    }
}

public class PendingReviewState : BaseJobState
{
    public override string Name => "PendingReview";

    public override void Approve(JobCard job)
    {
        job.ClosedAt = DateTime.Now;
        job.TransitionTo(new ClosedState());
        Console.WriteLine($"  🔒 Approved and closed");
    }

    // Review rejected — send back to InProgress
    public override void Start(JobCard job)
    {
        job.CompletedAt = null;
        job.TransitionTo(new InProgressState());
        Console.WriteLine($"  ↩️  Review rejected — reopened for rework");
    }
}

public class ClosedState : BaseJobState
{
    public override string Name => "Closed";
    // No transitions out of Closed — all actions rejected by base
}

// ── Context — JobCard ──────────────────────────────────────

public class JobCard
{
    public string    JobId       { get; init; } = Guid.NewGuid().ToString()[..8];
    public string    Title       { get; init; } = string.Empty;
    public string    Category    { get; init; } = string.Empty;
    public string?   Assignee    { get; set;  }
    public string?   PauseReason { get; set;  }
    public DateTime? StartedAt   { get; set;  }
    public DateTime? CompletedAt { get; set;  }
    public DateTime? ClosedAt    { get; set;  }

    private IJobState _state;

    public JobCard(string title, string category)
    {
        Title    = title;
        Category = category;
        _state   = new DraftState(); // initial state
    }

    public string CurrentState => _state.Name;

    // Called by state classes to move to the next state
    public void TransitionTo(IJobState newState)
    {
        Console.WriteLine($"  [{_state.Name}] → [{newState.Name}]");
        _state = newState;
    }

    // Delegate all actions to the current state
    public void Assign(string handyman)      => _state.Assign(this, handyman);
    public void Start()                       => _state.Start(this);
    public void Pause(string reason)          => _state.Pause(this, reason);
    public void Resume()                      => _state.Resume(this);
    public void Complete()                    => _state.Complete(this);
    public void Approve()                     => _state.Approve(this);

    public override string ToString() =>
        $"Job #{JobId} '{Title}' [{Category}] — State: {_state.Name}" +
        (Assignee is not null ? $" | {Assignee}" : "");
}

// ── Client Code ────────────────────────────────────────────

class Program
{
    static void Main()
    {
        var job = new JobCard("Replace burst pipe under sink", "Plumbing");

        Console.WriteLine($"=== Initial: {job} ===\n");

        // Valid lifecycle
        job.Assign("James Nghipandua");
        job.Assign("Peter Nangolo");        // reassignment allowed in Assigned
        job.Start();
        job.Pause("Waiting for replacement valve");
        job.Resume();
        job.Complete();
        job.Start();                         // review rejected — back to InProgress
        job.Complete();
        job.Approve();

        Console.WriteLine($"\n=== Final: {job} ===");

        // ── Invalid transitions are rejected by the state ──
        Console.WriteLine("\n=== Attempting Invalid Transitions ===");

        TryAction(() => job.Start(),               "start a Closed job");
        TryAction(() => job.Assign("Anna Nghoshi"), "assign a Closed job");
        TryAction(() => job.Pause("no reason"),     "pause a Closed job");
    }

    static void TryAction(Action action, string description)
    {
        try
        {
            action();
        }
        catch (InvalidOperationException ex)
        {
            Console.WriteLine($"  ❌ Cannot {description}: {ex.Message}");
        }
    }
}

Output:

=== Initial: Job #f3a12b 'Replace burst pipe under sink' [Plumbing] — State: Draft ===

  [Draft] → [Assigned]
  ✅ Assigned to James Nghipandua
  🔄 Reassigned from James Nghipandua to Peter Nangolo
  [Assigned] → [InProgress]
  ▶️  Work started at 09:25:14
  [InProgress] → [Paused]
  ⏸️  Paused — Waiting for replacement valve
  [Paused] → [InProgress]
  ▶️  Resumed
  [InProgress] → [PendingReview]
  ✔️  Work complete — awaiting review
  [PendingReview] → [InProgress]
  ↩️  Review rejected — reopened for rework
  [InProgress] → [PendingReview]
  ✔️  Work complete — awaiting review
  [PendingReview] → [Closed]
  🔒 Approved and closed

=== Final: Job #f3a12b '...' [Plumbing] — State: Closed | Peter Nangolo ===

=== Attempting Invalid Transitions ===
  ❌ Cannot start a Closed job: Cannot 'start' a job in 'Closed' state.
  ❌ Cannot assign a Closed job: Cannot 'assign' a job in 'Closed' state.
  ❌ Cannot pause a Closed job: Cannot 'pause' a job in 'Closed' state.

TypeScript Implementation

// ── State/IJobState.ts ─────────────────────────────────────

import type { JobCard } from "../Context/JobCard";

export interface IJobState {
  readonly name: string;

  assign(job: JobCard, handyman: string): void;
  start(job: JobCard): void;
  pause(job: JobCard, reason: string): void;
  resume(job: JobCard): void;
  complete(job: JobCard): void;
  approve(job: JobCard): void;
}

// ── State/BaseJobState.ts ──────────────────────────────────

import type { IJobState } from "./IJobState";
import type { JobCard   } from "../Context/JobCard";

export abstract class BaseJobState implements IJobState {
  abstract readonly name: string;

  assign(_job: JobCard, _handyman: string): void { this.reject("assign");   }
  start(_job: JobCard): void                     { this.reject("start");    }
  pause(_job: JobCard, _reason: string): void    { this.reject("pause");    }
  resume(_job: JobCard): void                    { this.reject("resume");   }
  complete(_job: JobCard): void                  { this.reject("complete"); }
  approve(_job: JobCard): void                   { this.reject("approve");  }

  private reject(action: string): never {
    throw new Error(`Cannot '${action}' a job in '${this.name}' state.`);
  }
}

// ── State/DraftState.ts ────────────────────────────────────

import { BaseJobState   } from "./BaseJobState";
import { AssignedState  } from "./AssignedState";
import type { JobCard   } from "../Context/JobCard";

export class DraftState extends BaseJobState {
  readonly name = "Draft";

  override assign(job: JobCard, handyman: string): void {
    job.assignee = handyman;
    job.transitionTo(new AssignedState());
    console.log(`  ✅ Assigned to ${handyman}`);
  }
}

// ── State/AssignedState.ts ─────────────────────────────────

import { BaseJobState     } from "./BaseJobState";
import { InProgressState  } from "./InProgressState";
import type { JobCard     } from "../Context/JobCard";

export class AssignedState extends BaseJobState {
  readonly name = "Assigned";

  override start(job: JobCard): void {
    job.startedAt = new Date();
    job.transitionTo(new InProgressState());
    console.log(`  ▶️  Work started at ${job.startedAt.toTimeString().slice(0, 8)}`);
  }

  override assign(job: JobCard, handyman: string): void {
    const previous = job.assignee;
    job.assignee   = handyman;
    console.log(`  🔄 Reassigned from ${previous} to ${handyman}`);
  }
}

// ── State/InProgressState.ts ───────────────────────────────

import { BaseJobState        } from "./BaseJobState";
import { PausedState         } from "./PausedState";
import { PendingReviewState  } from "./PendingReviewState";
import type { JobCard        } from "../Context/JobCard";

export class InProgressState extends BaseJobState {
  readonly name = "InProgress";

  override pause(job: JobCard, reason: string): void {
    job.pauseReason = reason;
    job.transitionTo(new PausedState());
    console.log(`  ⏸️  Paused — ${reason}`);
  }

  override complete(job: JobCard): void {
    job.completedAt = new Date();
    job.transitionTo(new PendingReviewState());
    console.log(`  ✔️  Work complete — awaiting review`);
  }
}

// ── State/PausedState.ts ───────────────────────────────────

import { BaseJobState     } from "./BaseJobState";
import { InProgressState  } from "./InProgressState";
import type { JobCard     } from "../Context/JobCard";

export class PausedState extends BaseJobState {
  readonly name = "Paused";

  override resume(job: JobCard): void {
    job.pauseReason = undefined;
    job.transitionTo(new InProgressState());
    console.log(`  ▶️  Resumed`);
  }
}

// ── State/PendingReviewState.ts ────────────────────────────

import { BaseJobState     } from "./BaseJobState";
import { ClosedState      } from "./ClosedState";
import { InProgressState  } from "./InProgressState";
import type { JobCard     } from "../Context/JobCard";

export class PendingReviewState extends BaseJobState {
  readonly name = "PendingReview";

  override approve(job: JobCard): void {
    job.closedAt = new Date();
    job.transitionTo(new ClosedState());
    console.log(`  🔒 Approved and closed`);
  }

  override start(job: JobCard): void {
    job.completedAt = undefined;
    job.transitionTo(new InProgressState());
    console.log(`  ↩️  Review rejected — reopened for rework`);
  }
}

// ── State/ClosedState.ts ───────────────────────────────────

import { BaseJobState } from "./BaseJobState";

export class ClosedState extends BaseJobState {
  readonly name = "Closed";
  // No transitions out — all actions rejected by base
}

// ── Context/JobCard.ts ─────────────────────────────────────

import type { IJobState } from "../State/IJobState";
import { DraftState     } from "../State/DraftState";

const shortId = () => Math.random().toString(36).slice(2, 10);

export class JobCard {
  readonly jobId: string = shortId();

  assignee?:    string;
  pauseReason?: string;
  startedAt?:   Date;
  completedAt?: Date;
  closedAt?:    Date;

  private state: IJobState;

  constructor(
    readonly title:    string,
    readonly category: string
  ) {
    this.state = new DraftState();
  }

  get currentState(): string { return this.state.name; }

  transitionTo(newState: IJobState): void {
    console.log(`  [${this.state.name}] → [${newState.name}]`);
    this.state = newState;
  }

  // Delegate all actions to the current state
  assign(handyman: string): void { this.state.assign(this, handyman); }
  start():               void    { this.state.start(this);            }
  pause(reason: string): void    { this.state.pause(this, reason);    }
  resume():              void    { this.state.resume(this);           }
  complete():            void    { this.state.complete(this);         }
  approve():             void    { this.state.approve(this);          }

  toString(): string {
    return `Job #${this.jobId} '${this.title}' [${this.category}] — State: ${this.state.name}` +
      (this.assignee ? ` | ${this.assignee}` : "");
  }
}

// ── App.ts ─────────────────────────────────────────────────

import { JobCard } from "./Context/JobCard";

const job = new JobCard("Replace burst pipe under sink", "Plumbing");

console.log(`=== Initial: ${job} ===\n`);

job.assign("James Nghipandua");
job.assign("Peter Nangolo");
job.start();
job.pause("Waiting for replacement valve");
job.resume();
job.complete();
job.start();      // review rejected
job.complete();
job.approve();

console.log(`\n=== Final: ${job} ===`);

console.log("\n=== Attempting Invalid Transitions ===");

const tryAction = (action: () => void, description: string): void => {
  try {
    action();
  } catch (e) {
    console.log(`  ❌ Cannot ${description}: ${(e as Error).message}`);
  }
};

tryAction(() => job.start(),                "start a Closed job");
tryAction(() => job.assign("Anna Nghoshi"), "assign a Closed job");
tryAction(() => job.pause("no reason"),     "pause a Closed job");

C# vs TypeScript — Key Differences

Aspect C# TypeScript
Base state rejection virtual methods calling Reject() Concrete methods calling reject() returning never
Override keyword override required override optional but recommended (TS 4.3+)
Never-returning method void method that throws never return type — TypeScript knows it never returns
Circular imports Not an issue — same assembly Use import type for the context to avoid circular runtime imports
Unused parameters Not flagged Prefix with _ to satisfy noUnusedParameters

The circular import issue is real in TypeScript. JobCard imports DraftState, and every state imports JobCard. Using import type { JobCard } tells TypeScript this is a type-only import that gets erased at compile time, breaking the runtime circular dependency.

TypeScript's never return type is also worth noting, a method returning never tells the compiler it always throws, which improves control flow analysis in calling code.


State vs Strategy — What's the Difference?

They have nearly identical class diagrams. The difference is entirely in intent and behaviour:

State Strategy
Purpose Change behaviour as internal state changes Choose an algorithm from a family
Who decides States decide their own transitions Client chooses the strategy
States know each other Yes — DraftState creates AssignedState No — strategies are independent
Transitions Built into the pattern Not applicable
FixIt Pro Job lifecycle: Draft → Assigned → Closed Pricing: Fixed vs Hourly vs Emergency (Episode 20)

Rule of thumb: if the objects transition into each other, it's State. If the client picks one and it stays picked, it's Strategy.

Episode 20 covers Strategy next week, the comparison will be much clearer with both implementations side by side.


When to Use the State

Use it when:

  • An object's behaviour depends on its state and must change at runtime
  • You have large conditionals that branch on an object's state field
  • State transitions have complex rules that belong together per-state
  • Invalid transitions should be caught explicitly rather than silently ignored

Avoid it when:

  • You have only two or three states with simple transitions, an enum and a switch is clearer
  • States rarely change, then the class overhead isn't justified
  • The state machine is better expressed declaratively and a transition table or a dedicated state machine library may be cleaner

Real-World Takeaway

The State pattern is behind every workflow engine, order management system, and approval process in enterprise software. In .NET, Stateless is a popular state machine library built on this concept. TCP connection handling is a classic State pattern implementation, Listen, Established, Closed, each with different behaviour for the same operations. In frontend, XState in the JavaScript ecosystem formalises state machines for UI flows. Order lifecycles in e-commerce (Pending → Paid → Shipped → Delivered → Refunded) are State pattern implementations.

In FixIt Pro, the giant conditional is gone. Each state class owns its own transition rules. Adding an AwaitingPartsState means writing one new class and updating the states that transition into it and the other states are untouched. Invalid transitions throw immediately with a clear message rather than silently doing nothing. And ClosedState needs no code at all, it inherits the base rejection behaviour for every action.


Repo Structure for This Episode

github.com/antonlungameni/fixit-pro-design-patterns

fixit-pro-design-patterns/
├── csharp/Behavioral/19-State/
│   ├── State/IJobState.cs
│   ├── State/BaseJobState.cs
│   ├── State/DraftState.cs
│   ├── State/AssignedState.cs
│   ├── State/InProgressState.cs
│   ├── State/PausedState.cs
│   ├── State/PendingReviewState.cs
│   ├── State/ClosedState.cs
│   ├── Context/JobCard.cs
│   └── Program.cs
└── typescript/Behavioral/19-State/
    ├── State/IJobState.ts
    ├── State/BaseJobState.ts
    ├── State/DraftState.ts
    ├── State/AssignedState.ts
    ├── State/InProgressState.ts
    ├── State/PausedState.ts
    ├── State/PendingReviewState.ts
    ├── State/ClosedState.ts
    ├── Context/JobCard.ts
    └── App.ts

Previous: #18 — Observer Pattern
Next up: #20 — Strategy Pattern
Swappable pricing strategies — Fixed Rate, Hourly Rate, Emergency Surcharge — chosen at runtime without a single conditional in the billing service.