Dart Object-Oriented Programming

Design Patterns: Singleton & Factory

55 min Lesson 18 of 24

What Are Design Patterns?

Design patterns are proven, reusable solutions to common problems in software design. They are not code you copy-paste -- they are templates for solving recurring design challenges. In this lesson, we will explore two of the most fundamental creational patterns: the Singleton (ensuring only one instance exists) and the Factory (delegating object creation to specialized methods).

The Singleton Pattern

A Singleton ensures that a class has exactly one instance throughout the entire application, and provides a global access point to that instance. Use it when you need a single shared resource -- like a database connection pool, a logger, or an app configuration.

Singleton with Factory Constructor (Dart Idiom)

class AppConfig {
  // 1. Private static field holds the single instance
  static final AppConfig _instance = AppConfig._internal();

  // 2. Factory constructor always returns the SAME instance
  factory AppConfig() {
    return _instance;
  }

  // 3. Private named constructor -- called only once
  AppConfig._internal();

  // Instance fields
  String apiBaseUrl = 'https://api.example.com';
  bool debugMode = false;
  int maxRetries = 3;

  void configure({String? apiUrl, bool? debug, int? retries}) {
    if (apiUrl != null) apiBaseUrl = apiUrl;
    if (debug != null) debugMode = debug;
    if (retries != null) maxRetries = retries;
  }
}

void main() {
  // Both variables point to the SAME object
  final config1 = AppConfig();
  final config2 = AppConfig();

  print(identical(config1, config2)); // true -- same instance!

  config1.configure(apiUrl: 'https://prod.api.com', debug: true);
  print(config2.apiBaseUrl); // https://prod.api.com
  print(config2.debugMode);  // true -- changes reflect everywhere
}
How It Works: The factory keyword tells Dart this constructor does not always create a new instance. Instead, it returns _instance, which was created once by the private _internal() constructor. Every call to AppConfig() returns the same object.

Lazy Singleton

The example above is an eager singleton -- the instance is created when the class loads. Sometimes you want a lazy singleton that is only created when first accessed, especially if initialization is expensive.

Lazy Singleton Pattern

class DatabaseConnection {
  // Instance is null until first access
  static DatabaseConnection? _instance;

  // Track connection state
  final String connectionString;
  bool _isConnected = false;

  // Factory constructor with lazy initialization
  factory DatabaseConnection(String connStr) {
    // Create the instance only on first call
    _instance ??= DatabaseConnection._internal(connStr);
    return _instance!;
  }

  DatabaseConnection._internal(this.connectionString);

  Future<void> connect() async {
    if (_isConnected) return;
    print('Connecting to $connectionString...');
    // Simulate connection delay
    await Future.delayed(Duration(milliseconds: 100));
    _isConnected = true;
    print('Connected!');
  }

  bool get isConnected => _isConnected;
}

void main() async {
  // First call creates the instance
  final db1 = DatabaseConnection('postgresql://localhost/mydb');
  await db1.connect();

  // Second call returns the SAME instance (argument is ignored)
  final db2 = DatabaseConnection('mysql://other/db');
  print(identical(db1, db2));   // true
  print(db2.connectionString);  // postgresql://localhost/mydb
  print(db2.isConnected);       // true
}
Warning: Notice that the second call’s argument ('mysql://other/db') is silently ignored because the instance already exists. This is a common source of confusion. If your singleton needs different configurations, consider using a separate initialize() method instead of passing arguments to the constructor.

Singleton with Static Getter

An alternative approach uses a static getter with the late keyword for lazy initialization without a factory constructor:

Static Getter Singleton

class Logger {
  // late + static = lazy initialization
  static late final Logger instance = Logger._();

  Logger._();

  final List<String> _logs = [];

  void log(String message) {
    final timestamp = DateTime.now().toIso8601String();
    final entry = '[$timestamp] $message';
    _logs.add(entry);
    print(entry);
  }

  void warning(String message) => log('WARNING: $message');
  void error(String message) => log('ERROR: $message');

  List<String> get history => List.unmodifiable(_logs);
  void clear() => _logs.clear();
}

void main() {
  // Access via the static getter
  Logger.instance.log('App started');
  Logger.instance.warning('Low memory');

  // Same instance everywhere
  final logger = Logger.instance;
  logger.error('Network failure');

  print('Total logs: ${Logger.instance.history.length}'); // 3
}

The Factory Method Pattern

The Factory Method pattern delegates object creation to a method instead of calling a constructor directly. This lets you return different subtypes based on input, hide complex creation logic, and make your code easier to extend.

Simple Factory with Static Method

abstract class Notification {
  final String title;
  final String body;

  Notification(this.title, this.body);

  void send();

  // Factory method -- returns the right subtype
  static Notification create(String channel, String title, String body) {
    return switch (channel) {
      'email' => EmailNotification(title, body),
      'sms'   => SmsNotification(title, body),
      'push'  => PushNotification(title, body),
      _       => throw ArgumentError('Unknown channel: $channel'),
    };
  }
}

class EmailNotification extends Notification {
  EmailNotification(super.title, super.body);

  @override
  void send() => print('EMAIL: "$title" -- $body');
}

class SmsNotification extends Notification {
  SmsNotification(super.title, super.body);

  @override
  void send() => print('SMS: "$title" -- $body');
}

class PushNotification extends Notification {
  PushNotification(super.title, super.body);

  @override
  void send() => print('PUSH: "$title" -- $body');
}

