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.
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.
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.
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);
}
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.
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']
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.
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.
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.
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.
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 ScrollPhysics — BouncingScrollPhysics 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.
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.