Dart Object-Oriented Programming

Design Patterns: Observer & Strategy

55 min Lesson 19 of 24

Behavioral Design Patterns

In the previous lesson, we explored creational patterns (Singleton, Factory) that focus on how objects are created. Now we turn to behavioral patterns that focus on how objects communicate and share responsibilities. The Observer pattern handles one-to-many notifications, while the Strategy pattern makes algorithms interchangeable at runtime.

The Observer Pattern

The Observer pattern defines a one-to-many dependency between objects: when one object (the subject) changes state, all its dependents (the observers) are notified automatically. This is the foundation of event systems, reactive programming, and Flutter’s ChangeNotifier.

Basic Observer Pattern

// The contract every observer must fulfill
abstract class Observer<T> {
  void onUpdate(T data);
}

// The subject that observers subscribe to
class Subject<T> {
  final List<Observer<T>> _observers = [];
  T? _state;

  T? get state => _state;

  void addObserver(Observer<T> observer) {
    if (!_observers.contains(observer)) {
      _observers.add(observer);
    }
  }

  void removeObserver(Observer<T> observer) {
    _observers.remove(observer);
  }

  void notify(T data) {
    _state = data;
    // Create a copy to avoid issues if observers add/remove during iteration
    for (final observer in List.of(_observers)) {
      observer.onUpdate(data);
    }
  }

  int get observerCount => _observers.length;
}

// Concrete subject: a temperature sensor
class TemperatureSensor extends Subject<double> {
  void updateReading(double celsius) {
    print('Sensor: new reading = ${celsius.toStringAsFixed(1)} C');
    notify(celsius);
  }
}

// Concrete observers
class DisplayPanel implements Observer<double> {
  final String name;
  DisplayPanel(this.name);

  @override
  void onUpdate(double celsius) {
    print('  [$name] Temperature: ${celsius.toStringAsFixed(1)} C');
  }
}

class AlarmSystem implements Observer<double> {
  final double threshold;
  AlarmSystem(this.threshold);

  @override
  void onUpdate(double celsius) {
    if (celsius > threshold) {
      print('  [ALARM] Temperature ${celsius.toStringAsFixed(1)} C exceeds threshold ${threshold.toStringAsFixed(1)} C!');
    }
  }
}

void main() {
  final sensor = TemperatureSensor();
  final display = DisplayPanel('Main Display');
  final alarm = AlarmSystem(30.0);

  sensor.addObserver(display);
  sensor.addObserver(alarm);

  sensor.updateReading(25.5);
  // Sensor: new reading = 25.5 C
  //   [Main Display] Temperature: 25.5 C

  sensor.updateReading(35.2);
  // Sensor: new reading = 35.2 C
  //   [Main Display] Temperature: 35.2 C
  //   [ALARM] Temperature 35.2 C exceeds threshold 30.0 C!
}
Key Concept: The sensor does not know or care what its observers do. The display shows the temperature; the alarm checks a threshold. You can add a logger, a graph plotter, or any other observer without changing the sensor code. This is the Open/Closed Principle in action.

Event Bus Pattern

A more flexible version of Observer is the Event Bus -- a centralized hub where any part of your app can publish events and any other part can subscribe to specific event types. This decouples publishers and subscribers completely.

Type-Safe Event Bus

// Base event type
abstract class AppEvent {
  final DateTime timestamp;
  AppEvent() : timestamp = DateTime.now();
}

// Concrete events
class UserLoggedIn extends AppEvent {
  final String username;
  UserLoggedIn(this.username);
}

class OrderPlaced extends AppEvent {
  final String orderId;
  final double total;
  OrderPlaced(this.orderId, this.total);
}

class ErrorOccurred extends AppEvent {
  final String message;
  ErrorOccurred(this.message);
}

// Type alias for event handlers
typedef EventHandler<T extends AppEvent> = void Function(T event);

// The Event Bus
class EventBus {
  // Map from event type to list of handlers
  final Map<Type, List<Function>> _handlers = {};

  // Subscribe to a specific event type
  void on<T extends AppEvent>(EventHandler<T> handler) {
    _handlers.putIfAbsent(T, () => []).add(handler);
  }

  // Unsubscribe a handler
  void off<T extends AppEvent>(EventHandler<T> handler) {
    _handlers[T]?.remove(handler);
  }

  // Publish an event to all subscribers of that type
  void emit<T extends AppEvent>(T event) {
    final handlers = _handlers[T];
    if (handlers != null) {
      for (final handler in List.of(handlers)) {
        (handler as EventHandler<T>)(event);
      }
    }
  }

  // Clear all handlers
  void dispose() => _handlers.clear();
}

