Skip to content
Commitwise
Menu
Design Patterns

Strategy

Swap one algorithm for another at runtime, without a growing switch statement, with examples in Swift, Kotlin, Java and TypeScript.

intermediate

The core idea with one simple example.Step by step, with more examples and exercises.A cheat sheet: key APIs, gotchas and trade-offs.Internals, trade-offs and production pitfalls.

Your shop’s checkout supports two shipping methods. Then marketing wants express delivery. Then store pickup. Then “free shipping over 50 EUR, except on weekends”. Every request means opening the same switch statements in three different files, and every time, something else breaks.

The Strategy pattern fixes this: each way of doing the job becomes its own small object, and the code that uses it doesn’t care which one it got.

New to some of these? Open what you need, or skip ahead.

Basics:What is a design pattern?

A design pattern is a named, reusable solution to a problem that keeps coming up in software design, like “swap one algorithm for another” or “notify many objects of a change”. It’s not a library you install; it’s a way of arranging classes and interfaces that experienced developers recognize.

Knowing the names helps in two ways: you recognize the pattern in code you read (Android, iOS and Spring use many of them), and you can discuss designs quickly in reviews and interviews (“let’s use a Strategy here”).

Basics:What is an interface (or protocol)?

An interface (Kotlin, Java) or protocol (Swift) is a list of things a type promises it can do, without saying how. For example, “anything that is a PaymentMethod has a pay(amount) function”.

Different classes can implement the same interface in different ways (card, PayPal, cash), and code that only knows the interface can use any of them. That’s what lets you swap one implementation for another, for example a fake one in tests.

Basics:What are lambdas and closures?

A lambda (Kotlin, Java) or closure (Swift) is a small function without a name that you can store in a variable or pass to another function:

Swift:   numbers.filter { $0 > 10 }           // $0 is the first parameter
Kotlin:  numbers.filter { it > 10 }           // it is the single parameter
Java:    numbers.stream().filter(n -> n > 10)
TS:      numbers.filter((n) => n > 10)

The code inside the braces (or after ->) runs later, when the function you passed it to decides to call it: once per number here, or when a button is tapped in UI code. A closure can also use variables from the code around it.

Basics:What is a singleton?

A singleton is a class with exactly one instance that the whole app shares: a Kotlin object, a Swift static let shared, a Spring bean (Spring creates one instance of each @Service by default), or an object exported from a TypeScript module.

Sharing one instance is efficient and convenient, but anything the singleton stores is shared too: by every screen, every user request and every thread. That’s why singletons should hold no per-user or per-request data.

Part Not started

From switch statements to strategies

Think of a navigation app. You pick a destination, then choose driving, walking or public transport. The app does the same thing in all three cases (“find me a route”), but how it finds the route is completely different.

The app doesn’t contain one giant function with if driving … else if walking …. Each way of routing is a separate piece, and the app just asks the one you picked: “give me a route”. That separate piece is a strategy.

In code, a strategy is:

  1. an interface (a protocol in Swift) that describes the job, like “calculate shipping cost”,
  2. several implementations of it, one per way of doing the job,
  3. a context that holds one implementation and uses it, without knowing which one it is.

Strategy is a design patternDesign patternA named, reusable solution to a problem that keeps coming up in software design.Open in glossary → that encapsulates an interchangeable algorithm behind an interface, so the client chooses (or is given) the algorithm at runtime and the context depends only on the abstraction. In practice it’s the open/closed principleOpen/closed principleCode should be open for extension but closed for modification: add new behavior without editing what already works.Open in glossary → made concrete: adding a variant means adding a type, not editing a switch.

In languages with first-class functions it’s often just a function value, and the interesting questions are elsewhere: enum + switch or protocol + types? (the expression problem), how strategies are selected (config, server flags, DI), and how they stay stateless so they can be shared. All of that is in the Detailed version.

Here’s the shipping code before and after:

Before: Every new shipping method edits every switch

enum ShippingMethod { case standard, express, pickup }

func shippingCost(_ order: Order, _ method: ShippingMethod) -> Double {
    switch method {
    case .standard: return order.total >= 50 ? 0 : 4.99
    case .express:  return 9.99 + Double(order.itemCount) * 0.5
    case .pickup:   return 0
    }
}

