Replace Type Code with State/Strategy: When the Type Itself Can Change
Learn the Replace Type Code with State/Strategy refactoring with a prepaid-to-postpaid SIM story, swappable plan objects in TypeScript and C#, and the decision guide for Class vs Subclasses vs State/Strategy.
📱 The SIM that changed its mind
Meet Farhan Uncle from Lucknow. He runs a small electronics repair shop near Aminabad, and he has used the same mobile number for twelve years — it is painted on his shop's signboard, printed on his visiting cards, and saved in the phones of five hundred customers. The number can never change. The number is the shop.
For years his SIM was prepaid. The rules of prepaid life: recharge first, talk later. Every call quietly eats the balance. When the balance hits zero, calls stop — but nothing else bad happens. No monthly bill ever comes. Farhan Uncle liked the discipline, but he hated the monthly trip to the recharge shop, and twice a year he would miss a customer's call because the balance had silently died at the wrong moment.
Last month, his daughter Nida, who studies computer science in Delhi, finally convinced him to convert the SIM to postpaid. Same SIM, same number, same phone, same signboard. But the rules of his telecom life flipped completely. Now he talks first and pays later. Calls add to a monthly bill instead of eating a balance. There is a credit limit instead of a recharge. And if the bill is not paid by the due date, the connection gets suspended — a brand-new failure mode that prepaid life never had.
Stop and notice the two facts hiding in this small story, because together they decide today's whole lesson:
- Behaviour varies by plan type. A call on prepaid does one thing (deduct balance); the same call on postpaid does another (grow the bill). Recharge means something on prepaid and nothing on postpaid.
- The plan type changed on the SAME SIM. Farhan Uncle did not take a new number. The very same object — his SIM, his identity, his twelve-year-old number — switched from one set of rules to another, at runtime, in the middle of its life.
Now think like a programmer, the way Nida did on the bus back to Delhi. If we had modelled this with inheritance — a PrepaidSim class and a PostpaidSim class — we would be stuck. An object's class is decided at birth and can never be reassigned. A PrepaidSim object can no more become a PostpaidSim than a mango can become a banana. We would have to destroy Uncle's SIM object and create a new one, carefully copying twelve years of identity across, and praying nothing else in the system still points to the old object. Every reference in five hundred customers' phones, so to speak, would dangle.
The telecom company has a smarter mental model, and it is exactly today's refactoring. The SIM is one fixed thing. The plan is a separate card kept inside the SIM's file — and that card can be pulled out and replaced. Swap the prepaid card for a postpaid card, and the same SIM instantly follows new rules. This is Replace Type Code with State/Strategy: move the varying behaviour into a swappable object.
🔍 What is Replace Type Code with State/Strategy?
This is the third member of the type-code family, and the one built for movement. Its siblings are Replace Type Code with Class (for codes that are pure labels) and Replace Type Code with Subclasses (for codes that drive behaviour but never change). This refactoring handles the hardest combination: the type code drives behaviour and changes during the object's lifetime.
The recipe:
- Define an interface for everything that varies —
PlanBehaviourwithmakeCall(),recharge(),monthEnd(). - Write one implementation per type code —
PrepaidPlan,PostpaidPlan. Each gathers all of one type's rules in one cohesive place. - Give the host a field holding the current implementation. The SIM keeps a
planreference. - Delegate. The host's methods stop switching on a code and instead forward to the current plan object.
- Make transitions into swaps. Converting to postpaid is now one assignment:
this.plan = new PostpaidPlan(). The host's identity is untouched; only its behaviour-card changed.
This works because it replaces inheritance with composition. Subclasses bake the variation into the object's own class — fixed forever, like a birth certificate. Composition keeps the variation in a part, and parts can be replaced while the whole keeps living — like a card in a wallet. That is the one superpower subclasses can never have.
One-line summary: Replace Type Code with State/Strategy moves each type's behaviour into its own swappable object behind a common interface, so the same host can change its behaviour at runtime by replacing that object — something subclassing can never do.
College corner: why exactly is "change the class of a live object" forbidden? At allocation time the runtime fixes the object's memory layout (its fields) and its dispatch table (the vtable that routes virtual calls). Changing class would mean reshaping memory under every existing reference. A few dynamic languages expose escape hatches — Python lets you assign instance.__class__, Smalltalk has become: — and JavaScript can even mutate an object's prototype with Object.setPrototypeOf. All of them are documented as traps: they wreck JIT optimizations, break invariants the constructors established, and confuse every reader. The respectable, portable answer in every language is the one this refactoring teaches — keep identity in the host, keep changeable behaviour in a part, and swap the part.
🎭 State or Strategy — which name applies?
The refactoring's name mentions two design patterns because the structure it builds is identical for both; only the intent differs.
- State pattern: the swappable objects represent positions in a lifecycle, and the system cares about transitions — what is legal, what happens next, often with the state object itself triggering the move. Prepaid → Postpaid → Suspended is a little state machine, so our SIM leans State.
- Strategy pattern: the swappable objects are interchangeable algorithms, chosen from outside by a client, with no idea of progression — think of choosing a route-finding method or a discount calculation. Nothing "flows" from one strategy to the next.
You do not need to pick the label before refactoring — build the swappable object first, and the intent will tell you its name. We explore both patterns fully in the State and Strategy posts.
College corner: in the Gang of Four book, State and Strategy literally share a structure diagram, and the authors discuss them as siblings. A useful interview-grade distinction: ask who changes the object and how often. Strategy is typically injected once by the client at construction or configuration time and rarely touched again; State is expected to churn during the host's life, and the legal transition graph is part of the domain rules. Also note the knowledge direction: states often hold a back-reference to the host so they can trigger transitions, while strategies are usually kept ignorant of their host — they receive everything as parameters. That small coupling difference is the structural fingerprint that tells you which pattern you actually built.
🚨 When do we need it?
Check for this exact combination of signs:
- A type code exists and methods branch on it.
switch (this.planType)ladders insidemakeCall(),recharge(),monthEnd()— the same ladder, copy-pasted per method. That is the Switch Statements smell sitting on top of Primitive Obsession. - The code is written to, not just read. Somewhere there is
sim.planType = POSTPAID— the type mutates on a live object. This single observation rules out subclasses immediately. - "Same identity, new rules" appears in the domain language. "Convert the plan", "upgrade the account", "suspend the subscription", "promote the user". The order moves Pending → Paid → Shipped. The same object flows through phases.
- Illegal transitions are causing bugs. A suspended SIM somehow made calls; an unshipped order got delivered. When transition rules are scattered across switches, nobody enforces them in one place.
- Two independent codes vary on one object. Even if each alone never changed, subclassing on two dimensions explodes into
PrepaidStudentSim,PostpaidStudentSim,PrepaidSeniorSim... Composition handles each dimension with its own swappable object, no explosion.
And check the negatives. If the type never changes on a live object, prefer the simpler Subclasses — no delegation layer to maintain. If there is no behaviour at all behind the code, a humble value class or enum is the whole answer. This refactoring is the most powerful of the three and also the most machinery; use it when both signs genuinely point here.
🔄 Before and after at a glance
Here is the SIM before the refactoring — one class, one mutable type code, switches multiplying:
// BEFORE: a mutable type code with behaviour switches everywhere
const PREPAID = 0;
const POSTPAID = 1;
class Sim {
planType = PREPAID; // changes at runtime!
balance = 0; // prepaid only
monthlyBill = 0; // postpaid only
makeCall(minutes: number): void {
switch (this.planType) {
case PREPAID:
if (this.balance < minutes) throw new Error("Recharge first");
this.balance -= minutes;
break;
case POSTPAID:
this.monthlyBill += minutes * 1.2;
break;
}
}
monthEnd(): string {
switch (this.planType) { // the SAME switch again
case PREPAID: return "No bill. Balance carries forward.";
case POSTPAID: return `Bill generated: Rs. ${this.monthlyBill}`;
default: throw new Error("Unknown plan");
}
}
}And after. The plan becomes a swappable object; the SIM delegates:
// AFTER: behaviour lives in a swappable plan object
interface PlanBehaviour {
makeCall(minutes: number): void;
monthEnd(): string;
}
class PrepaidPlan implements PlanBehaviour {
private balance = 0;
recharge(amount: number) { this.balance += amount; }
makeCall(minutes: number) {
if (this.balance < minutes) throw new Error("Recharge first");
this.balance -= minutes;
}
monthEnd() { return "No bill. Balance carries forward."; }
}
class PostpaidPlan implements PlanBehaviour {
private monthlyBill = 0;
makeCall(minutes: number) { this.monthlyBill += minutes * 1.2; }
monthEnd() { return `Bill generated: Rs. ${this.monthlyBill}`; }
}
class Sim {
constructor(
readonly number: string, // identity NEVER changes
private plan: PlanBehaviour = new PrepaidPlan(),
) {}
// The transition: swap the card, keep the SIM
convertToPostpaid() { this.plan = new PostpaidPlan(); }
convertToPrepaid() { this.plan = new PrepaidPlan(); }
makeCall(minutes: number) { this.plan.makeCall(minutes); }
monthEnd(): string { return this.plan.monthEnd(); }
}
const uncleSim = new Sim("98390-12345");
uncleSim.convertToPostpaid(); // same SIM, same number, new rules — at runtimeThree things to admire. The switches are gone — makeCall is one delegating line. The plan-specific fields moved home: balance lives only inside PrepaidPlan, monthlyBill only inside PostpaidPlan; the SIM no longer carries fields that are meaningless half the time. And convertToPostpaid() is the whole miracle: one assignment changes the object's entire behaviour, while its identity — the twelve-year-old number on the signboard — stays untouched.
The static shape of the design is worth one careful look: the host composes an interface, and the implementations hang off that interface — not off the host.
And here is the runtime conversation. Watch the middle of the diagram: the swap happens between two calls, and the caller never notices anything except new behaviour.
🧭 Which of the three should I pick?
Here is the map of the whole type-code family — the same guide appears in all three posts, so any single post teaches you the full choice. Ask two questions about your type code:
Question 1: Does behaviour vary by the code? Do methods do different work per type — are there switch/if ladders keyed on it?
Question 2: Can the type change at runtime? Can the same object move from one type to another during its life?
| Behaviour varies by code? | Type can change at runtime? | Pick this refactoring | Everyday example |
|---|---|---|---|
| No — pure label | Doesn't matter | Replace Type Code with Class (or a plain enum) | School house badge — Red, Blue, Green, Yellow; all behave the same |
| Yes | No — fixed for the object's whole life | Replace Type Code with Subclasses | Day-scholar vs boarder vs hosteller — different fees and timings, but a record never flips type |
| Yes | Yes — the same object switches type | Replace Type Code with State/Strategy | A SIM that moves from prepaid to postpaid — same number, new behaviour |
Farhan Uncle's SIM answers yes to both questions — behaviour varies (prepaid and postpaid rules differ) and the type flips on the same live object. Bottom row. Only State/Strategy fits, because only composition lets behaviour change after construction.
On the two-axis map, the SIM sits in the far top-right corner — maximum behaviour difference, maximum runtime movement. That corner belongs exclusively to today's refactoring.
A memory trick for the whole family: label → badge, fixed kind → birth certificate, changing kind → replaceable card. A badge just names you. A birth certificate is decided once and never edited. A card in your wallet can be swapped any day, and you remain you.
🪜 Step-by-step, the safe way
This refactoring has more moving parts than its siblings, so small steps matter even more. Follow Fowler's mechanics gently.
Step 1: Self-encapsulate the type code. Funnel every read and write through a getter and setter. Now you control the only doorway to the code.
class Sim {
private _planType = PREPAID;
get planType() { return this._planType; }
set planType(value: number) { this._planType = value; } // the one doorway
}Step 2: Create the interface and empty implementations. Define PlanBehaviour and write PrepaidPlan and PostpaidPlan as empty shells. Nothing uses them yet; everything still compiles.
Step 3: Plant the state field and tie it to the setter. This is the key intermediate stage — old and new live side by side. The type code still exists, but every write to it also swaps the plan object:
class Sim {
private plan: PlanBehaviour = new PrepaidPlan(); // new — planted
set planType(value: number) {
this._planType = value; // old — still alive
this.plan = value === PREPAID
? new PrepaidPlan()
: new PostpaidPlan(); // kept in sync
}
}Step 4: Move one method at a time. Take monthEnd(). Add it to the interface, move each switch branch into the matching plan class, and make the host delegate: monthEnd() { return this.plan.monthEnd(); }. Compile, test, breathe. Then take makeCall(). One method per step.
Step 5: Move the type-specific fields. balance migrates into PrepaidPlan, monthlyBill into PostpaidPlan. The host slims down to identity plus the plan reference.
Step 6: Replace code assignments with named transitions, then delete the code. Every sim.planType = POSTPAID becomes sim.convertToPostpaid(). When no reader of _planType remains, delete the field and the constants. At I/O boundaries (database, API), a small factory maps the stored string to the right plan object on load.
The most dangerous moment is Step 3-to-6, while the old code and the new object both exist. If some write path sets _planType directly without going through the setter, the plan object silently goes stale and the SIM behaves like the wrong plan. That is why Step 1 (self-encapsulation) is not optional — close every side door before you start. Also think about data crossing a transition: when prepaid converts to postpaid, what happens to the leftover balance? Decide explicitly (refund it, credit the first bill) and write a test for it; do not let it vanish by accident.
🏗️ A bigger real-life example
Real telecom life has a third phase: Suspended. Two months after his conversion, Farhan Uncle learnt this one the hard way — a busy festival season, a forgotten bill, and one Tuesday morning his shop number went silent. No calls in, no calls out. The number was preserved, the SIM was alive, but the rules had flipped again — this time by the company's decision, not his. Paying the dues reactivated it within the hour.
Now we truly have a state machine, and the State pattern shines. Watch how each state can even decide the next state:
interface PlanState {
makeCall(sim: Sim, minutes: number): void;
payment(sim: Sim, amount: number): void;
label(): string;
}
class PrepaidState implements PlanState {
private balance = 0;
makeCall(sim: Sim, minutes: number) {
if (this.balance < minutes) throw new Error("Recharge first");
this.balance -= minutes;
}
payment(sim: Sim, amount: number) { this.balance += amount; } // recharge
label() { return "Prepaid"; }
}
class PostpaidState implements PlanState {
private bill = 0;
private static readonly CREDIT_LIMIT = 2000;
makeCall(sim: Sim, minutes: number) {
this.bill += minutes * 1.2;
if (this.bill > PostpaidState.CREDIT_LIMIT) {
sim.transitionTo(new SuspendedState(this.bill)); // state decides next state
}
}
payment(sim: Sim, amount: number) { this.bill = Math.max(0, this.bill - amount); }
label() { return "Postpaid"; }
}
class SuspendedState implements PlanState {
constructor(private dues: number) {}
makeCall(_sim: Sim, _minutes: number): void {
throw new Error("Connection suspended. Please clear dues.");
}
payment(sim: Sim, amount: number) {
this.dues -= amount;
if (this.dues <= 0) {
sim.transitionTo(new PostpaidState()); // pay dues -> reactivated
}
}
label() { return "Suspended"; }
}
class Sim {
private state: PlanState = new PrepaidState();
constructor(readonly number: string) {}
transitionTo(next: PlanState) {
console.log(`${this.number}: ${this.state.label()} -> ${next.label()}`);
this.state = next;
}
convertToPostpaid() { this.transitionTo(new PostpaidState()); }
makeCall(minutes: number) { this.state.makeCall(this, minutes); }
payment(amount: number) { this.state.payment(this, amount); }
}Read SuspendedState.makeCall and enjoy it: the rule "a suspended SIM cannot call" lives in exactly one obvious place, instead of being a forgotten if inside a giant switch. The transition rules are explicit too — crossing the credit limit suspends; clearing dues reactivates. Notice the host passes itself (sim) into the state methods so a state can trigger transitionTo; that is the standard State-pattern handshake, and the small extra coupling is the price of letting states manage the lifecycle. Adding a brand-new phase — say SafeCustodyState for customers travelling abroad — means adding one class and the transitions that touch it. Nothing else changes.
If instead the variation had no lifecycle — say the SIM offers three billing algorithms (per-second, per-minute, bulk-pack) the customer picks in the app — the very same structure would be a Strategy: the client sets the algorithm from outside, and no strategy ever "flows" into another. Same skeleton, different soul.
Nida, being a CS student, kept score of what each new plan phase cost the codebase before and after the refactoring. In the switch world, a new phase meant editing every ladder plus every test that poked them; in the state world, it meant writing one new class and wiring its transitions.
College corner: Python actually permits the forbidden trick — sim.__class__ = PostpaidSim will "work" — and it is worth seeing once, in a sandbox, to understand why nobody ships it: the new class's __init__ never ran, so its invariants do not hold; any state the old class carried is now misinterpreted; and type checkers, serializers, and your teammates all assume classes are stable. The clean Python version of today's refactoring looks exactly like the TypeScript one — a PlanState protocol (or ABC), one class per phase, and a self._state attribute that gets reassigned. Composition is not a workaround for a language limitation; it is the design that keeps identity and behaviour honestly separated even in languages that allow the hack.
from abc import ABC, abstractmethod
class PlanState(ABC):
@abstractmethod
def make_call(self, sim: "Sim", minutes: int) -> None: ...
class Prepaid(PlanState):
def __init__(self) -> None:
self.balance = 0
def make_call(self, sim: "Sim", minutes: int) -> None:
if self.balance < minutes:
raise RuntimeError("Recharge first")
self.balance -= minutes
class Postpaid(PlanState):
def __init__(self) -> None:
self.bill = 0.0
def make_call(self, sim: "Sim", minutes: int) -> None:
self.bill += minutes * 1.2
class Sim:
def __init__(self, number: str) -> None:
self.number = number # identity never changes
self._state: PlanState = Prepaid()
def convert_to_postpaid(self) -> None:
self._state = Postpaid() # the swap, not a class change
def make_call(self, minutes: int) -> None:
self._state.make_call(self, minutes)💻 The same refactoring in C#
The C# version maps one-to-one, and reads beautifully with interfaces and expression-bodied members:
public interface IPlanState
{
void MakeCall(Sim sim, int minutes);
void Payment(Sim sim, decimal amount);
string Label { get; }
}
public sealed class PrepaidState : IPlanState
{
private decimal _balance;
public string Label => "Prepaid";
public void MakeCall(Sim sim, int minutes)
{
if (_balance < minutes) throw new InvalidOperationException("Recharge first");
_balance -= minutes;
}
public void Payment(Sim sim, decimal amount) => _balance += amount;
}
public sealed class PostpaidState : IPlanState
{
private decimal _bill;
private const decimal CreditLimit = 2000m;
public string Label => "Postpaid";
public void MakeCall(Sim sim, int minutes)
{
_bill += minutes * 1.2m;
if (_bill > CreditLimit) sim.TransitionTo(new SuspendedState(_bill));
}
public void Payment(Sim sim, decimal amount) => _bill = Math.Max(0, _bill - amount);
}
public class Sim
{
public string Number { get; }
private IPlanState _state = new PrepaidState();
public Sim(string number) => Number = number;
public void TransitionTo(IPlanState next) => _state = next;
public void ConvertToPostpaid() => TransitionTo(new PostpaidState());
public void MakeCall(int minutes) => _state.MakeCall(this, minutes);
public void Payment(decimal amount) => _state.Payment(this, amount);
}A few C#-specific notes worth knowing:
- Plain enums cannot do this job.
enum PlanType { Prepaid, Postpaid }plus switch expressions only re-creates the before-picture: behaviour stays outside the type, and every new plan revisits every switch. Enums belong to the no-behaviour case from the Class refactoring. - Smart enums get close but stop short. An Ardalis-style smart enum can hold per-value behaviour, which nicely covers stateless strategy-like variation. But the moment each plan needs its own mutable data (
_balance,_bill) and lifecycle transitions, full state classes as above are the honest home. - Stateless strategies can be shared. If your implementations hold no per-host data — pure algorithms like billing formulas — register one instance of each and reuse it everywhere (even as singletons via dependency injection). Our plan states hold per-SIM money, so each SIM gets its own instances.
- Modern C# pattern matching (
sim.State switch { PrepaidState => ..., ... }) is fine at boundaries — serialization, display — but if you find behavioural switches creeping back over the state types, the refactoring is leaking.
College corner: that third bullet is the Flyweight idea quietly meeting Strategy. A stateless strategy object is pure behaviour — it has no fields, so one shared instance serves every host safely, even across threads. This is why dependency-injection containers register strategies as singletons by default. The moment a strategy or state grows per-host mutable fields (our _balance), sharing becomes a bug: two SIMs would share one balance. So the allocation rule of thumb is: stateless → share one instance; stateful → one fresh instance per host (or per transition). Knowing which side of that line your objects sit on is the difference between a clean design and a very confusing production incident.
This refactoring is really the doorway into two classic Gang of Four patterns. When the swappable object models a lifecycle with rules, you have built the State pattern; when it models interchangeable algorithms picked by a client, you have built the Strategy pattern. Read both posts next — you will recognize every diagram, because you have just built the structure with your own hands.
📊 Benefits and risks
| Benefits | Risks / costs |
|---|---|
| Behaviour changes at runtime by swapping one reference — impossible with subclasses | Extra indirection and a small object graph — overkill if the type never changes (use subclasses) |
| Behavioural switches collapse into one-line delegation | Host must manage transitions; states may need the host passed in, adding coupling |
| Each state/strategy is small, cohesive, and independently testable | Per-host data in state objects means allocations on every transition (stateless ones can be shared) |
| Transition rules become explicit, named, and enforceable in one place | Behaviour spread across many small classes can feel scattered for trivial variation |
| Adding a state/strategy is purely additive — existing code untouched | Data crossing a transition (leftover balance) needs explicit, tested handling |
| Naturally expresses state machines and pluggable algorithms | Persistence must map a stored label back to the right object on load |
🧪 Which smells does it cure?
| Smell | How this refactoring helps |
|---|---|
| Switch Statements | Repeated behavioural switches over a mutable code dissolve into delegation |
| Primitive Obsession | The mutable int/string code becomes real, swappable objects |
| Temporary field | Fields meaningful in only one mode (balance, monthlyBill) move into the state that owns them |
| Shotgun surgery | A new state/strategy is one new class, not edits across every switch |
| Combinatorial subclass explosion | Two independent varying dimensions become two composed objects instead of N×M subclasses |
🗺️ The whole idea in one picture
📦 Quick revision box
+----------------------------------------------------------------+
| REPLACE TYPE CODE WITH STATE/STRATEGY - REVISION CARD |
+----------------------------------------------------------------+
| Problem : type code DRIVES behaviour AND CHANGES at runtime |
| -> switches everywhere + subclasses cannot help |
| (an object's class is fixed at birth) |
| Solution : interface for the varying behaviour, |
| one implementation per code, |
| host holds CURRENT one and DELEGATES, |
| transition = swap the reference |
| Result : same identity, new rules — at runtime |
| |
| STATE : lifecycle + transition rules (Prepaid->Suspended) |
| STRATEGY : interchangeable algorithm picked from outside |
| |
| WHICH OF THE THREE? |
| no behaviour varies -> CLASS / ENUM |
| behaviour varies, type fixed -> SUBCLASSES |
| behaviour varies + type changes-> STATE/STRATEGY (this one) |
+----------------------------------------------------------------+✍️ Practice exercise
Your turn. A food delivery app tracks orders with a mutable status code, and the switches are already multiplying:
const PLACED = 0, COOKING = 1, ON_THE_WAY = 2, DELIVERED = 3;
class Order {
status = PLACED; // changes at runtime!
cancel(): string {
switch (this.status) {
case PLACED: return "Cancelled. Full refund.";
case COOKING: return "Cancelled. 50% refund.";
case ON_THE_WAY: return "Cannot cancel now.";
case DELIVERED: return "Cannot cancel. Order completed.";
default: throw new Error("Unknown status");
}
}
trackingMessage(): string {
switch (this.status) { // the same ladder again
case PLACED: return "Restaurant is confirming your order.";
case COOKING: return "Your food is being prepared.";
case ON_THE_WAY: return "Rider is on the way!";
case DELIVERED: return "Delivered. Enjoy your meal!";
default: throw new Error("Unknown status");
}
}
}Refactor it step by step:
- Run the two-question check: behaviour varies (yes — cancel and tracking differ per status) and the status changes on the same live order (yes). Bottom row of the table — State/Strategy it is. Is this one State or Strategy in intent? Decide before you code.
- Create an
OrderStateinterface withcancel(order),trackingMessage(), and one implementation per status:PlacedState,CookingState,OnTheWayState,DeliveredState. - Give
Orderastatefield and atransitionTo(next)method. Move one switching method at a time into the states, delegating from the host. Compile and test between moves — remember the six safe steps and the stale-code trap from the warning above. - Make the lifecycle real: add
confirmCooking(),pickUp(), anddeliver()transitions, and enforce one rule inside a state — for example,DeliveredStateshould make any further transition throw. - Now the new requirement: a "Returned" status, where cancel says "Refund processing" and tracking says "Pickup rider assigned". Prove you only added a class and one transition — no existing method edited.
- Bonus thinking: if the app instead let users choose between three tip calculation methods (percentage, flat, round-up), would that be State or Strategy? One sentence, using the difference in intent.
If you answered question 1 with "State — because an order has a lifecycle with legal transitions," and question 6 with "Strategy — because tips are interchangeable algorithms with no progression," then you have mastered not just a refactoring but two design patterns in one sitting. Farhan Uncle's signboard never changed, and neither did his number — and now you know exactly how to build software that can say the same. Well done.
Frequently asked questions
- Why can't I just use subclasses when the type changes at runtime?
- Because an object's class is fixed the moment it is constructed — no mainstream language lets you reassign it later. A PrepaidSim object can never become a PostpaidSim object. State/Strategy solves this by moving the varying behaviour into a separate collaborator object that the host can swap with a simple assignment.
- What is the difference between the State pattern and the Strategy pattern here?
- The structure is identical — a host delegating to a swappable object behind an interface. The intent differs. State models a lifecycle where the object itself often decides when to transition, and transitions follow rules. Strategy models interchangeable algorithms picked from outside, with no notion of progression between them.
- Does the SIM lose its data when the plan changes?
- No, and that is the beauty of composition. The host object — the SIM with its number and identity — stays exactly the same. Only the small plan object inside it is replaced. Everything that does not vary lives safely on the host; only the varying behaviour is swapped.
- Isn't this refactoring too heavy for simple cases?
- Yes, it can be. If the type never changes at runtime, plain subclasses are simpler — no delegation layer. If the code carries no behaviour at all, a value class or enum is enough. Use State/Strategy only when behaviour varies AND the type changes on a live object.
- Where do the state transitions live after the refactoring?
- Two common homes. The host can own named transition methods like convertToPostpaid, which keeps things simple. Or, in the full State pattern, each state object decides and returns the next state, which suits strict state machines where only certain transitions are legal.
Further reading
Related Lessons
State Pattern: The Fan That Changes Its Mood
Learn the State design pattern with a ceiling fan regulator story, simple TypeScript and C# code, state diagrams, real software examples, and practice.
Strategy Pattern: Cycle, Bus, or Auto — You Choose
Learn the Strategy design pattern with a simple school travel story, easy TypeScript and C# code, runtime swapping, real examples, and practice tasks.
Switch Statements: The Receptionist With the Giant Rulebook
Learn the Switch Statements code smell with a school receptionist story, duplicated switch examples in TypeScript and C#, and the polymorphism cure.
Primitive Obsession: When Everything Is Just a String or a Number
Primitive Obsession explained simply — why plain strings and numbers hide bugs, and how value objects like Money and Address make code safe and clear.