Design Patterns: Observer & Strategy
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!
}
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
}
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)
}
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-- ...
}
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!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.