func maxDeliveryDays(_ method: ShippingMethod) -> Int {
    switch method {
    case .standard: return 5
    case .express:  return 1
    case .pickup:   return 2
    }
}
// …and another switch for the label, and one in analytics
enum class ShippingMethod { STANDARD, EXPRESS, PICKUP }

fun shippingCost(order: Order, method: ShippingMethod): Double = when (method) {
    ShippingMethod.STANDARD -> if (order.total >= 50) 0.0 else 4.99
    ShippingMethod.EXPRESS -> 9.99 + order.itemCount * 0.5
    ShippingMethod.PICKUP -> 0.0
}

fun maxDeliveryDays(method: ShippingMethod): Int = when (method) {
    ShippingMethod.STANDARD -> 5
    ShippingMethod.EXPRESS -> 1
    ShippingMethod.PICKUP -> 2
}
// …and another when for the label, and one in analytics
enum ShippingMethod { STANDARD, EXPRESS, PICKUP }

static double shippingCost(Order order, ShippingMethod method) {
    return switch (method) {
        case STANDARD -> order.total() >= 50 ? 0 : 4.99;
        case EXPRESS -> 9.99 + order.itemCount() * 0.5;
        case PICKUP -> 0;
    };
}

static int maxDeliveryDays(ShippingMethod method) {
    return switch (method) {
        case STANDARD -> 5;
        case EXPRESS -> 1;
        case PICKUP -> 2;
    };
}
// …and another switch for the label, and one in analytics
type ShippingMethod = "standard" | "express" | "pickup";

function shippingCost(order: Order, method: ShippingMethod): number {
  switch (method) {
    case "standard": return order.total >= 50 ? 0 : 4.99;
    case "express":  return 9.99 + order.itemCount * 0.5;
    case "pickup":   return 0;
  }
}

function maxDeliveryDays(method: ShippingMethod): number {
  switch (method) {
    case "standard": return 5;
    case "express":  return 1;
    case "pickup":   return 2;
  }
}
// …and another switch for the label, and one in analytics

After: One type per shipping method

protocol ShippingOption {
    var name: String { get }
    var maxDeliveryDays: Int { get }
    func cost(for order: Order) -> Double
}

struct StandardShipping: ShippingOption {
    let name = "Standard"
    let maxDeliveryDays = 5
    func cost(for order: Order) -> Double { order.total >= 50 ? 0 : 4.99 }
}

struct ExpressShipping: ShippingOption {
    let name = "Express"
    let maxDeliveryDays = 1
    func cost(for order: Order) -> Double { 9.99 + Double(order.itemCount) * 0.5 }
}

struct Checkout {
    let order: Order
    var shipping: any ShippingOption   // the strategy, swappable at runtime

    var total: Double { order.total + shipping.cost(for: order) }
}
interface ShippingOption {
    val name: String
    val maxDeliveryDays: Int
    fun cost(order: Order): Double
}

object StandardShipping : ShippingOption {
    override val name = "Standard"
    override val maxDeliveryDays = 5
    override fun cost(order: Order) = if (order.total >= 50) 0.0 else 4.99
}

object ExpressShipping : ShippingOption {
    override val name = "Express"
    override val maxDeliveryDays = 1
    override fun cost(order: Order) = 9.99 + order.itemCount * 0.5
}

class Checkout(private val order: Order, var shipping: ShippingOption) {
    val total: Double get() = order.total + shipping.cost(order)
}
interface ShippingOption {
    String name();
    int maxDeliveryDays();
    double cost(Order order);
}

final class StandardShipping implements ShippingOption {
    public String name() { return "Standard"; }
    public int maxDeliveryDays() { return 5; }
    public double cost(Order order) { return order.total() >= 50 ? 0 : 4.99; }
}

final class ExpressShipping implements ShippingOption {
    public String name() { return "Express"; }
    public int maxDeliveryDays() { return 1; }
    public double cost(Order order) { return 9.99 + order.itemCount() * 0.5; }
}

final class Checkout {
    private final Order order;
    private ShippingOption shipping;   // the strategy, swappable at runtime

    Checkout(Order order, ShippingOption shipping) { this.order = order; this.shipping = shipping; }
    void setShipping(ShippingOption shipping) { this.shipping = shipping; }
    double total() { return order.total() + shipping.cost(order); }
}
interface ShippingOption {
  readonly name: string;
  readonly maxDeliveryDays: number;
  cost(order: Order): number;
}