void main() {
  final bus = EventBus();

  // Subscribe to different event types
  bus.on<UserLoggedIn>((event) {
    print('Welcome, ${event.username}!');
  });

  bus.on<OrderPlaced>((event) {
    print('Order ${event.orderId}: \$${event.total.toStringAsFixed(2)}');
  });

  bus.on<ErrorOccurred>((event) {
    print('ERROR: ${event.message}');
  });

  // Any part of the app can emit events
  bus.emit(UserLoggedIn('alice'));
  bus.emit(OrderPlaced('ORD-001', 49.99));
  bus.emit(ErrorOccurred('Payment failed'));

  // Welcome, alice!
  // Order ORD-001: $49.99
  // ERROR: Payment failed
}
Tip: In Flutter, the ChangeNotifier class is essentially an Observer implementation. When you call notifyListeners(), all registered listeners (widgets) rebuild. Libraries like bloc and riverpod use similar event-driven architectures internally.

The Strategy Pattern

The Strategy pattern defines a family of algorithms, encapsulates each one as a separate class, and makes them interchangeable. The client code can switch algorithms at runtime without changing its own logic. This is perfect when you have multiple ways to do something and want to choose at runtime.

Payment Strategy

// Strategy interface
abstract class PaymentStrategy {
  String get name;
  bool validate();
  Future<bool> processPayment(double amount);
}

// Concrete strategies
class CreditCardPayment implements PaymentStrategy {
  final String cardNumber;
  final String expiryDate;

  CreditCardPayment(this.cardNumber, this.expiryDate);

  @override
  String get name => 'Credit Card';

  @override
  bool validate() {
    return cardNumber.length == 16 && expiryDate.contains('/');
  }

  @override
  Future<bool> processPayment(double amount) async {
    print('Processing \$$amount via Credit Card ending in ${cardNumber.substring(12)}');
    await Future.delayed(Duration(milliseconds: 100));
    return true;
  }
}

class PayPalPayment implements PaymentStrategy {
  final String email;

  PayPalPayment(this.email);

  @override
  String get name => 'PayPal';

  @override
  bool validate() => email.contains('@');

  @override
  Future<bool> processPayment(double amount) async {
    print('Processing \$$amount via PayPal ($email)');
    await Future.delayed(Duration(milliseconds: 100));
    return true;
  }
}

class CryptoPayment implements PaymentStrategy {
  final String walletAddress;

  CryptoPayment(this.walletAddress);

  @override
  String get name => 'Cryptocurrency';

  @override
  bool validate() => walletAddress.startsWith('0x') && walletAddress.length == 42;

  @override
  Future<bool> processPayment(double amount) async {
    print('Processing \$$amount via Crypto to ${walletAddress.substring(0, 10)}...');
    await Future.delayed(Duration(milliseconds: 200));
    return true;
  }
}

// Context class that uses the strategy
class PaymentProcessor {
  PaymentStrategy? _strategy;

  void setStrategy(PaymentStrategy strategy) {
    _strategy = strategy;
  }

  Future<bool> checkout(double amount) async {
    if (_strategy == null) {
      print('No payment method selected!');
      return false;
    }

    print('--- Checkout: \$$amount ---');
    print('Method: ${_strategy!.name}');

    if (!_strategy!.validate()) {
      print('Validation failed for ${_strategy!.name}');
      return false;
    }

    return await _strategy!.processPayment(amount);
  }
}

void main() async {
  final processor = PaymentProcessor();

  // User selects credit card
  processor.setStrategy(CreditCardPayment('4111111111111234', '12/25'));
  await processor.checkout(99.99);
  // --- Checkout: $99.99 ---
  // Method: Credit Card
  // Processing $99.99 via Credit Card ending in 1234

  // User switches to PayPal
  processor.setStrategy(PayPalPayment('alice@example.com'));
  await processor.checkout(49.50);
  // --- Checkout: $49.50 ---
  // Method: PayPal
  // Processing $49.50 via PayPal (alice@example.com)
}
Warning: Do not confuse Strategy with simple if/else branching. If you have a switch statement that picks behavior based on a type string, and that logic is used in multiple places, that is a code smell that the Strategy pattern can fix. But if the logic exists in only one place and is simple, a switch statement is perfectly fine.

Sorting Strategy Example

Another classic use case is interchangeable sorting algorithms:

Sorting with Strategy Pattern

// Strategy interface for sorting
abstract class SortStrategy<T> {
  String get algorithmName;
  List<T> sort(List<T> items, int Function(T a, T b) compare);
}

class BubbleSort<T> implements SortStrategy<T> {
  @override
  String get algorithmName => 'Bubble Sort';

  @override
  List<T> sort(List<T> items, int Function(T a, T b) compare) {
    final result = List.of(items);
    for (int i = 0; i < result.length - 1; i++) {
      for (int j = 0; j < result.length - i - 1; j++) {
        if (compare(result[j], result[j + 1]) > 0) {
          final temp = result[j];
          result[j] = result[j + 1];
          result[j + 1] = temp;
        }
      }
    }
    return result;
  }
}

class QuickSort<T> implements SortStrategy<T> {
  @override
  String get algorithmName => 'Quick Sort';

  @override
  List<T> sort(List<T> items, int Function(T a, T b) compare) {
    final result = List.of(items);
    _quickSort(result, 0, result.length - 1, compare);
    return result;
  }

