Behavioural Patterns

By the Syncfusion DevTutorials team · Last updated

Learn strategy, observer, state, command and chain of responsibility, then turn QuickBite's order status if-chains into state classes with listeners.

In a restaurant kitchen, people do not shout instructions to everyone. The head chef sets the process. Orders are placed on a ticket rail. When a dish is ready, a bell rings, and the people who need that information respond.

Software needs similar ways of working. Behavioural patterns are common solutions that help objects communicate and share responsibilities without creating one large class that controls everything.

In simple words: behavioural patterns define how work is shared between objects and how they communicate with each other.

What You Will Learn

In this lesson, you will learn five behavioural patterns:

  1. Strategy: a rule or algorithm that can be changed easily.
  2. Observer: notify interested objects when something changes.
  3. State: represent each state with its own class and behavior.
  4. Command: represent an action as an object so it can be stored, queued, or undone.
  5. Chain of Responsibility: pass a request through a series of handlers until one processes it.

You have already used two of these patterns before, even if you did not know their names. The payment classes from the SOLID lesson are an example of the Strategy pattern. The onChange listeners used in this lesson's lab are examples of the Observer pattern.

Strategy: A Swappable Rule

QuickBite can calculate delivery charges in different ways: a fixed fee, free delivery for orders above 500, or surge pricing during busy hours. Each pricing rule is a strategy. The Checkout class keeps one strategy and can switch to a different one when needed.

The Example

Step through one order of 600 at 8 pm while Checkout swaps its fee rule:

Here is the same idea in code:

Strategy: swap the rule, keep the code

interface DeliveryFee {
  fee(subtotal: number, hour: number): number;
}

class FlatFee implements DeliveryFee {
  fee(): number { return 30; }
}
class FreeAbove500 implements DeliveryFee {
  fee(subtotal: number): number { return subtotal >= 500 ? 0 : 30; }
}
class SurgeFee implements DeliveryFee {
  fee(subtotal: number, hour: number): number { return hour >= 19 && hour <= 21 ? 45 : 30; }
}

class Checkout {
  constructor(private fee: DeliveryFee) {}
  useFee(fee: DeliveryFee): void { this.fee = fee; }
  total(subtotal: number, hour: number): number { return subtotal + this.fee.fee(subtotal, hour); }
}

const c = new Checkout(new FlatFee());
console.log(c.total(600, 20));
c.useFee(new FreeAbove500());
console.log(c.total(600, 20));
c.useFee(new SurgeFee());
console.log(c.total(600, 20));

Expected output:

630
600
645

The same order worth 600 costs 630, then 600, and then 645. The Checkout class did not need any changes because only the strategy changed.

Without a Strategy

The delivery fee logic would be written as a series of if statements inside Checkout. Every new pricing rule would require modifying the class.

Observer: Tell Everyone Who Cares

When an order is sent out for delivery, several systems may need to know about it, such as the SMS service, the order tracking screen, and the analytics system. The order should not need to know about each of these systems directly.

With an observer pattern, interested objects subscribe to events. The publisher simply announces that an event has happened.

The Example

Follow one event from the order to every listener:

Here is the same idea in code:

Observer: one change, many listeners

type Listener = (event: string) => void;

class OrderEvents {
  private readonly listeners: Listener[] = [];
  subscribe(l: Listener): void { this.listeners.push(l); }
  publish(event: string): void {
    for (const l of this.listeners) l(event);
  }
}

const events = new OrderEvents();
events.subscribe((e) => console.log(`SMS: ${e}`));
events.subscribe((e) => console.log(`Tracking screen: ${e}`));
events.subscribe((e) => console.log(`Analytics saved "${e}"`));

events.publish("order 881 is out for delivery");

Expected output:

SMS: order 881 is out for delivery
Tracking screen: order 881 is out for delivery
Analytics saved "order 881 is out for delivery"

All three listeners receive the same text, "order 881 is out for delivery", in the order they subscribed: SMS, then the tracking screen, then analytics.

Why It Helps

To add a new listener, such as push notifications, you only need to subscribe one additional function. The order-related code remains unchanged.

Listeners that are not removed continue to run and keep objects in memory. When a screen is closed, make sure to unsubscribe its listeners. The second exercise below gives you practice with this.

State: Each Status Is a Class

An order can move through different statuses, such as placed, preparing, out for delivery, delivered, or cancelled. Some transitions are allowed, while others are not. For example, you should not be able to cancel an order that is already with the rider.

The Problem With If-Chains

A common first approach is to check the status with if statements inside each method. When a new status is added, every if chain must be updated, and it is easy to miss one.

The State Pattern

Instead of using many if statements, create a separate class for each state. Each state class knows which actions are allowed, which state comes next, and which actions should be rejected. Step through it with a restaurant table:

Here is the same idea in code:

State: each status is a class with its own rules

interface TableState {
  readonly name: string;
  seat(): TableState;
  leave(): TableState;
}

class Free implements TableState {
  readonly name = "free";
  seat(): TableState { return new Busy(); }
  leave(): TableState { throw new Error("nobody is sitting here"); }
}
class Busy implements TableState {
  readonly name = "busy";
  seat(): TableState { throw new Error("table is taken"); }
  leave(): TableState { return new Free(); }
}

let table: TableState = new Free();
table = table.seat();
console.log(table.name);
try {
  table = table.seat();
} catch (e) {
  console.log("refused:", (e as Error).message);
}
table = table.leave();
console.log(table.name);