const standardShipping: ShippingOption = {
  name: "Standard",
  maxDeliveryDays: 5,
  cost: (order) => (order.total >= 50 ? 0 : 4.99),
};

const expressShipping: ShippingOption = {
  name: "Express",
  maxDeliveryDays: 1,
  cost: (order) => 9.99 + order.itemCount * 0.5,
};

class Checkout {
  constructor(private readonly order: Order, public shipping: ShippingOption) {}   // the strategy, swappable

  get total(): number {
    return this.order.total + this.shipping.cost(this.order);
  }
}

Look at what changed:

  • Everything about express shipping now lives in one place. To add store pickup, you write one new type and touch nothing else.
  • Checkout only knows about ShippingOption. When the user taps a different option, you replace checkout.shipping and the total updates.
  • Each strategy is tiny, so it’s easy to test: create an order, call cost, check the number.

The Kotlin version uses object for each strategy, and TypeScript a plain object literal: they hold no state, so one shared instance is enough. Keep strategies stateless (or immutable) and they’re trivially thread-safe and reusable; push per-request data in through parameters, as cost(order) does.

Practice

Enum + switch or protocol + types?

This is the expression problem, and neither side wins everywhere:

Enum with exhaustive switch / whenInterface with one type per variant
Add a variantEdit every switch (the compiler lists them)Add one type, touch nothing else
Add an operationAdd one functionEdit every type
Variants defined outside the moduleImpossibleEasy (plugins, server-driven, other teams)
Exhaustiveness checkingYesNo

Swift enums with associated values, Kotlin sealed hierarchies and TypeScript discriminated unions sit in between: closed sets with exhaustive checks, and behavior that can live on the cases. For a fixed, small set that many operations switch over, they’re often the better choice. Strategy pays off when the set is open or the behavior is chosen at runtime.

Strategy in the platforms you use

  • Swift: JSONDecoder.keyDecodingStrategy and dateDecodingStrategy (.convertFromSnakeCase, .iso8601, or .custom { … }).
  • Android: TimeInterpolator decides how an animation progresses; LinearInterpolator, OvershootInterpolator or your own.
  • Java / Spring: Comparator, Spring Security’s PasswordEncoder (BCrypt, Argon2, delegating), and retry back-off policies.
  • TypeScript / Node: the compare function of sort, the reviver of JSON.parse, and Passport.js, whose login methods are literally called strategies (passport.use(new GoogleStrategy(…))).

Strategy vs State

Both look identical in a class diagram. The difference is who switches: with Strategy, the client picks the algorithm and the context just uses it. With State, the states themselves move the context to the next state (an order goes from Paid to Shipped). If your “strategies” start calling context.setStrategy(...), you’ve built a state machine.

SwiftKotlinJavaTypeScript
Interface + typesprotocol + structinterface + object / classinterface + classinterface + object literals or classes
Closed set with behaviorenum with methodssealed interface / enum class with methodsenum with abstract method, sealedunion + Record<Kind, Strategy>
Stored in contextany ShippingOptionShippingOptionShippingOptionShippingOption
  • It’s the expression problem: an enum with an exhaustive switch makes new operations cheap and new variants a checked edit; an interface with one type per variant makes new variants cheap. Choose Strategy when the set is open or chosen at runtime.
  • Strategy vs State: with Strategy the client picks; when the objects pick the next implementation themselves, it’s State.

Check this part

  1. What does Checkout need to know about shipping after the refactor?

  2. Marketing adds store pickup. What changes in the strategy version?

  3. With an enum and exhaustive switches instead of strategies, what does adding a variant cost?

  4. Your "strategies" start calling context.setStrategy(next) themselves. What pattern is that?

Strategy: one interface, one small type per variant, and a context that uses whichever one it's given. Adding a variant means adding a type, not editing every switch.

Part Not started

When a strategy is just a function

If the interface has a single method, you often don’t need classes at all. A function is a strategy.

let byPrice = products.sorted { $0.price < $1.price }
let byName  = products.sorted { $0.name < $1.name }
val byPrice = products.sortedBy { it.price }
val byNameThenPrice = products.sortedWith(compareBy({ it.name }, { it.price }))
var byPrice = products.stream().sorted(Comparator.comparing(Product::price)).toList();
var byNameThenPrice = products.stream()
    .sorted(Comparator.comparing(Product::name).thenComparing(Product::price))
    .toList();