void main() {
  // Caller does not need to know which class to instantiate
  final channels = ['email', 'sms', 'push'];
  for (final ch in channels) {
    final notification = Notification.create(ch, 'Hello', 'Welcome!');
    notification.send();
  }
  // EMAIL: "Hello" -- Welcome!
  // SMS: "Hello" -- Welcome!
  // PUSH: "Hello" -- Welcome!
}
Tip: The Factory Method is excellent when paired with sealed classes (from the previous lesson). You can create a sealed hierarchy and use a factory method to produce the right subtype while keeping construction logic centralized.

Abstract Factory Pattern

The Abstract Factory takes the concept further -- it defines an interface for creating families of related objects. This is useful when you need to create multiple related objects that must be consistent with each other (like UI themes or platform-specific widgets).

Abstract Factory -- Theme System

// Abstract products
abstract class Button {
  void render();
}

abstract class TextField {
  void render();
}

abstract class Card {
  void render();
}

// Abstract factory
abstract class ThemeFactory {
  Button createButton(String label);
  TextField createTextField(String placeholder);
  Card createCard(String title, String content);

  // Factory method to get the right theme
  static ThemeFactory getTheme(String theme) {
    return switch (theme) {
      'material' => MaterialThemeFactory(),
      'cupertino' => CupertinoThemeFactory(),
      _ => throw ArgumentError('Unknown theme: $theme'),
    };
  }
}

// Material Design family
class MaterialButton extends Button {
  final String label;
  MaterialButton(this.label);

  @override
  void render() => print('[Material Button: $label]');
}

class MaterialTextField extends TextField {
  final String placeholder;
  MaterialTextField(this.placeholder);

  @override
  void render() => print('[Material TextField: $placeholder]');
}

class MaterialCard extends Card {
  final String title, content;
  MaterialCard(this.title, this.content);

  @override
  void render() => print('[Material Card: $title - $content]');
}

class MaterialThemeFactory extends ThemeFactory {
  @override
  Button createButton(String label) => MaterialButton(label);
  @override
  TextField createTextField(String ph) => MaterialTextField(ph);
  @override
  Card createCard(String t, String c) => MaterialCard(t, c);
}

// Cupertino (iOS) family
class CupertinoButton extends Button {
  final String label;
  CupertinoButton(this.label);

  @override
  void render() => print('[Cupertino Button: $label]');
}

class CupertinoTextField extends TextField {
  final String placeholder;
  CupertinoTextField(this.placeholder);

  @override
  void render() => print('[Cupertino TextField: $placeholder]');
}

class CupertinoCard extends Card {
  final String title, content;
  CupertinoCard(this.title, this.content);

  @override
  void render() => print('[Cupertino Card: $title - $content]');
}

class CupertinoThemeFactory extends ThemeFactory {
  @override
  Button createButton(String label) => CupertinoButton(label);
  @override
  TextField createTextField(String ph) => CupertinoTextField(ph);
  @override
  Card createCard(String t, String c) => CupertinoCard(t, c);
}

void main() {
  // Change this one line to switch the entire UI theme
  final factory = ThemeFactory.getTheme('material');

  final btn = factory.createButton('Submit');
  final input = factory.createTextField('Enter email');
  final card = factory.createCard('Welcome', 'Hello there!');

  btn.render();    // [Material Button: Submit]
  input.render();  // [Material TextField: Enter email]
  card.render();   // [Material Card: Welcome - Hello there!]
}
Key Insight: The Abstract Factory guarantees consistency. If you use a MaterialThemeFactory, all your buttons, text fields, and cards will be Material-styled. You cannot accidentally mix Material buttons with Cupertino text fields. In Flutter, this concept underpins the difference between MaterialApp and CupertinoApp.

Anti-Patterns to Avoid

Design patterns are powerful, but misusing them creates more problems than they solve:

Common Anti-Patterns

// ANTI-PATTERN 1: Singleton for everything
// BAD -- making a UserService a singleton creates tight coupling
class UserService {
  static final UserService _i = UserService._();
  factory UserService() => _i;
  UserService._();

  // This is hard to test! You cannot mock or replace it.
  Future<User> getUser(int id) { /* ... */ }
}

// BETTER -- use dependency injection
class UserService {
  final ApiClient _client;
  UserService(this._client); // inject the dependency

  Future<User> getUser(int id) => _client.get('/users/$id');
}

// ANTI-PATTERN 2: God Factory
// BAD -- one factory that creates unrelated objects
class AppFactory {
  static Object create(String type) {
    return switch (type) {
      'user' => User(),
      'logger' => Logger(),
      'database' => Database(),
      'button' => Button(),
      _ => throw Error(),
    };
  }
}

// BETTER -- separate factories for related families
class NotificationFactory { /* email, sms, push */ }
class ThemeFactory { /* buttons, inputs, cards */ }

// ANTI-PATTERN 3: Factory returning concrete type
// BAD -- no benefit over calling constructor directly
class UserFactory {
  static User create(String name) => User(name);
  // This adds indirection with zero benefit
}

// Factories are useful when:
// - You return different subtypes based on input
// - Creation involves complex logic
// - You want to cache/reuse instances
Warning: The number one mistake with Singletons is overuse. Not every shared service should be a Singleton. If a class does not genuinely need to be globally unique, prefer dependency injection. Singletons make testing harder because you cannot easily replace them with mocks. Use Singletons sparingly -- typically for loggers, configuration, and caches.
Best Practice: In Flutter apps, prefer using a dependency injection system like get_it, provider, or riverpod over raw Singletons. These tools give you the benefits of single instances while keeping your code testable and modular.