Expected output:

busy
refused: table is taken
free

Busy handles the rule on its own by refusing a second customer. There is no need to repeat this rule in multiple if statements.

Command: An Action You Can Keep

Normally, an action happens immediately and then disappears. A command pattern turns an action into an object. This allows you to store it in a list, execute it later, or even undo it if needed.

The Example: Undo in the Cart

Step through three dishes added and one undo:

Here is the same idea in code:

Command: an action as an object you can undo

interface Command {
  run(): void;
  undo(): void;
}

class Cart {
  readonly items: string[] = [];
}

class AddItem implements Command {
  constructor(private readonly cart: Cart, private readonly dish: string) {}
  run(): void { this.cart.items.push(this.dish); }
  undo(): void { this.cart.items.splice(this.cart.items.lastIndexOf(this.dish), 1); }
}

class History {
  private readonly done: Command[] = [];
  run(c: Command): void { c.run(); this.done.push(c); }
  undo(): void { this.done.pop()?.undo(); }
}

const cart = new Cart();
const history = new History();
history.run(new AddItem(cart, "Dosa"));
history.run(new AddItem(cart, "Lassi"));
history.run(new AddItem(cart, "Vada"));
console.log(cart.items);
history.undo();
console.log(cart.items);

Expected output:

[
  "Dosa",
  "Lassi",
  "Vada"
]
[
  "Dosa",
  "Lassi"
]

After three commands the cart holds Dosa, Lassi and Vada. After one undo it holds Dosa and Lassi.

History stores the commands that have already been executed. When undo is called, it asks the most recently executed command to reverse its action.

Where Commands Are Used

  • Undo and redo functionality.
  • Queues of tasks that need to run later, such as sending a receipt.
  • Logs of actions that can be replayed when needed.

Chain of Responsibility: Pass It Along

Before an order can be placed, several checks may need to run. For example: Is the user logged in? Are they placing orders too quickly? Is the coupon valid? Each check becomes one step in a chain. A step can either reject the request or pass it to the next step.

The Example

Step through three requests going through the same chain:

Here is the same idea in code:

Chain of responsibility: pass it along, or stop it

type Request = { user: string | null; code: string; recentOrders: number };

abstract class Handler {
  private next: Handler | null = null;
  then(h: Handler): Handler { this.next = h; return h; }
  handle(r: Request): string {
    const problem = this.check(r);
    if (problem) return "stopped: " + problem;
    return this.next ? this.next.handle(r) : "order placed";
  }
  protected abstract check(r: Request): string | null;
}

class MustLogIn extends Handler {
  protected check(r: Request) { return r.user ? null : "please log in"; }
}
class RateLimit extends Handler {
  protected check(r: Request) { return r.recentOrders > 5 ? "too many orders, slow down" : null; }
}
class CouponCheck extends Handler {
  protected check(r: Request) { return r.code === "" || r.code === "TENOFF" ? null : "unknown coupon"; }
}

const chain = new MustLogIn();
chain.then(new RateLimit()).then(new CouponCheck());

console.log(chain.handle({ user: "asha", code: "TENOFF", recentOrders: 1 }));
console.log(chain.handle({ user: null, code: "", recentOrders: 0 }));
console.log(chain.handle({ user: "ravi", code: "FREE", recentOrders: 2 }));

Expected output:

order placed
stopped: please log in
stopped: unknown coupon

To add a new check, you simply add another link to the chain. The existing links do not need to be changed.

Easy way to remember the five patterns: Strategy changes a rule. Observer announces updates. State changes behavior based on status. Command stores an action. Chain passes a request from one handler to the next.

Lab: Order Status as States

QuickBite's OrderFlow currently uses if chains to manage order status, and it cannot notify other parts of the system when the status changes. Open the lab in the sandbox.

Predict the Output

Here is an observer example in C#. Read the code and try to predict the output before running it.

Common Mistakes

MistakeWrongCorrectWhy it happens
Keeping rules in an if-chainif (feeType === "surge") ... inside Checkout.Create a separate strategy class for each fee rule.The first rule is simple, so it seems easy to keep adding more conditions.
Calling listeners directlyorder.ship() calls sms.send(), app.update(), and analytics.log().order.ship() publishes an event, and listeners subscribe to receive it.It is the quickest and most direct way to make the feature work initially.
Forgetting to remove listenersA screen that is already closed still receives updates.Unsubscribe listeners when they are no longer needed.The problem usually does not appear immediately.
Checking status everywhereif (status === "delivered") appears in many methods.Let each state class manage its own rules and transitions.Each individual check looks small and harmless at first.
Undo based on guessesundo() always resets the tip to 0.run() saves the previous value, and undo() restores it.Early tests may only cover one simple scenario.

Practice

What to Remember

  • Strategy: a replaceable rule that works through a common interface.
  • Observer: listeners subscribe to events, and the publisher does not need to know who the listeners are.
  • State: each status is represented by its own class, which knows its valid actions and transitions.
  • Command: an action is stored as an object so it can be queued, saved, or undone.
  • Chain of Responsibility: a request moves through a sequence of handlers, and each handler can either process it or pass it to the next one.

Further Reading

These are the sources used to check this lesson:

Next: API and Interface Design. You will learn how to design the methods and endpoints other people call, so they can rely on them.