const byPrice = products.toSorted((a, b) => a.price - b.price);
const byNameThenPrice = products.toSorted((a, b) => a.name.localeCompare(b.name) || a.price - b.price);

You’ve used Strategy many times already: the sorting algorithm stays the same, and you pass in the part that changes, the comparison.

Predict the output

What does this print?

let fruits = ["pear", "fig", "banana"]
print(fruits.sorted { $0.count < $1.count })
val fruits = listOf("pear", "fig", "banana")
println(fruits.sortedWith(compareBy { it.length }))
var fruits = List.of("pear", "fig", "banana");
System.out.println(fruits.stream().sorted(Comparator.comparing(String::length)).toList());
const fruits = ["pear", "fig", "banana"];
console.log(fruits.toSorted((a, b) => a.length - b.length));

Step by step: discount codes

Let’s build a second strategy family from scratch. A shop has three kinds of discount codes: a percentage off, a fixed amount off, and no discount. Here’s the recipe you can reuse for any strategy:

  1. Name the decision that varies. Here: “how much does this code take off the total?”
  2. Write it as one interface method. apply(total) returns the new total.
  3. Write one small type per variant. Each one knows only its own rule.
  4. Let the caller hold the interface, never a concrete type, so any discount fits.
protocol Discount {
    func apply(to total: Double) -> Double
}

struct PercentOff: Discount {
    let percent: Double
    func apply(to total: Double) -> Double { total * (1 - percent / 100) }
}

struct AmountOff: Discount {
    let amount: Double
    func apply(to total: Double) -> Double { max(0, total - amount) }
}

struct NoDiscount: Discount {
    func apply(to total: Double) -> Double { total }
}

let codes: [String: any Discount] = [
    "WINTER10": PercentOff(percent: 10),
    "WELCOME5": AmountOff(amount: 5),
]

func discount(for code: String) -> any Discount {
    codes[code.uppercased()] ?? NoDiscount()
}
fun interface Discount {
    fun apply(total: Double): Double
}

class PercentOff(private val percent: Double) : Discount {
    override fun apply(total: Double) = total * (1 - percent / 100)
}

class AmountOff(private val amount: Double) : Discount {
    override fun apply(total: Double) = maxOf(0.0, total - amount)
}

val NoDiscount = Discount { total -> total }

val codes: Map<String, Discount> = mapOf(
    "WINTER10" to PercentOff(10.0),
    "WELCOME5" to AmountOff(5.0),
)

fun discount(code: String): Discount = codes[code.uppercase()] ?: NoDiscount
interface Discount {
    double apply(double total);
}

record PercentOff(double percent) implements Discount {
    public double apply(double total) { return total * (1 - percent / 100); }
}

record AmountOff(double amount) implements Discount {
    public double apply(double total) { return Math.max(0, total - amount); }
}

static final Discount NO_DISCOUNT = total -> total;

static final Map<String, Discount> CODES = Map.of(
    "WINTER10", new PercentOff(10),
    "WELCOME5", new AmountOff(5)
);

static Discount discount(String code) {
    return CODES.getOrDefault(code.toUpperCase(), NO_DISCOUNT);
}
type Discount = (total: number) => number;

const percentOff = (percent: number): Discount => (total) => total * (1 - percent / 100);
const amountOff = (amount: number): Discount => (total) => Math.max(0, total - amount);
const noDiscount: Discount = (total) => total;

const codes = new Map<string, Discount>([
  ["WINTER10", percentOff(10)],
  ["WELCOME5", amountOff(5)],
]);

function discount(code: string): Discount {
  return codes.get(code.toUpperCase()) ?? noDiscount;
}

A few things to notice:

  • No switch anywhere. Picking a discount is a map lookup. A new code for Black Friday is one new line in the map.
  • “No discount” is a strategy too. Instead of checking if (discount != null) everywhere, a do-nothing strategy (a null object) means the caller always has something to call.
  • The numbers come in through the constructor. PercentOff(10) and PercentOff(25) are the same class with different settings, so marketing can add codes without new classes.
  • In Kotlin and Java, a one-method interface can be implemented with a lambda, which is how NoDiscount is written. TypeScript skips the interface entirely: a Discount is a function type, and percentOff(10) is a function that returns one.

Predict the output

