The Problem
Every HR system has a leave request, and every leave request has a lifecycle:
Pending → Approved / Rejected → (maybe) Cancelled
The usual first implementation is an enum plus switch statements. It works, until the rules grow.
The "Before" Code
public enum LeaveStatus { Pending, Approved, Rejected, Cancelled } public class LeaveRequest { public LeaveStatus Status { get; private set; } = LeaveStatus.Pending; public DateOnly StartDate { get; init; } public void Approve(string approver) { switch (Status) { case LeaveStatus.Pending: Status = LeaveStatus.Approved; break; case LeaveStatus.Approved: throw new InvalidOperationException("Already approved."); case LeaveStatus.Rejected: throw new InvalidOperationException("Rejected requests cannot be approved."); case LeaveStatus.Cancelled: throw new InvalidOperationException("Cancelled requests cannot be approved."); } } public void Cancel() { switch (Status) { case LeaveStatus.Pending: Status = LeaveStatus.Cancelled; break; case LeaveStatus.Approved when StartDate > DateOnly.FromDateTime(DateTime.UtcNow): Status = LeaveStatus.Cancelled; break; default: throw new InvalidOperationException($"Cannot cancel a {Status} request."); } } // Reject() has the same switch again... } ``` ### What's wrong here? - Every new action (`Reject`, `Withdraw`, `Escalate`) repeats the same `switch`. - Every new status (`OnHold`, `PartiallyApproved`) forces you to edit **every** method. - The rule "what can happen in which state" is scattered across the class. - `DateTime.UtcNow` makes the cancel rule hard to test. ## The Refactor: State Pattern The idea: **each state is an object that knows which transitions are legal from it.** The request delegates to its current state. ### Step 1: The state base class `csharp public abstract class LeaveState { public abstract string Name { get; } // Illegal by default. Each state overrides only what it allows. public virtual LeaveState Approve(LeaveRequest request, string approver) => throw Invalid(nameof(Approve)); public virtual LeaveState Reject(LeaveRequest request, string approver, string reason) => throw Invalid(nameof(Reject)); public virtual LeaveState Cancel(LeaveRequest request) => throw Invalid(nameof(Cancel)); protected InvalidOperationException Invalid(string action) => new($"Cannot {action} a leave request that is {Name}."); } ` A base class with throwing defaults beats an interface here: concrete states only write the transitions they actually allow. ### Step 2: Concrete states csharp `public sealed class PendingState : LeaveState { public static readonly PendingState Instance = new(); private PendingState() { } public override string Name => "Pending"; public override LeaveState Approve(LeaveRequest request, string approver) { request.Record($"Approved by {approver}"); return ApprovedState.Instance; } public override LeaveState Reject(LeaveRequest request, string approver, string reason) { request.Record($"Rejected by {approver}: {reason}"); return RejectedState.Instance; } public override LeaveState Cancel(LeaveRequest request) { request.Record("Cancelled by employee"); return CancelledState.Instance; } } public sealed class ApprovedState : LeaveState { public static readonly ApprovedState Instance = new(); private ApprovedState() { } public override string Name => "Approved"; // An approved request can only be cancelled before it starts public override LeaveState Cancel(LeaveRequest request) { if (request.StartDate <= request.Today) throw new InvalidOperationException("Leave that has already started cannot be cancelled."); request.Record("Cancelled after approval"); return CancelledState.Instance; } } public sealed class RejectedState : LeaveState { public static readonly RejectedState Instance = new(); private RejectedState() { } public override string Name => "Rejected"; } public sealed class CancelledState : LeaveState { public static readonly CancelledState Instance = new(); private CancelledState() { } public override string Name => "Cancelled"; }` The states hold no data, so one shared instance each is safe. ### Step 3: The context csharp `public class LeaveRequest { private readonly TimeProvider _clock; private readonly List<string> _history = []; private LeaveState _state = PendingState.Instance; public LeaveRequest(DateOnly startDate, DateOnly endDate, TimeProvider? clock = null) { StartDate = startDate; EndDate = endDate; _clock = clock ?? TimeProvider.System; } public DateOnly StartDate { get; } public DateOnly EndDate { get; } public string Status => _state.Name; public IReadOnlyList<string> History => _history; public DateOnly Today => DateOnly.FromDateTime(_clock.GetUtcNow().UtcDateTime); public void Approve(string approver) => _state = _state.Approve(this, approver); public void Reject(string approver, string reason) => _state = _state.Reject(this, approver, reason); public void Cancel() => _state = _state.Cancel(this); internal void Record(string entry) => _history.Add($"{_clock.GetUtcNow():u} - {entry}"); }` Adding a new action means one method on the context and one virtual on the base. The existing `switch` blocks are gone. ## Testing the Rules csharp `[Fact] public void Approved_request_cannot_be_cancelled_once_leave_has_started() { var clock = new FakeTimeProvider(DateTimeOffset.Parse("2026-11-10")); var request = new LeaveRequest(new DateOnly(2026, 11, 9), new DateOnly(2026, 11, 12), clock); request.Approve("manager"); Assert.Throws<InvalidOperationException>(() => request.Cancel()); } [Fact] public void Rejected_request_cannot_be_approved() { var request = new LeaveRequest(new DateOnly(2026, 12, 1), new DateOnly(2026, 12, 3)); request.Reject("manager", "Team deadline"); Assert.Throws<InvalidOperationException>(() => request.Approve("hr")); }` (`FakeTimeProvider` comes from `Microsoft.Extensions.TimeProvider.Testing`.) ## Is This Really the State Pattern? I like to check before I name a pattern: | Check | Our code | |---|---| | Does behavior change based on internal state? | Yes, `Cancel()` does different things in different states | | Is there a context that delegates to a state object? | Yes, `LeaveRequest` | | Do state objects decide the next state? | Yes, each method returns the next state | | Is it a different algorithm chosen by the caller? | No. The caller never picks the state, so it's not Strategy | That last row matters. **State and Strategy have nearly identical class diagrams.** The difference is who decides: in Strategy the client picks the behavior, while in State the object transitions itself. I'll cover Strategy next. ## A Practical Note: Persistence EF Core can't store `LeaveState` objects directly. Persist the name (or an enum) and rehydrate: csharp `internal static LeaveState FromName(string name) => name switch { "Pending" => PendingState.Instance, "Approved" => ApprovedState.Instance, "Rejected" => RejectedState.Instance, "Cancelled" => CancelledState.Instance, _ => throw new ArgumentException($"Unknown state '{name}'.") };` ## When NOT to Use It - **Two or three states, one or two transitions.** An enum and a small `switch` is simpler and more readable. - **States that differ only by data**, not behavior. A status column is enough. - **Workflows that change at runtime** (admin-configurable approval flows). Reach for a workflow engine or a transition table instead. - **A transition table may be enough.** A `Dictionary<(LeaveStatus, Action), LeaveStatus>` gives you the same "what's legal" overview with less ceremony. ## Conclusion The State pattern earns its place when behavior depends on state and the rules keep growing. For our leave request, the lifecycle now lives in four small classes instead of repeated `switch` blocks. Adding `OnHold` means adding one class, not editing every method. Next up: **Strategy**, which looks almost identical but solves a different problem (leave accrual rules per leave type).