Home
Dart
OOP
Flutter
Contact

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.

💡 Dart's Advantage Dart's concise syntax, nullable types, and mixins mean many GoF patterns are simpler here than in Java or C++. You'll often find that Dart's built-in language features already solve the problem a pattern was designed for.

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.

database_service.dart
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:

main.dart
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:

config_service.dart
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
⚠️ Testability Warning Singletons make unit testing harder because they carry state between tests. Consider wrapping the singleton behind an abstract class or using dependency injection so you can swap in a mock during testing.

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:

api_response.dart
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';
}
main.dart
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:

shopping_cart.dart
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:

main.dart
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:

event_bus.dart
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
🚀 Flutter's Built-In Observer Flutter's 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:

payment_strategy.dart
// 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);
  }
}
main.dart
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:

discount_strategy.dart
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/else or switch on 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:

http_client_builder.dart
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,
    );
  }
}
main.dart
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
💡 Flutter Builders Flutter's 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:

analytics.dart
// Your app's internal analytics interface
abstract class AnalyticsService {
  void logEvent(String name, [Map<String, dynamic>? properties]);
  void setUserProperty(String name, String value);
}
third_party_analytics.dart
// 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');
  }
}
analytics_adapter.dart
// 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});
  }
}
main.dart
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
🚀 Dart's Language Features Replace Many Patterns Dart's mixins replace the Strategy pattern in many cases. Named constructors and factory constructors simplify the Factory pattern. Streams replace hand-rolled Observer implementations. Always check if Dart already provides a built-in solution before implementing a pattern from scratch.

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
🚀 Next Step Apply these patterns in a real project — start with Dart Coding Challenges.
K
Kesavaraja Murugesan
Flutter Developer & Educator

Flutter developer with 3 years of experience building production-grade mobile applications. Passionate about teaching clean architecture patterns and helping developers write maintainable, scalable code.

← OOP: Mixins Next: Dart Challenges →