Mastering the Singleton Pattern in Dart
Damola Adekoya

Damola Adekoya @devdammak

About: A full stack developer who love learning new things and everything about JavaScript

Joined:
Feb 1, 2019

Mastering the Singleton Pattern in Dart

Publish Date: Jun 16
1 0

Image descriptionPicture this: You're building a Flutter app for a food delivery service. Your app needs to track user analytics, manage configuration settings, handle API calls, and maintain a logging system. Without proper architecture, you might end up creating multiple instances of these services throughout your app, leading to inconsistent data, memory waste, and debugging nightmares.

I learned this the hard way during my first major Flutter project. I had analytics data scattered across different instances, configuration settings that somehow got out of sync between screens, and log files that were being written to from multiple places simultaneously. The result? A buggy app with memory leaks and data inconsistencies that took weeks to untangle.
This is where the Singleton design pattern becomes your best friend. It's like having a single, trusted manager for critical services in your app – ensuring there's only one instance of important classes and providing global access to them.

What is the Singleton Pattern?

The Singleton pattern is one of the most well-known design patterns in software engineering. At its core, it's deceptively simple: ensure a class has only one instance and provide a global point of access to that instance.
Think of it like having a single CEO in a company. No matter how many departments need to communicate with the CEO, there's only one person in that role. Similarly, a Singleton ensures that no matter how many parts of your app need access to a particular service, there's only one instance of that service running.

When to Use Singleton (The Good Times)

The Singleton pattern shines in several specific scenarios:

  1. Shared Resources Management When you need to manage access to shared resources like files, network connections, or hardware devices, Singleton ensures coordinated access and prevents conflicts.
  2. Configuration Management Application settings and configuration data typically need to be consistent across your entire app. A Singleton configuration manager ensures all parts of your app access the same settings.
  3. Logging Services Centralized logging is crucial for debugging and monitoring. A Singleton logger ensures all log entries go through the same service, maintaining consistency and proper file handling.
  4. Caching Systems Cache managers need to maintain a single source of truth for cached data. Multiple cache instances could lead to inconsistent data and memory waste.
  5. Service Locators When implementing service locator patterns, you typically want a single registry that manages and provides access to various services.

When to Avoid Singleton (The Dark Side)

Despite its usefulness, Singleton can become problematic when overused:

  1. Testing Difficulties Singletons create global state, making unit testing challenging. They can introduce dependencies between tests and make it difficult to isolate components.
  2. Hidden Dependencies When classes use Singletons directly, it's not immediately obvious what dependencies they have, making the code harder to understand and maintain.
  3. Tight Coupling Overuse of Singletons can lead to tightly coupled code where classes depend directly on specific implementations rather than abstractions.
  4. Concurrent Access Issues In multi-threaded environments, Singletons can introduce race conditions and require careful synchronization.
  5. Violating Single Responsibility Principle Singletons often end up doing too much, violating the single responsibility principle and becoming "god objects."

Implementing Singleton in Dart: A Complete Example

enum LogLevel { debug, info, warning, error }

class Logger {
  // Singleton instance
  static final Logger _instance = Logger._internal();

  // Private constructor
  Logger._internal();

  // Factory constructor returns the same instance every time
  factory Logger() => _instance;

  void log(String message, [LogLevel level = LogLevel.info]) {
    final timestamp = DateTime.now().toIso8601String();
    final levelStr = level.toString().split('.').last.toUpperCase();
    print('[$timestamp] [$levelStr] $message');
  }
}


Enter fullscreen mode Exit fullscreen mode

Understanding the Implementation

Let's break down the key components of our Singleton implementation:
1. Private Constructor
The Logger._internal() constructor is private, preventing external instantiation of the class. This is crucial for maintaining the singleton guarantee.
2. Factory Constructor
The factory Logger() constructor is the public interface for getting the singleton instance. It checks if an instance already exists and creates one only if necessary.
3. Static Instance Variable
The _instance variable holds the single instance of the class. It's nullable initially and gets initialized on first access.
4. Thread Safety Considerations
In Dart, the null-aware assignment operator (??=) provides basic thread safety for singleton creation. However, for more complex scenarios, you might need additional synchronization mechanisms.

Alternative Singleton Implementations in Dart

1. Eager Initialization

class EagerSingleton {
  static final EagerSingleton _instance = EagerSingleton._internal();

  EagerSingleton._internal();

  factory EagerSingleton() => _instance;
}

Enter fullscreen mode Exit fullscreen mode

2. Using Static Final

class StaticSingleton {
  static final StaticSingleton instance = StaticSingleton._internal();

  StaticSingleton._internal();
}

Enter fullscreen mode Exit fullscreen mode

*3. With Async Initialization *

class AsyncSingleton {
  static AsyncSingleton? _instance;
  static final Completer<AsyncSingleton> _completer = Completer<AsyncSingleton>();

  AsyncSingleton._internal();

  static Future<AsyncSingleton> getInstance() async {
    if (_instance == null) {
      _instance = AsyncSingleton._internal();
      await _instance!._initialize();
      _completer.complete(_instance);
    }
    return _completer.future;
  }