The order total is exactly 50 and has 2 items. What gets printed?

let order = Order(total: 50, itemCount: 2)
var checkout = Checkout(order: order, shipping: StandardShipping())
print(checkout.total)

checkout.shipping = ExpressShipping()
print(checkout.total)
val order = Order(total = 50.0, itemCount = 2)
val checkout = Checkout(order, shipping = StandardShipping)
println(checkout.total)

checkout.shipping = ExpressShipping
println(checkout.total)
Order order = new Order(50, 2);
Checkout checkout = new Checkout(order, new StandardShipping());
System.out.println(checkout.total());

checkout.setShipping(new ExpressShipping());
System.out.println(checkout.total());
const order: Order = { total: 50, itemCount: 2 };
const checkout = new Checkout(order, standardShipping);
console.log(checkout.total);

checkout.shipping = expressShipping;
console.log(checkout.total);
Spot the bugA discount that pays the customer

Tap the line with the bug.

struct AmountOff: Discount {
    let amount: Double

    func apply(to total: Double) -> Double {
        total - amount
    }
}
class AmountOff(private val amount: Double) : Discount {

    override fun apply(total: Double): Double {
        return total - amount
    }
}
record AmountOff(double amount) implements Discount {

    public double apply(double total) {
        return total - amount;
    }
}
const amountOff = (amount: number): Discount => (total) => {
  return total - amount;
};

Exerciseeasy

Sort the product list

A shop screen has a “Sort by” menu with three choices: price, low to high, price, high to low and newest first. Write a SortOption enum for the menu and a function sorted(products, option) that returns the products in the chosen order.

struct Product {
    let name: String
    let price: Double
    let addedAt: Date
}
data class Product(val name: String, val price: Double, val addedAt: Instant)
record Product(String name, double price, Instant addedAt) {}
interface Product {
  name: string;
  price: number;
  addedAt: Date;
}
›

Each choice is a strategy for comparing two products. You don’t need a new interface: every language already has one for that (a closure in Swift, Comparator in Kotlin and Java, a compare function in TypeScript).

›

Give each enum case the comparator it stands for. Then sorted is one line, and adding “name, A to Z” later is one new case.

SolutionTry it yourself firstTime O(n log n)Space O(n)
enum SortOption: CaseIterable {
    case priceLowToHigh, priceHighToLow, newest

    var areInOrder: (Product, Product) -> Bool {
        switch self {
        case .priceLowToHigh: return { $0.price < $1.price }
        case .priceHighToLow: return { $0.price > $1.price }
        case .newest:         return { $0.addedAt > $1.addedAt }
        }
    }
}

func sorted(_ products: [Product], by option: SortOption) -> [Product] {
    products.sorted(by: option.areInOrder)
}
enum class SortOption(val comparator: Comparator<Product>) {
    PRICE_LOW_TO_HIGH(compareBy { it.price }),
    PRICE_HIGH_TO_LOW(compareByDescending { it.price }),
    NEWEST(compareByDescending { it.addedAt }),
}

fun sorted(products: List<Product>, option: SortOption): List<Product> =
    products.sortedWith(option.comparator)
enum SortOption {
    PRICE_LOW_TO_HIGH(Comparator.comparingDouble(Product::price)),
    PRICE_HIGH_TO_LOW(Comparator.comparingDouble(Product::price).reversed()),
    NEWEST(Comparator.comparing(Product::addedAt).reversed());

    final Comparator<Product> comparator;

    SortOption(Comparator<Product> comparator) {
        this.comparator = comparator;
    }
}

static List<Product> sorted(List<Product> products, SortOption option) {
    return products.stream().sorted(option.comparator).toList();
}
type SortOption = "priceLowToHigh" | "priceHighToLow" | "newest";

// Record<SortOption, …> must have a comparator for every option: a missing one doesn't compile
const comparators: Record<SortOption, (a: Product, b: Product) => number> = {
  priceLowToHigh: (a, b) => a.price - b.price,
  priceHighToLow: (a, b) => b.price - a.price,
  newest: (a, b) => b.addedAt.getTime() - a.addedAt.getTime(),
};

function sorted(products: Product[], option: SortOption): Product[] {
  return products.toSorted(comparators[option]);
}

The switch (or enum constructor) still exists, but it’s in one place, next to the list of choices. The screen just passes the option the user tapped. Sorting makes a new list, so the original order is kept for when the user picks again.

