Dart Design Patterns
Classic Gang of Four patterns adapted for idiomatic Dart.
Design patterns are reusable solutions to common software design problems. They're not libraries or frameworks — they're blueprints that help you structure code for maintainability, testability, and scalability. The original Gang of Four catalog introduced 23 patterns for C++ and Smalltalk. Dart, with its first-class functions, mixins, named constructors, and async streams, makes many of these patterns trivially implementable — sometimes collapsing a classic multi-class pattern into a few dozen lines.
This tutorial covers six patterns that Dart and Flutter developers encounter daily. Each section explains the problem the pattern solves, provides a complete working example, and describes real-world use cases.
Singleton Pattern
The Singleton pattern ensures a class has only one instance and provides a global point of access to it. This is essential when exactly one object is needed to coordinate actions across the system — for example, a database connection pool, an application configuration, or a logging service.
The Problem
Imagine your app needs to connect to a remote database. Creating multiple connections wastes resources and can cause race conditions. You need to guarantee that every part of your app talks through the same connection instance.
The Dart Solution
Dart doesn't have private constructors in the Java sense, but you can achieve the same effect using a named private constructor inside the class. The key insight is that Dart named constructors can be private (prefixed with _), preventing external instantiation.
class DatabaseService {
// The single instance
static final DatabaseService _instance = DatabaseService._internal();
// Factory constructor returns the existing instance
factory DatabaseService() => _instance;
// Private named constructor prevents external instantiation
DatabaseService._internal();
// Example state
final Map<String, dynamic> _cache = {};
bool _connected = false;
void connect() {
if (!_connected) {
print('Establishing database connection…');
_connected = true;
}
}
dynamic query(String sql) {
print('Executing: $sql');
return _cache[sql];
}
}
Usage — no matter where you call it, you always get the same object:
void main() {
var db1 = DatabaseService();
var db2 = DatabaseService();
db1.connect();
print(db1 == db2); // true — same instance
db1.query('SELECT * FROM users');
}
Lazy Initialization
The example above initializes the singleton eagerly (at first access). For expensive resources, use lazy initialization to defer construction until it's actually needed:
class ConfigService {
static ConfigService? _instance;
factory ConfigService() {
_instance ??= ConfigService._internal();
return _instance!;
}
ConfigService._internal() {
print('Config loaded from disk…');
}
}
When to Use Singleton
- Database connections or connection pools
- Application-wide configuration or feature flags
- Logging services
- In-memory caches that must be shared across modules
Factory Pattern
The Factory pattern delegates object creation to a separate method or class, decoupling the caller from the concrete type being instantiated. Instead of writing new ConcreteType(), you call create() and the factory decides which subclass or implementation to return.
The Problem
When your app consumes an API that returns JSON, the structure of the JSON can vary by endpoint. You might need to parse an User, a Product, or an Order — each with different fields. Creating the right object from raw data is a classic creation problem.
The Dart Solution
Dart's factory keyword makes this especially elegant. A factory constructor can return an existing instance, a subtype, or even a different class entirely:
abstract class ApiResponse {
factory ApiResponse.fromJson(Map<String, dynamic> json) {
final String type = json['type'];
switch (type) {
case 'user':
return UserResponse.fromJson(json);
case 'product':
return ProductResponse.fromJson(json);
case 'error':
return ErrorResponse.fromJson(json);
default:
throw FormatException('Unknown response type: $type');
}
}
String get message;
}
class UserResponse implements ApiResponse {
final String name;
final String email;
UserResponse.fromJson(Map<String, dynamic> json)
: name = json['name'],
email = json['email'];
@override
String get message => 'User: $name';
}
class ProductResponse implements ApiResponse {
final String title;
final double price;
ProductResponse.fromJson(Map<String, dynamic> json)
: title = json['title'],
price = (json['price'] as num).toDouble();
@override
String get message => 'Product: $title ($price)';
}
class ErrorResponse implements ApiResponse {
final String error;
ErrorResponse.fromJson(Map<String, dynamic> json)
: error = json['message'];
@override
String get message => 'Error: $error';
}
void main() {
final jsonData = {'type': 'product', 'title': 'Widget', 'price': 9.99};
final response = ApiResponse.fromJson(jsonData);
print(response.message); // Product: Widget (9.99)
}
When to Use Factory
- Parsing JSON into different model types based on a discriminator field
- Creating platform-specific implementations (e.g., HTTP clients for web vs mobile)
- Object caching — returning a cached instance instead of creating a new one
- When the exact type of object needed is determined at runtime
Observer Pattern
The Observer pattern defines a one-to-many dependency between objects so that when one object changes state, all its dependents are notified and updated automatically. This is the backbone of event-driven architectures and reactive programming.
The Problem
Your app has a shopping cart. When the user adds or removes an item, multiple UI components need to update: the cart badge count, the total price display, and the checkout button state. Hard-coding these dependencies creates tight coupling — the cart class must know about every widget that cares about its state.
The Dart Solution
Dart provides streams and stream controllers natively, which are essentially an implementation of the Observer pattern. For class-level observation without streams, you can maintain a list of listeners and notify them manually:
typedef CartListener = void Function(List<CartItem> items);
class CartItem {
final String name;
final double price;
final int quantity;
const CartItem(this.name, this.price, [this.quantity = 1]);
}
class ShoppingCart {
final List<CartItem> _items = [];
final List<CartListener> _listeners = [];
void addListener(CartListener listener) => _listeners.add(listener);
void removeListener(CartListener listener) => _listeners.remove(listener);
List<CartItem> get items => List.unmodifiable(_items);
double get total => _items.fold(0, (sum, item) => sum + item.price * item.quantity);
void add(CartItem item) {
_items.add(item);
_notifyListeners();
}
void remove(CartItem item) {
_items.remove(item);
_notifyListeners();
}
void _notifyListeners() {
for (var listener in _listeners) {
listener(items);
}
}
}
Multiple widgets subscribe to the same cart instance and react independently when items change:
void main() {
var cart = ShoppingCart();
// Badge counter
cart.addListener((items) {
print('Badge: ${items.length} items');
});
// Total price display
cart.addListener((items) {
var total = items.fold(0.0, (s, i) => s + i.price * i.quantity);
print('Total: \$$total');
});
// Checkout button state
cart.addListener((items) {
print('Checkout enabled: ${items.isNotEmpty}');
});
cart.add(CartItem('Widget', 9.99));
cart.add(CartItem('Gadget', 14.99, 2));
}
Using Streams for Async Observation
For asynchronous scenarios, Dart's StreamController is the idiomatic observer mechanism:
class EventBus {
final _controller = StreamController<dynamic>.broadcast();
Stream<dynamic> get stream => _controller.stream;
void emit(dynamic event) => _controller.add(event);
void dispose() => _controller.close();
}
When to Use Observer
- UI components that need to react to state changes without polling
- Event buses for cross-module communication
- Real-time data feeds (chat messages, stock prices, sensor data)
- Decoupling business logic from presentation layers
ValueNotifier, ChangeNotifier, and StreamBuilder are all built on the Observer pattern. Understanding the raw pattern helps you understand how these Flutter widgets work under the hood.
Strategy Pattern
The Strategy pattern defines a family of interchangeable algorithms, encapsulates each one, and makes them interchangeable at runtime. The client code selects the strategy without knowing the concrete implementation details.
The Problem
Your e-commerce app supports multiple payment methods — credit card, PayPal, and Apple Pay. Writing a massive if/else block inside your checkout logic creates unmaintainable code. Adding a new payment method means modifying the checkout class, violating the Open/Closed Principle.
The Dart Solution
Dart's first-class functions make this pattern incredibly concise. You can pass a function as a strategy without needing a full abstract class hierarchy:
// Strategy as a function type
typedef PaymentStrategy = bool Function(double amount);
// Concrete strategies
bool payWithCreditCard(double amount) {
print('Charging \$$amount to credit card…');
return true;
}
bool payWithPayPal(double amount) {
print('Sending \$$amount via PayPal…');
return true;
}
bool payWithApplePay(double amount) {
print('Processing \$$amount with Apple Pay…');
return true;
}
// Context class that uses a strategy
class CheckoutService {
PaymentStrategy? _paymentStrategy;
void setPaymentStrategy(PaymentStrategy strategy) {
_paymentStrategy = strategy;
}
bool processOrder(double total) {
if (_paymentStrategy == null) {
throw StateError('No payment strategy set');
}
return _paymentStrategy!(total);
}
}
void main() {
var checkout = CheckoutService();
checkout.setPaymentStrategy(payWithCreditCard);
checkout.processOrder(59.99);
// Switch strategy at runtime
checkout.setPaymentStrategy(payWithPayPal);
checkout.processOrder(29.99);
}
Class-Based Strategy
When strategies carry complex state, a class-based approach is cleaner:
abstract class DiscountStrategy {
double apply(double total);
}
class NoDiscount implements DiscountStrategy {
@override
double apply(double total) => total;
}
class PercentageDiscount implements DiscountStrategy {
final double percent;
const PercentageDiscount(this.percent);
@override
double apply(double total) => total * (1 - percent / 100);
}
class FlatDiscount implements DiscountStrategy {
final double amount;
const FlatDiscount(this.amount);
@override
double apply(double total) => (total - amount).clamp(0, total);
}
When to Use Strategy
- Payment processing with multiple providers
- Sorting algorithms selectable at runtime
- Discount or pricing rules in e-commerce
- Validation rules that change per context
- Any scenario where you'd otherwise use a long
if/elseorswitchon a type
Builder Pattern
The Builder pattern separates the construction of a complex object from its representation, allowing the same construction process to create different representations. It's especially useful when an object has many optional parameters or when construction involves multiple steps.
The Problem
You're configuring an HTTP request client with many optional settings: base URL, timeout, headers, interceptors, retry policy, authentication tokens, and more. A constructor with 10+ parameters is unreadable and error-prone. Telescoping constructors or named parameters with defaults only go so far.
The Dart Solution
Dart's named parameters and method chaining make builders natural. You accumulate configuration on a builder, then call build() to produce the final immutable object:
class HttpClient {
final String baseUrl;
final Duration timeout;
final Map<String, String> headers;
final int maxRetries;
final bool enableLogging;
const HttpClient({
required this.baseUrl,
this.timeout = const Duration(seconds: 30),
this.headers = const {},
this.maxRetries = 3,
this.enableLogging = false,
});
String toString() =>
'HttpClient($baseUrl, timeout: $timeout, retries: $maxRetries)';
}
class HttpClientBuilder {
String? _baseUrl;
Duration _timeout = const Duration(seconds: 30);
final Map<String, String> _headers = {};
int _maxRetries = 3;
bool _enableLogging = false;
HttpClientBuilder baseUrl(String url) {
_baseUrl = url;
return this;
}
HttpClientBuilder timeout(Duration duration) {
_timeout = duration;
return this;
}
HttpClientBuilder header(String key, String value) {
_headers[key] = value;
return this;
}
HttpClientBuilder maxRetries(int count) {
_maxRetries = count;
return this;
}
HttpClientBuilder enableLogging([bool enabled = true]) {
_enableLogging = enabled;
return this;
}
HttpClient build() {
if (_baseUrl == null) {
throw StateError('baseUrl is required');
}
return HttpClient(
baseUrl: _baseUrl!,
timeout: _timeout,
headers: Map.unmodifiable(_headers),
maxRetries: _maxRetries,
enableLogging: _enableLogging,
);
}
}
void main() {
var client = HttpClientBuilder()
.baseUrl('https://api.example.com')
.timeout(const Duration(seconds: 10))
.header('Authorization', 'Bearer token123')
.header('Content-Type', 'application/json')
.maxRetries(5)
.enableLogging()
.build();
print(client); // HttpClient(https://api.example.com, …)
}
When to Use Builder
- Objects with many optional configuration parameters
- Complex UI widget trees (Flutter's widget constructors are essentially builders)
- Constructing objects that require validation before instantiation
- When you want immutable objects but need flexible construction
AlertDialog, Container, and many other widgets use a builder-like pattern with named constructor parameters. The QueryBuilder widget in Firebase is a literal implementation of this pattern.
Adapter Pattern
The Adapter pattern converts the interface of a class into another interface the client expects. It lets classes work together that otherwise couldn't because of incompatible interfaces. Think of it as a translator between two systems that speak different languages.
The Problem
You're integrating a third-party analytics SDK that has its own API shape. Your app already has an internal analytics interface. You don't want to scatter the third-party's method calls throughout your codebase — if you switch providers later, you'd have to refactor every call site.
The Dart Solution
Create an adapter that implements your internal interface but delegates to the third-party SDK internally:
// Your app's internal analytics interface
abstract class AnalyticsService {
void logEvent(String name, [Map<String, dynamic>? properties]);
void setUserProperty(String name, String value);
}
// Simulated third-party SDK with a different API shape
class ThirdPartyTracker {
void track(String eventName, Map<String, dynamic> data) {
print('[ThirdParty] track: $eventName with $data');
}
void identify(String userId, Map<String, String> traits) {
print('[ThirdParty] identify: $userId with $traits');
}
}
// The adapter translates our interface to the third-party API
class ThirdPartyAnalyticsAdapter implements AnalyticsService {
final ThirdPartyTracker _tracker;
ThirdPartyAnalyticsAdapter(this._tracker);
@override
void logEvent(String name, [Map<String, dynamic>? properties]) {
// Translate our method name and arguments to the third-party format
_tracker.track(name, properties ?? {});
}
@override
void setUserProperty(String name, String value) {
// Map our setter to their identify method
_tracker.identify('current_user', {name: value});
}
}
void main() {
var tracker = ThirdPartyTracker();
var analytics = ThirdPartyAnalyticsAdapter(tracker);
// Use your own interface — the adapter handles the translation
analytics.logEvent('page_view', {'page': 'home'});
analytics.setUserProperty('subscription', 'premium');
}
When to Use Adapter
- Integrating third-party libraries whose APIs don't match your internal interfaces
- Wrapping legacy APIs so new code can use a clean interface
- Switching providers without changing every call site
- Working with data formats that need to be translated (XML to JSON, old DTO to new model)
When to Use Patterns
Design patterns are tools, not rules. Applying them blindly leads to over-engineered code. Here's a guide for when each pattern earns its keep:
| Pattern | Use When | Don't Use When |
|---|---|---|
| Singleton | Exactly one instance must exist globally (database, config) | You need to swap implementations for testing |
| Factory | Object creation depends on runtime data or type selection | The object is always the same type with no variation |
| Observer | Multiple components must react to state changes | Only one consumer exists — just call it directly |
| Strategy | Algorithm or behavior needs to be swapped at runtime | There's only one algorithm and it won't change |
| Builder | Complex object with many optional parameters | Simple data class with 2–3 fields |
| Adapter | Third-party API doesn't match your internal interface | You control the API and can design it to fit |
The SOLID Connection
Design patterns implement SOLID principles in practice:
- Single Responsibility: Each strategy, observer, or adapter handles one concern
- Open/Closed: Factory and Strategy let you extend behavior without modifying existing code
- Liskov Substitution: Factory-created objects are interchangeable through their shared interface
- Interface Segregation: Adapters expose only the interface the client needs
- Dependency Inversion: High-level modules depend on abstractions (interfaces), not concrete implementations
Summary
- Singleton — one instance, global access. Use for database connections, config, caches
- Factory — delegate creation logic. Use for JSON parsing, platform-specific types
- Observer — notify dependents on state change. Use for event buses, UI reactivity
- Strategy — swap algorithms at runtime. Use for payment, sorting, validation
- Builder — construct complex objects step by step. Use for config objects, HTTP clients
- Adapter — bridge incompatible interfaces. Use for third-party SDK integration