In my earlier article on the HR Leave Approval workflow, a leave request moved through statuses like Pending, Approved and Rejected. A status field is fine while the rules are simple. But what happens when HR says: “Approved leave can be cancelled, but only before it starts. Rejected leave is final. Cancelled leave can never be touched again.”
Now the status is no longer just data. It decides what the object is allowed to do. That is the situation the State pattern is designed for.
In this article, you will learn:
- Why status-driven
if/switch code becomes hard to maintain
- How the State pattern is structured (with a UML-style description)
- How to implement it step by step in C# using .NET 8+
- How to run and verify it in a console app
- When the pattern is worth it, and when it is not
What Is the State Pattern?
The State pattern lets an object change its behavior when its internal state changes. To the caller, it looks as if the object changed its class.
Participants:
| Role |
In our example |
| Context |
LeaveRequest |
| State (interface) |
ILeaveState |
| Concrete States |
PendingState, ApprovedState, RejectedState, CancelledState |
UML-style structure:
┌──────────────────┐ holds ┌─────────────────────┐
│ LeaveRequest │──────────▶ │ «interface» │
│ (Context) │ │ ILeaveState │
│------------------│ │---------------------│
│ - _state │ │ Approve(...) │
│ + Approve() │ │ Reject(...) │
│ + Reject() │ │ Cancel(...) │
│ + Cancel() │ └─────────▲───────────┘
│ ~ TransitionTo() │ │ implements
└──────────────────┘ ┌────────────────┼────────────────┐
PendingState ApprovedState RejectedState / CancelledState
State flow:
Pending ──Approve──▶ Approved ──Cancel (before start)──▶ Cancelled
│
├──Reject───▶ Rejected
└──Cancel───▶ Cancelled
The Problem: Status Checks Everywhere
Here is the typical first version:
csharp
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 Cancel()
{
if (Status == LeaveStatus.Pending)
Status = LeaveStatus.Cancelled;
else if (Status == LeaveStatus.Approved && StartDate > DateOnly.FromDateTime(DateTime.UtcNow))
Status = LeaveStatus.Cancelled;
else
throw new InvalidOperationException($"Cannot cancel a {Status} request.");
}
// Approve() and Reject() repeat the same style of checks
}
Problems with this approach:
- The rules for one state are scattered across many methods.
- Adding a status such as
OnHold means editing every method.
- It is easy to forget a status in one place and create a silent bug.
DateTime.UtcNow makes the rule difficult to unit test.
Step 1: Define the State Contract
csharp
public interface ILeaveState
{
string Name { get; }
void Approve(LeaveRequest request, string approver);
void Reject(LeaveRequest request, string approver, string reason);
void Cancel(LeaveRequest request);
}
Step 2: Create a Base Class with Safe Defaults
Most actions are illegal in most states. Instead of repeating throw everywhere, the base class rejects everything by default, and each state overrides only what it allows.
csharp
public abstract class LeaveStateBase : ILeaveState
{
public abstract string Name { get; }
public virtual void Approve(LeaveRequest request, string approver)
=> throw NotAllowed("approve");
public virtual void Reject(LeaveRequest request, string approver, string reason)
=> throw NotAllowed("reject");
public virtual void Cancel(LeaveRequest request)
=> throw NotAllowed("cancel");
private InvalidOperationException NotAllowed(string action)
=> new($"You cannot {action} a leave request that is {Name}.");
}
Step 3: Implement the Concrete States
csharp
public sealed class PendingState : LeaveStateBase
{
public override string Name => "Pending";
public override void Approve(LeaveRequest request, string approver)
=> request.TransitionTo(new ApprovedState(), $"Approved by {approver}");
public override void Reject(LeaveRequest request, string approver, string reason)
=> request.TransitionTo(new RejectedState(), $"Rejected by {approver}. Reason: {reason}");
public override void Cancel(LeaveRequest request)
=> request.TransitionTo(new CancelledState(), "Cancelled by employee");
}
public sealed class ApprovedState : LeaveStateBase
{
public override string Name => "Approved";
public override void Cancel(LeaveRequest request)
{
if (request.StartDate <= request.Today)
throw new InvalidOperationException(
"Leave has already started and can no longer be cancelled.");
request.TransitionTo(new CancelledState(), "Cancelled after approval");
}
}
public sealed class RejectedState : LeaveStateBase
{
public override string Name => "Rejected"; // final: nothing is allowed
}
public sealed class CancelledState : LeaveStateBase
{
public override string Name => "Cancelled"; // final: nothing is allowed
}
Notice how readable the rules are. RejectedState and CancelledState are empty because they allow nothing, and that is exactly what the business rule says.
Step 4: Build the Context
csharp
public class LeaveRequest
{
private readonly TimeProvider _clock;
private readonly List<string> _history = new();
private ILeaveState _state = new PendingState();
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 actions simply delegate to the current state
public void Approve(string approver) => _state.Approve(this, approver);
public void Reject(string approver, string reason) => _state.Reject(this, approver, reason);
public void Cancel() => _state.Cancel(this);
// Called by the states
internal void TransitionTo(ILeaveState next, string note)
{
_state = next;
_history.Add($"{_clock.GetUtcNow():u} - {note}");
}
}
Two design points worth highlighting:
- The context has no
if or switch on status. It only delegates.
- Time comes from
TimeProvider (built into .NET 8+), so the “cancel before start” rule is easy to test.
Step 5: Run It in a Console App
A small fixed clock keeps the output predictable:
csharp
public sealed class FixedClock(DateTimeOffset now) : TimeProvider
{
public override DateTimeOffset GetUtcNow() => now;
}
csharp
var clock = new FixedClock(new DateTimeOffset(2026, 11, 11, 9, 0, 0, TimeSpan.Zero));
var request = new LeaveRequest(new DateOnly(2026, 11, 20), new DateOnly(2026, 11, 22), clock);
Console.WriteLine($"Status: {request.Status}");
request.Approve("Priya (Manager)");
Console.WriteLine($"Status: {request.Status}");
try
{
request.Approve("HR"); // already approved
}
catch (InvalidOperationException ex)
{
Console.WriteLine($"Blocked: {ex.Message}");
}
request.Cancel(); // allowed: leave starts on 20 Nov
Console.WriteLine($"Status: {request.Status}");
try
{
request.Reject("HR", "Too late"); // cancelled is final
}
catch (InvalidOperationException ex)
{
Console.WriteLine($"Blocked: {ex.Message}");
}
Console.WriteLine("History:");
foreach (var entry in request.History)
Console.WriteLine($" {entry}");
Output:
Status: Pending
Status: Approved
Blocked: You cannot approve a leave request that is Approved.
Status: Cancelled
Blocked: You cannot reject a leave request that is Cancelled.
History:
2026-11-11 09:00:00Z - Approved by Priya (Manager)
2026-11-11 09:00:00Z - Cancelled after approval
Adding a New State: The Real Payoff
Suppose HR introduces OnHold. With the old approach, you edit every method. With State:
- Add
OnHoldState and override only the actions it allows.
- Add one
Hold() entry to the interface/context if needed.
- Make
PendingState transition to it.
Existing states (Approved, Rejected, Cancelled) stay untouched, which respects the Open/Closed Principle.
Persistence Tip (EF Core)
EF Core cannot map state objects directly. Store the state name and rebuild the object when loading:
csharp
internal static ILeaveState FromName(string name) => name switch
{
"Pending" => new PendingState(),
"Approved" => new ApprovedState(),
"Rejected" => new RejectedState(),
"Cancelled" => new CancelledState(),
_ => throw new ArgumentException($"Unknown state '{name}'.")
};
Is It Really the State Pattern?
Since accuracy matters, here is a quick checklist:
- Behavior changes depending on internal state ✔
- A context delegates to a state object ✔
- The states themselves trigger transitions ✔
- The caller does not choose the behavior ✔
That last point separates State from Strategy. Their class diagrams look almost the same, but in Strategy the client chooses the algorithm, while in State the object moves itself between behaviors.
Advantages
- Rules for each state live in one place
- No repeated
if/switch blocks
- Easy to add new states
- Individual states are simple to unit test
Disadvantages
- More classes for a small problem
- Transitions are spread across state classes, so the full flow is harder to see at a glance
- Needs extra work for persistence
When Not to Use It
- You have two or three statuses with trivial rules. An enum is clearer.
- States differ only in data, not behavior.
- Workflows are configured by admins at runtime. Use a workflow engine or a transition table instead.
Conclusion
The State pattern is not about avoiding switch statements for their own sake. It is about putting each state’s rules where they belong. For the leave request, the lifecycle is now four small, readable classes, and the business rules read almost like the HR policy document itself.
In the next article, we will look at the Strategy pattern, which looks structurally similar but solves a different problem: calculating leave accrual for different leave types.
Join the conversation! Your thoughts help the community grow.