Skip to content

04 · Design Patterns in Dart

Classes & objects and generics gave you the building blocks. Design patterns are named solutions to recurring structural problems — this module covers four that show up constantly in Dart/Flutter codebases: Singleton, Factory, Observer, and Strategy.

Singleton: exactly one instance

A private constructor plus a factory constructor that always returns the same cached instance.

class AppConfig {
  AppConfig._internal(this.apiUrl); // private -- can't be called from outside
  static final AppConfig _instance = AppConfig._internal('https://api.example.com');
  factory AppConfig() => _instance;
  final String apiUrl;
}

void main() {
  final a = AppConfig();
  final b = AppConfig();
  print('Same instance: ${identical(a, b)}');
}
// Same instance: true

factory constructors are Dart's built-in escape hatch for "don't necessarily create a new instance" — a regular constructor is required to allocate a new object, but a factory can return a cached one, an instance of a subclass, or null (for a nullable return type).

Factory: choosing the concrete type at runtime

Shape.fromType(...) decides which concrete class to instantiate based on a runtime value, so calling code only ever depends on the abstract Shape interface.

abstract class Shape {
  double area();
  factory Shape.fromType(String type, double a, [double b = 0]) {
    switch (type) {
      case 'circle':
        return Circle(a);
      case 'rectangle':
        return Rectangle(a, b);
      default:
        throw ArgumentError('Unknown shape: $type');
    }
  }
}

class Circle implements Shape {
  Circle(this.radius);
  final double radius;
  @override
  double area() => 3.14159 * radius * radius;
}

class Rectangle implements Shape {
  Rectangle(this.width, this.height);
  final double width;
  final double height;
  @override
  double area() => width * height;
}

void main() {
  final shapes = [Shape.fromType('circle', 2), Shape.fromType('rectangle', 3, 4)];
  for (final s in shapes) {
    print('${s.runtimeType} area: ${s.area()}');
  }
}
// Circle area: 12.56636
// Rectangle area: 12.0

Adding a new shape type only requires a new class plus one more case in the factory — every caller that already depends on Shape needs no changes.

Observer: broadcasting state changes

An object (the "subject") keeps a list of listeners and notifies all of them whenever its state changes. This is the pattern behind ChangeNotifier in Flutter, Stream broadcasting, and any pub/sub system.

abstract class Observer {
  void onChanged(int value);
}

class Counter {
  int _value = 0;
  final List<Observer> _observers = [];

  void subscribe(Observer o) => _observers.add(o);

  void increment() {
    _value++;
    for (final o in _observers) {
      o.onChanged(_value);
    }
  }
}

class LoggingObserver implements Observer {
  @override
  void onChanged(int value) => print('Logger: value is now $value');
}

void main() {
  final counter = Counter();
  counter.subscribe(LoggingObserver());
  counter.increment();
  counter.increment();
}
// Logger: value is now 1
// Logger: value is now 2

Strategy: swapping behavior at runtime

An interchangeable algorithm, held as a field so it can change after construction — here, how a Checkout computes its total.

abstract class DiscountStrategy {
  double apply(double price);
}

class NoDiscount implements DiscountStrategy {
  @override
  double apply(double price) => price;
}

class PercentOff implements DiscountStrategy {
  PercentOff(this.percent);
  final double percent;
  @override
  double apply(double price) => price * (1 - percent / 100);
}

class Checkout {
  Checkout(this.strategy);
  DiscountStrategy strategy;
  double total(double price) => strategy.apply(price);
}

void main() {
  final checkout = Checkout(NoDiscount());
  print('No discount: ${checkout.total(100)}');
  checkout.strategy = PercentOff(20);
  print('20% off: ${checkout.total(100)}');
}
// No discount: 100.0
// 20% off: 80.0

The trap: identical() vs == for singleton checks

Verifying a singleton with a == b instead of identical(a, b) is a common mistake — if the class ever overrides == (or mixes in something that does), two different instances could compare equal, hiding a broken singleton (e.g. one where the private constructor got called directly via reflection, or a refactor accidentally removed the factory). identical() checks object identity regardless of any == override, which is the actual property a singleton needs to guarantee.

Cheat sheet

Pattern Problem it solves Dart mechanism
Singleton Exactly one shared instance Private constructor + factory returning a cached field
Factory Pick concrete type at runtime, hide it from callers factory constructor with a switch/lookup
Observer Broadcast state changes to many listeners List of interfaces, loop-and-notify on mutation
Strategy Swap an algorithm without changing the caller Interface held as a mutable field
identical(a, b) True object-identity check Ignores any == override

How It Actually Works

A Dart Singleton implemented via a static final instance = Foo._internal() field relies on the same lazy static-initialization mechanism covered in the classes lesson: the static field is only initialized the first time it's touched, and the Dart runtime guarantees that initialization happens exactly once per isolate — this "per isolate" qualifier is not a footnote, it's the actual mechanism: because isolates share no memory (see the isolates lesson), a "singleton" in Dart is only a singleton within one isolate's memory space. Spawn a second isolate that imports the same class, and it gets its own, entirely separate singleton instance — there's no cross-isolate enforcement possible without explicit message-passing.

identical() checks true reference/pointer equality — the VM compares the two operands' underlying object addresses (or, for eligible small immutable objects, canonicalized identity), completely bypassing any overridden ==. This is exactly why identical() is the correct way to verify singleton-ness: even if a class's == were (mis)overridden to compare fields and return true for two structurally-identical-but-distinct instances, identical() would correctly report false, because it's checking the one thing that actually defines "is this the same singleton object" — its allocation identity.

Observer's notifyListeners() iterating a mutable listener list while a listener callback adds/removes another listener is a genuine concurrent-modification hazard at the Dart language level — List iteration uses an internal cursor/index that the mutation invalidates, which is why Dart's own ChangeNotifier implementation (and well-written custom observers) iterate over a defensive copy of the listener list rather than the live list itself.

Exercise

Implement the Strategy pattern for a Logger class with a mutable LogFormatter field. Write two formatters — PlainFormatter (returns the message unchanged) and JsonFormatter (returns {"message": "$msg", "level": "$level"}) — and a Logger.log(String level, String message) that prints formatter.format(level, message). Then add a Factory constructor LogFormatter.fromName(String name) that returns the right formatter for 'plain' or 'json', throwing ArgumentError for anything else, and use it to build a Logger from a string read at "runtime" (a hardcoded variable is fine).