  Future<void> _initialize() async {
    // Perform async initialization
    await Future.delayed(Duration(seconds: 1));
  }
}
Enter fullscreen mode Exit fullscreen mode

Common Misuse Patterns

1. The "Everything is a Singleton" Anti-pattern

I've seen developers make every service class a Singleton, thinking it's always better to have one instance. This leads to tightly coupled, hard-to-test code.

Bad Example:

// Don't do this!
class UserService {
  static UserService? _instance;
  UserService._internal();
  factory UserService() => _instance ??= UserService._internal();
}

class ProductService {
  static ProductService? _instance;
  ProductService._internal();
  factory ProductService() => _instance ??= ProductService._internal();
}

class CartService {
  static CartService? _instance;
  CartService._internal();
  factory CartService() => _instance ??= CartService._internal();
}
Enter fullscreen mode Exit fullscreen mode

2. Singleton as a Glorified Global Variable

Using Singleton just to avoid passing parameters is a code smell. If you're not truly ensuring single instance semantics, you're probably misusing the pattern.

3. Mutable Singleton State

Making Singleton instances heavily mutable can lead to unexpected behavior and debugging nightmares.

4. Ignoring Disposal and Cleanup

Not properly managing resources in Singletons can lead to memory leaks and resource exhaustion.

Best Practices for Singleton in Dart

1. Use Dependency Injection When Possible

Instead of accessing Singletons directly, consider using dependency injection frameworks like get_it or provider.

// Using get_it for dependency injection
import 'package:get_it/get_it.dart';

final getIt = GetIt.instance;

void setupDependencies() {
  getIt.registerSingleton<Logger>(Logger());
}

class OrderService {
  final Logger logger;
  OrderService({required this.logger});
}
Enter fullscreen mode Exit fullscreen mode

2. Make Singletons Immutable When Possible

If your Singleton doesn't need to change state, make it immutable.

3. Provide Clear Disposal Methods

Always provide ways to clean up resources:

class DatabaseService {
  // ... singleton implementation

  Future<void> dispose() async {
    await _connection?.close();
    _instance = null;
  }
}
Enter fullscreen mode Exit fullscreen mode

4. Consider Using Abstract Interfaces

Make your Singletons implement interfaces to improve testability:

abstract class ILogger {
  void info(String message);
  void error(String message);
}

class Logger implements ILogger {
  // ... singleton implementation
}
Enter fullscreen mode Exit fullscreen mode

5. Document Singleton Behavior

Always document why a class is a Singleton and what guarantees it provides.

Testing Strategies for Singletons

1. Reset Between Tests

void main() {
  tearDown(() {
    // Reset singleton state between tests
    Logger.instance.clearLogs();
  });
}
Enter fullscreen mode Exit fullscreen mode

2. Use Dependency Injection for Testing

class TestableOrderService {
  final ILogger logger;
  TestableOrderService(this.logger);
}
Enter fullscreen mode Exit fullscreen mode

3. Mock Singletons

class MockLogger implements ILogger {
  final List<String> logs = [];

  @override
  void info(String message) => logs.add('INFO: $message');

  @override
  void error(String message) => logs.add('ERROR: $message');
}
Enter fullscreen mode Exit fullscreen mode

Real-world Considerations

In production Flutter apps, I've found that Singleton works well for:

  • Analytics services - You want all events going through the same tracking instance
  • Configuration managers - App settings should be consistent everywhere
  • Cache managers - Shared cache state across the app
  • Network clients - Reusing HTTP clients and connection pools

However, I avoid Singleton for:

  • Business logic services - These should be injected dependencies
  • UI state management - Use proper state management solutions
  • Data repositories - These benefit from dependency injection for testing

Performance Considerations

From my experience with Flutter apps, Singletons can improve performance by:

  • Reducing object creation overhead
  • Sharing expensive resources like network connections
  • Maintaining cache state across the application

However, they can hurt performance if:

  • They hold onto large amounts of memory
  • They prevent garbage collection of unused resources
  • They create bottlenecks in multi-threaded scenarios

Conclusion

The Singleton pattern is a powerful tool that, like any tool, can be both helpful and harmful depending on how it's used. In Dart and Flutter development, it's particularly useful for managing shared resources, configuration, and services that truly benefit from having a single instance.
The key is restraint. Not every class needs to be a Singleton, and global state should be used judiciously. When you do use Singleton, implement it properly with consideration for testing, resource management, and long-term maintainability.
Remember that modern dependency injection frameworks often provide better alternatives to direct Singleton usage, offering the benefits of single instances while maintaining testability and loose coupling.
As with any design pattern, the goal isn't to use Singleton everywhere, but to recognize when it's the right tool for the job and implement it thoughtfully.

Coming up next: In our next article, we'll explore the Factory design pattern in Dart. We'll see how Factory methods can help you create objects without specifying their exact classes, making your code more flexible and easier to extend. We'll build a practical example showing how to create different types of payment processors for our food delivery app, and discuss when Factory patterns shine compared to simple constructors. Stay tuned!

Comments 0 total

    Add comment