SwiftKotlinJavaTypeScript
Single functionclosure (Order) -> Doublefun interface or (Order) -> Doublefunctional interface + lambdafunction type (order: Order) => number
  • A do-nothing strategy (a null object, like NoDiscount) means callers always have something to call, instead of if (discount != null) everywhere.
  • Put invariants inside the strategy (a discount never makes a total negative), so every caller gets them.

Check this part

  1. In products.sorted { … } (or sortedBy, Comparator, toSorted), what is the strategy?

  2. How do you write a strategy that has only one method?

  3. Why return a NoDiscount strategy for an unknown code instead of null?

A strategy with one method is just a function: closures, lambdas and comparators are strategies, and a do-nothing strategy beats null.

Part Not started

Choosing a strategy

Somewhere, something has to pick which strategy to use: the user, a setting, or the server. That’s where bugs hide.

Spot the bugShipping options from the server

Tap the line with the bug.

func shippingOption(for code: String) -> any ShippingOption {
    switch code {
    case "standard": return StandardShipping()
    case "express":  return ExpressShipping()
    default:         return PickupShipping()
    }
}
fun shippingOption(code: String): ShippingOption = when (code) {
    "standard" -> StandardShipping
    "express" -> ExpressShipping
    else -> PickupShipping
}
static ShippingOption shippingOption(String code) {
    return switch (code) {
        case "standard" -> new StandardShipping();
        case "express" -> new ExpressShipping();
        default -> new PickupShipping();
    };
}
function shippingOption(code: string): ShippingOption {
  switch (code) {
    case "standard": return standardShipping;
    case "express":  return expressShipping;
    default:         return pickupShipping;
  }
}

Selecting and injecting strategies

  • Dependency injection. Spring injects every implementation of an interface as a List<ShippingOption> (or a Map<String, ShippingOption> keyed by bean name); Hilt does the same with @IntoSet / @IntoMap multibindings. The registry builds itself, and adding a strategy is adding a class.
  • Server-driven selection. Map a server code to a strategy in exactly one place, treat unknown codes explicitly (see the bug above), and log them.
  • TypeScript. NestJS can inject a token that resolves to a map of strategies; without a container, a plain Map<string, Strategy> built in one module is the registry.
  • Testing. Inject a fake strategy to make the context deterministic, for example a pricing strategy that always returns 10.

Shared strategies with per-call state

Strategies are usually singletons: a Kotlin object, a Spring bean, a static let shared. That’s fine until one of them remembers something between calls:

Spot the bugBulk discount, sometimes

Tap the line with the bug.

final class BulkDiscount: Discount {
    static let shared = BulkDiscount()
    private var itemCount = 0

    func prepare(for order: Order) { itemCount = order.items.count }

    func apply(to total: Double) -> Double {
        itemCount >= 10 ? total * 0.9 : total
    }
}
@Component("bulk")
class BulkDiscount : Discount {
    private var itemCount = 0

    override fun prepare(order: Order) { itemCount = order.items.size }

    override fun apply(total: Double) =
        if (itemCount >= 10) total * 0.9 else total
}
@Component("bulk")
class BulkDiscount implements Discount {
    private int itemCount;

    public void prepare(Order order) { itemCount = order.items().size(); }

    public double apply(double total) {
        return itemCount >= 10 ? total * 0.9 : total;
    }
}
export const bulkDiscount = {
  itemCount: 0,
  prepare(order: Order) { this.itemCount = order.items.length; },
  apply(total: number) {
    return this.itemCount >= 10 ? total * 0.9 : total;
  },
};

Persisting which strategy was chosen

Orders, settings and feature flags often store which strategy applies. How you store that identifier matters more than how you implement the strategy:

Predict the output

Version 1 saved the user's shipping choice as 1 (express). Version 2 adds pickup in the middle. What does the saved 1 load as?

enum ShippingChoice: Int {           // version 2
    case standard, pickup, express
}

print(ShippingChoice(rawValue: 1)!)
enum class ShippingChoice {          // version 2
    STANDARD, PICKUP, EXPRESS
}

println(ShippingChoice.entries[1])
enum ShippingChoice {                // version 2
    STANDARD, PICKUP, EXPRESS
}

System.out.println(ShippingChoice.values()[1]);
enum ShippingChoice {                 // version 2
  Standard, Pickup, Express,
}

