Available for work
Ankit Ranjan
Back to Deep Dives

The Strategy Pattern in Dart — Swapping Behaviour Without Subclasses

One job, many ways to do it. The strategy pattern pulls the part that varies into its own object — and in Dart, that object is often just a function you hand in.

June 4, 2026 7 topics 7 quiz questions
Share:
1

The problem — one job, many ways to do it

Picture a checkout. You can pay by card, by PayPal, by UPI. The job is always the same — take the money — but the how changes every time. So where does that how live?

The first instinct is one big method with a branch per option.

void checkout(String method, int amount) {
  if (method == 'card') {
    // charge the card...
  } else if (method == 'paypal') {
    // redirect to PayPal...
  } else if (method == 'upi') {
    // open the UPI app...
  }
  // and one more branch every single time
}
It works. Right up until the fifth payment method, and the sixth, and the tax rule that applies to only two of them. The method swells, every edit risks the branches you didn't touch, and testing one path means dragging the whole thing along.

A switch that grows forever, or a slot you plug intoWithout strategycheckout(method) {if (method == card) {...}else if (paypal) {...}else if (upi) {...}else if (applePay) {...}// +1 branch every time}One method does every job. It only grows.With strategyCheckoutstrategy.pay()plug inCardPayPalUPI+ ApplePayNew way? Write a class. Checkout never changes.

The second instinct is a subclass per option — CardCheckout, PayPalCheckout, UpiCheckout. Cleaner at first. But now the behaviour is welded to the class hierarchy, and you can't switch it on a live object. The user changes their mind on the payment screen? You can't swap one subclass for another mid-flight.

Both instincts share the same flaw: the thing that varies is tangled into the thing that doesn't. Let's pull them apart.

2

The pattern — pull the behaviour into its own object

Here's the move. Take the part that varies — the payment behaviour — and lift it into its own object. The checkout stops knowing how to pay. It just holds something that does, and calls it.

There are two roles, and they map onto the two halves of the problem.

The context is the part that stays the same — the checkout flow. It holds a strategy and delegates to it.

The strategy is the part that varies — a tiny interface with one job, and a concrete class for each way of doing it.

// The strategy: an interface with one job
abstract interface class PaymentStrategy {
  void pay(int amount);
}

class CardPayment implements PaymentStrategy {
  @override
  void pay(int amount) => print('Charging card: \$amount');
}

class UpiPayment implements PaymentStrategy {
  @override
  void pay(int amount) => print('Opening UPI for: \$amount');
}

// The context: holds a strategy, never the details
class Checkout {
  final PaymentStrategy strategy;
  Checkout(this.strategy);

  void complete(int amount) => strategy.pay(amount);
}
Context leans on the interface, not the detailsCheckoutthe contextstrategy.pay()PaymentStrategythe interfacevoid pay();depends onCardPaymentpay() {...}PayPalPaymentpay() {...}UpiPaymentpay() {...}implementsSwap which concrete fills the slot. Checkout never notices.

Look at what Checkout depends on: the interface, never the concrete classes. That one detail is the whole payoff. You can add a tenth payment method tomorrow, and Checkout doesn't change a line.

var order = Checkout(CardPayment());
order.complete(500);                  // Charging card: 500

// user switched at the last second — just swap the object
order = Checkout(UpiPayment());
order.complete(500);                  // Opening UPI for: 500
Same context, different strategy, different behaviour. No branches, no subclassing the checkout.

3

In Dart, a strategy is often just a function

Most of the time, though, a full interface is more ceremony than you need. A strategy is behaviour, and behaviour is a function — and in Dart functions are first-class. We leaned on that a few episodes back. This is where it pays off again.

Take sorting. List.sort has no idea how you want things ordered. So you hand it the rule.

final numbers = [3, 1, 2];

numbers.sort((a, b) => a - b);   // ascending  → [1, 2, 3]
numbers.sort((a, b) => b - a);   // descending → [3, 2, 1]

final words = ['bb', 'a', 'ccc'];
words.sort((a, b) => a.length - b.length);   // → ['a', 'bb', 'ccc']
Same call, swap the function, different orderlist.sort( strategy )ascending[3, 1, 2](a, b) => a - b[1, 2, 3]descending[3, 1, 2](a, b) => b - a[3, 2, 1]by length — any type['bb','a','ccc'](a, b) => a.length - b.length['a','bb','ccc']

That comparator is a strategy. Same sort, swap the function, different order. Dart even names the type: Comparator<T>, which is nothing more than int Function(T, T).

And here's the thing — you've been passing strategies for this whole series without calling them that. where takes a predicate strategy. map takes a transform strategy. fold takes a combine strategy. The functional toolkit from the collections episode is the strategy pattern, sold without the label.

4

Class-based strategies — when behaviour carries state

So when do you reach for a whole class instead of a one-line function? The moment the strategy needs to remember something — configuration, dependencies, a name you'll reuse across the app.

A lambda is perfect for a throwaway rule. But a "10% bulk discount" strategy has state: the percentage. A class holds that cleanly.

abstract interface class PricingStrategy {
  int priceFor(int subtotal);
}

class FullPrice implements PricingStrategy {
  @override
  int priceFor(int subtotal) => subtotal;
}

class BulkDiscount implements PricingStrategy {
  final int percent;        // the state a lambda couldn't hold
  BulkDiscount(this.percent);

  @override
  int priceFor(int subtotal) => subtotal - (subtotal * percent ~/ 100);
}
Now the discount is a thing you can construct, configure, store, and pass around — BulkDiscount(10) versus BulkDiscount(25). Try squeezing that into an anonymous function and you'll be capturing variables and losing the name.

Rule of thumb: stateless and short, reach for a function; stateful, configurable, or reused, reach for a class. Both are the exact same pattern. You're only choosing how much structure the behaviour has earned.

5

The real lesson — composition over inheritance

Strategy is one face of a principle you'll hear forever in Flutter circles: favour composition over inheritance. This pattern is what that slogan actually looks like in code.

Inheritance answers "what kind of thing is this?" by making a subclass. That's fine when behaviour varies on one axis. But it rarely does. Payment method and pricing rule and currency — model each axis with subclasses and the count multiplies. Three payments times two pricings is six classes, and the next axis multiplies all six again.

Inheritance multiplies. Composition adds.A subclass per combinationCheckoutCardPayPalUPI+ Full+ DiscEvery combination is a class3 × 2 = 6, and it multipliesOne option, one classCheckoutpay: [ Card ]price: [ Full ]payment optionsCardPayPalUPIpricing optionsFullDiscount3 + 2 = 5, it just adds

Composition asks a different question: "what parts is this made of?" One Checkout, holding a payment strategy and a pricing strategy, each chosen on its own. Three plus two, not three times two. Add a fourth payment method and you write one class — not a fresh row of combinations.

Flutter is built on this from the ground up. You don't subclass Container to get a padded, bordered, rounded box. You wrap a Padding around a DecoratedBox around your child. Widgets are parts you assemble, not types you extend. Strategy is the same instinct, pointed at behaviour instead of layout.

6

Strategy is threaded through all of Flutter

Once the shape clicks, you start spotting strategies everywhere in the framework — including in widgets you already use every day.
ListView.builder takes an itemBuilder — a strategy for how to build each row.
showDialog takes a builder — a strategy for what to put on screen.
• A scrollable takes ScrollPhysicsBouncingScrollPhysics for the iOS feel, ClampingScrollPhysics for Android. Swap the object, swap the behaviour.
sort takes a Comparator. An animation takes a Curve. A text field takes a list of TextInputFormatters.

ListView.builder(
  physics: const BouncingScrollPhysics(),   // scroll strategy
  itemCount: messages.length,
  itemBuilder: (context, i) => MessageTile(messages[i]),  // build strategy
)
None of these are subclasses of the widget that uses them. They're behaviours handed in from outside. That's exactly why one ListView can render a chat, a feed, or a settings page — you give it a different building strategy each time, and the list itself never changes.

7

Choosing a form, and how Strategy relates to its neighbours

So how do you reach for this day to day? Start with the smallest thing that works and grow only when you must.
• A one-off rule → a lambda, written inline.
• A rule you reuse → a named function, passed as a tear-off.
• Behaviour with state or config → a class implementing a small interface.

All three are the strategy pattern. You're only sliding along a scale of how much structure the behaviour has earned. Most Flutter code lives at the top of that list and never says the word "strategy" out loud.

Strategy versus the Observer we just met. Observer is about who gets told when something changes. Strategy is about which behaviour runs when something happens. Different questions — and you'll often use both on one screen: an observer notifies the UI that the cart changed, and a strategy decides how the checkout button behaves.

Strategy and first-class functions. In a language without them, this pattern needs the full interface-and-classes machinery every time. Dart hands you most of it for free, which is why so much Dart code passes a function and never names the pattern at all.

And that's the quiet truth of design patterns in Dart: half of them are already in your hands as functions. Naming them just tells you when you're holding one.

Test your understanding

7 questions

Seven questions on the strategy pattern — the problem it solves, function versus class strategies, composition over inheritance, and where it hides inside Flutter.

Search

Loading search...