  void _quickSort(List<T> arr, int low, int high, int Function(T, T) compare) {
    if (low < high) {
      int pivotIndex = _partition(arr, low, high, compare);
      _quickSort(arr, low, pivotIndex - 1, compare);
      _quickSort(arr, pivotIndex + 1, high, compare);
    }
  }

  int _partition(List<T> arr, int low, int high, int Function(T, T) compare) {
    T pivot = arr[high];
    int i = low - 1;
    for (int j = low; j < high; j++) {
      if (compare(arr[j], pivot) <= 0) {
        i++;
        final temp = arr[i];
        arr[i] = arr[j];
        arr[j] = temp;
      }
    }
    final temp = arr[i + 1];
    arr[i + 1] = arr[high];
    arr[high] = temp;
    return i + 1;
  }
}

// Context
class DataProcessor<T> {
  SortStrategy<T> _strategy;

  DataProcessor(this._strategy);

  void setStrategy(SortStrategy<T> strategy) {
    _strategy = strategy;
  }

  List<T> process(List<T> data, int Function(T a, T b) compare) {
    print('Sorting with ${_strategy.algorithmName}...');
    final sorted = _strategy.sort(data, compare);
    return sorted;
  }
}

void main() {
  final data = [38, 27, 43, 3, 9, 82, 10];

  final processor = DataProcessor<int>(BubbleSort());
  print(processor.process(data, (a, b) => a.compareTo(b)));
  // Sorting with Bubble Sort...
  // [3, 9, 10, 27, 38, 43, 82]

  // Switch to quicksort for larger datasets
  processor.setStrategy(QuickSort());
  print(processor.process(data, (a, b) => a.compareTo(b)));
  // Sorting with Quick Sort...
  // [3, 9, 10, 27, 38, 43, 82]
}

Combining Observer and Strategy

Real applications often combine patterns. Here is a notification system that uses Observer for delivery and Strategy for formatting:

Combined Pattern -- Notification System

// Strategy: How to format the message
abstract class MessageFormatter {
  String format(String title, String body, DateTime time);
}

class PlainTextFormatter implements MessageFormatter {
  @override
  String format(String title, String body, DateTime time) {
    return '$title\n$body\n-- ${time.toIso8601String()}';
  }
}

class HtmlFormatter implements MessageFormatter {
  @override
  String format(String title, String body, DateTime time) {
    return '<h1>$title</h1><p>$body</p><small>$time</small>';
  }
}

class JsonFormatter implements MessageFormatter {
  @override
  String format(String title, String body, DateTime time) {
    return '{"title":"$title","body":"$body","time":"$time"}';
  }
}

// Observer: Who receives the notification
abstract class NotificationChannel {
  final String name;
  MessageFormatter formatter;

  NotificationChannel(this.name, this.formatter);

  void receive(String title, String body) {
    final formatted = formatter.format(title, body, DateTime.now());
    deliver(formatted);
  }

  void deliver(String formattedMessage);
}

class EmailChannel extends NotificationChannel {
  EmailChannel(MessageFormatter fmt) : super('Email', fmt);

  @override
  void deliver(String msg) => print('  [Email] $msg');
}

class SlackChannel extends NotificationChannel {
  SlackChannel(MessageFormatter fmt) : super('Slack', fmt);

  @override
  void deliver(String msg) => print('  [Slack] $msg');
}

// Subject: The notification dispatcher
class NotificationHub {
  final List<NotificationChannel> _channels = [];

  void addChannel(NotificationChannel channel) => _channels.add(channel);
  void removeChannel(NotificationChannel channel) => _channels.remove(channel);

  void broadcast(String title, String body) {
    print('Broadcasting: "$title"');
    for (final channel in _channels) {
      channel.receive(title, body);
    }
  }
}

void main() {
  final hub = NotificationHub();

  // Each channel can use a different formatting strategy
  hub.addChannel(EmailChannel(HtmlFormatter()));
  hub.addChannel(SlackChannel(PlainTextFormatter()));

  hub.broadcast('Deploy Complete', 'Version 2.1.0 is live!');
  // Broadcasting: "Deploy Complete"
  //   [Email] <h1>Deploy Complete</h1><p>Version 2.1.0 is live!</p>...
  //   [Slack] Deploy Complete\nVersion 2.1.0 is live!\n-- ...
}
Pattern Synergy: The Observer pattern handles who gets notified (channels subscribe/unsubscribe). The Strategy pattern handles how messages are formatted (each channel can use a different formatter). Combining them creates a flexible, extensible system where you can add new channels and new formatters independently.
Real-World Flutter: In Flutter, ChangeNotifier + Provider is essentially Observer. The AnimationController with different Curve objects is essentially Strategy. ListView.builder with a custom itemBuilder callback is also Strategy (the list delegates item creation to your callback). You use these patterns every day in Flutter without even realizing it!
Common Mistake: Avoid creating an overly complex event system when simple callbacks would suffice. If you only have one subscriber, a direct callback (VoidCallback or ValueChanged<T>) is simpler and clearer than a full Observer setup. Use the Observer pattern when you truly have multiple subscribers that change dynamically at runtime.