console.log(ShippingChoice[1]);
Exercisemedium

Pricing rules from the server

Marketing wants to change discounts without an app release. The backend sends a list of rules with each cart:

[
  { "type": "percent", "percent": 10, "minTotal": 30 },
  { "type": "amount", "amount": 5 },
  { "type": "bogo", "sku": "COFFEE-250" }
]

Design the client side:

  1. How do these rules become strategies, including types this app version has never heard of?
  2. How do several rules combine, and what invariants must always hold?
  3. How do you test it so a bad rule from the server can’t cost money?
›

Separate decoding from behavior: decode into a closed set of known rule types plus an “unknown” case, then map known rules to strategies. Old apps will meet new rule types for years.

›

A list of discounts is itself a discount: a composite that applies them in order. Put the invariants (never below zero, never above the original total) in the composite, so no single rule can break them.

SolutionTry it yourself first
enum RuleDTO: Decodable {
    case percent(Double, minTotal: Double)
    case amount(Double)
    case unknown(String)

    private enum Keys: String, CodingKey { case type, percent, minTotal, amount }

    init(from decoder: Decoder) throws {
        let c = try decoder.container(keyedBy: Keys.self)
        let type = try c.decode(String.self, forKey: .type)
        switch type {
        case "percent": self = .percent(try c.decode(Double.self, forKey: .percent),
                                        minTotal: try c.decodeIfPresent(Double.self, forKey: .minTotal) ?? 0)
        case "amount":  self = .amount(try c.decode(Double.self, forKey: .amount))
        default:        self = .unknown(type)    // never fail the whole cart on one rule
        }
    }
}

struct AllDiscounts: Discount {
    let rules: [any Discount]
    func apply(to total: Double) -> Double {
        let result = rules.reduce(total) { $1.apply(to: $0) }
        return min(max(result, 0), total)
    }
}
@Serializable
sealed interface RuleDto {
    @Serializable @SerialName("percent") data class Percent(val percent: Double, val minTotal: Double = 0.0) : RuleDto
    @Serializable @SerialName("amount") data class Amount(val amount: Double) : RuleDto
    // kotlinx.serialization: unknown "type" values decode to this via a custom polymorphic default
    @Serializable data object Unknown : RuleDto
}

fun RuleDto.toDiscount(log: Logger): Discount? = when (this) {
    is RuleDto.Percent -> PercentOff(percent, minTotal)
    is RuleDto.Amount -> AmountOff(amount)
    RuleDto.Unknown -> null.also { log.info("Skipping unknown pricing rule") }
}

class AllDiscounts(private val rules: List<Discount>) : Discount {
    override fun apply(total: Double): Double {
        val result = rules.fold(total) { t, rule -> rule.apply(t) }
        return result.coerceIn(0.0, total)   // invariants live here, not in each rule
    }
}
sealed interface RuleDto permits Percent, Amount, Unknown {}
record Percent(double percent, double minTotal) implements RuleDto {}
record Amount(double amount) implements RuleDto {}
record Unknown(String type) implements RuleDto {}   // Jackson: @JsonTypeInfo(defaultImpl = Unknown.class)

static Optional<Discount> toDiscount(RuleDto rule) {
    return switch (rule) {
        case Percent p -> Optional.of(new PercentOff(p.percent(), p.minTotal()));
        case Amount a -> Optional.of(new AmountOff(a.amount()));
        case Unknown u -> Optional.empty();
    };
}

record AllDiscounts(List<Discount> rules) implements Discount {
    public double apply(double total) {
        double result = total;
        for (Discount rule : rules) result = rule.apply(result);
        return Math.clamp(result, 0, total);
    }
}
type RuleDto =
  | { type: "percent"; percent: number; minTotal?: number }
  | { type: "amount"; amount: number }
  | { type: "unknown"; raw: unknown };

function parseRule(raw: any): RuleDto {                    // never fail the whole cart on one rule
  if (raw?.type === "percent" && typeof raw.percent === "number") return { type: "percent", percent: raw.percent, minTotal: raw.minTotal };
  if (raw?.type === "amount" && typeof raw.amount === "number") return { type: "amount", amount: raw.amount };
  return { type: "unknown", raw };
}

function toDiscount(rule: RuleDto): Discount | undefined {
  switch (rule.type) {
    case "percent": return (total) => (total >= (rule.minTotal ?? 0) ? total * (1 - rule.percent / 100) : total);
    case "amount":  return (total) => total - rule.amount;
    case "unknown": return undefined;                      // skipped, and logged by the caller
  }
}

const allDiscounts = (rules: Discount[]): Discount => (total) => {
  const result = rules.reduce((t, rule) => rule(t), total);
  return Math.min(Math.max(result, 0), total);           // invariants live here, not in each rule
};

The design decisions worth saying out loud:

  • Unknown rules are skipped, not fatal, and logged so you notice. One new rule type must not break checkout for every old app version. (Whether to skip or block depends on the business: for discounts, showing the full price is the safe failure; the server must re-price at payment anyway.)
  • The server is the source of truth. The client calculates to show a price; the backend recalculates when the order is placed, so a tampered or outdated client can’t change what’s charged.
  • Order matters: 10% then 5 off is not 5 off then 10%. Make the order explicit in the payload (the array order) and document it.
  • Test the invariants, not just examples: generate random carts and random rule lists (property-based testing: Kotest, swift-testing with arguments, jqwik, fast-check) and assert 0 <= result <= total every time.

Deep dives

Deep diveThe open/closed principle, honestlyInterview favorite

Strategy is the textbook example of the open/closed principle: code is open for extension (add a new strategy) but closed for modification (the context doesn’t change). Interviewers like to ask for the principle by name, and then for its limits.

The limit is the expression problem from the first part: strategies make new variants cheap and new operations expensive (every strategy must implement the new method). “Closed for modification” is a goal for the parts that change often, not a rule for all code; applying it everywhere produces interfaces with one implementation and no second one ever coming.

  • Resolve strategies in one place (a DI map, a registry) and treat unknown keys explicitly: hide and log, or an explicit safe default, never a silent “real” strategy.
  • Strategies are usually shared (singletons, beans, module objects): keep them stateless and pass per-call data as parameters, or concurrent calls read each other’s state.
  • Persist a stable key (a name or explicit code), never an ordinal or auto-numbered value.

Kotlin, Java

Spring injects all implementations as a List or Map<String, Strategy> (bean names); Hilt uses @IntoSet / @IntoMap. JPA’s @Enumerated defaults to ORDINAL: use EnumType.STRING.

Swift

Swift 6 rejects a static let holding a non-Sendable class with mutable state; a struct strategy is the fix it pushes you toward. Use String raw values, not implicit Int ones, for anything stored.

TypeScript

A module-level object is one instance for every request in a Node server, and any await between “prepare” and “apply” lets another request run in between. Numeric enums are ordinals: store string keys.

Check this part

  1. The server starts sending a new code, "same_day", and the app maps unknown codes to store pickup with a default branch. What do customers see?

  2. What should the lookup do with a code it doesn't know?

  3. A static let shared class strategy stores itemCount in prepare(for:) and reads it in apply. What does Swift 6 strict concurrency say?A Spring-singleton strategy stores itemCount in prepare(order) and reads it in apply. Two checkouts run at once. What happens?A module-level strategy object stores itemCount in prepare(order), and apply runs after an await. Two requests overlap. What happens?

  4. Version 1 stored the shipping choice as its position, 1 (express). Version 2 inserts pickup before express. What does the stored 1 load as?

Map keys to strategies in one place, and never let an unknown key fall into a real strategy; keep shared strategies stateless and persist stable keys.

Key takeaways

  • Strategy = one interface, several interchangeable implementations, and a context that uses whichever one it’s given.
  • It turns “edit every switch” into “add one type”: the open/closed principle in practice.
  • A single-method strategy is just a function: closures, lambdas and comparators are strategies.
  • Pick strategies in one place and handle unknown keys explicitly.
  • Keep strategies stateless or immutable and pass per-call data as parameters: they’re shared across threads and requests.
  • Pick the representation by how open the set is: a function type for one method, sealed/enum for a closed set, an interface plus a registry for an open one.
  • Resolve strategies in one place (a DI map, a registry) and treat unknown keys explicitly, never as a silent default.
  • If the objects choose the next implementation themselves, you’re writing State, not Strategy.

Finished this page?Nice work, this page is complete!

Mark it as done to track your progress. It's saved in this browser, no account needed.

Its cards are now in your Bites review ⚡
Esc

Type to search the whole site.