Dart OOP Design Patterns


Object-Oriented Design Patterns in Dart

Object-Oriented Design Patterns are general, reusable solutions to common problems in software design. These patterns help to solve design problems by providing established and well-tested templates for building software components. Some common OOP design patterns are:

  1. Creational Patterns – Deal with object creation mechanisms, trying to create objects in a manner suitable to the situation.
  2. Structural Patterns – Concern how classes and objects are composed to form larger structures.
  3. Behavioral Patterns – Focus on communication between objects, what is the best way to interact between objects in a system.

In this explanation, we'll cover a few commonly used design patterns in Dart with examples.


1. Singleton Pattern (Creational)

The Singleton Pattern ensures that a class has only one instance, and provides a global point of access to it.

Example: Singleton Pattern in Dart

class Singleton { // Private constructor Singleton._privateConstructor(); // The single instance static final Singleton _instance = Singleton._privateConstructor(); // A factory constructor that returns the same instance factory Singleton() { return _instance; } void showMessage() { print("Singleton Pattern: Single instance accessed!"); } } void main() { var s1 = Singleton(); var s2 = Singleton(); s1.showMessage(); print(identical(s1, s2)); // Output: true (both references point to the same instance) }

Output:

Singleton Pattern: Single instance accessed! true

Explanation:

  • The Singleton class has a private constructor _privateConstructor, which prevents external instantiation.
  • The factory constructor returns the same instance (_instance) every time it’s called, ensuring only one object exists.

2. Factory Pattern (Creational)

The Factory Pattern defines an interface for creating an object, but it’s up to the subclass to decide which class to instantiate. It helps when the exact type of the object isn’t known until runtime.

Example: Factory Pattern in Dart

abstract class Animal { void makeSound(); } class Dog implements Animal { @override void makeSound() { print("Bark"); } } class Cat implements Animal { @override void makeSound() { print("Meow"); } } class AnimalFactory { static Animal getAnimal(String type) { if (type == 'dog') { return Dog(); } else if (type == 'cat') { return Cat(); } else { throw Exception("Invalid animal type"); } } } void main() { var dog = AnimalFactory.getAnimal("dog"); dog.makeSound(); // Output: Bark var cat = AnimalFactory.getAnimal("cat"); cat.makeSound(); // Output: Meow }

Output:

Bark Meow

Explanation:

  • AnimalFactory.getAnimal decides which Animal subclass to instantiate based on the provided type.
  • This pattern hides the instantiation logic from the client and centralizes it in the factory.

3. Observer Pattern (Behavioral)

The Observer Pattern is used when an object (subject) needs to notify other objects (observers) about changes in its state without knowing who or what those observers are.

Example: Observer Pattern in Dart

// Observer class abstract class Observer { void update(String message); } // Concrete observer class ConcreteObserver implements Observer { final String name; ConcreteObserver(this.name); @override void update(String message) { print("$name received message: $message"); } } // Subject class class Subject { final List<Observer> _observers = []; void addObserver(Observer observer) { _observers.add(observer); } void removeObserver(Observer observer) { _observers.remove(observer); } void notifyObservers(String message) { for (var observer in _observers) { observer.update(message); } } } void main() { var subject = Subject(); var observer1 = ConcreteObserver("Observer 1"); var observer2 = ConcreteObserver("Observer 2"); subject.addObserver(observer1); subject.addObserver(observer2); subject.notifyObservers("New Event!"); // Both observers will be notified subject.removeObserver(observer1); subject.notifyObservers("Another Event!"); // Only Observer 2 will be notified }

Output:

Observer 1 received message: New Event! Observer 2 received message: New Event! Observer 2 received message: Another Event!

Explanation:

  • The Subject maintains a list of observers and notifies them whenever the state changes.
  • The ConcreteObserver implements the Observer interface to respond to updates.

4. Strategy Pattern (Behavioral)

The Strategy Pattern is used to define a family of algorithms, encapsulate each one, and make them interchangeable. The Strategy allows the algorithm to vary independently from the clients that use it.

Example: Strategy Pattern in Dart

// Strategy interface abstract class SortStrategy { void sort(List<int> list); } // Concrete strategy 1 class QuickSort implements SortStrategy { @override void sort(List<int> list) { print("QuickSort: ${list..sort()}"); } } // Concrete strategy 2 class MergeSort implements SortStrategy { @override void sort(List<int> list) { print("MergeSort: ${list.reversed.toList()}"); } } // Context class class SortContext { SortStrategy? strategy; void setStrategy(SortStrategy strategy) { this.strategy = strategy; } void executeSort(List<int> list) { strategy?.sort(list); } } void main() { var list = [5, 3, 8, 1, 2]; var context = SortContext(); // Using QuickSort context.setStrategy(QuickSort()); context.executeSort(list); // Output: QuickSort: [1, 2, 3, 5, 8] // Using MergeSort context.setStrategy(MergeSort()); context.executeSort(list); // Output: MergeSort: [8, 5, 3, 2, 1] }

Output:

QuickSort: [1, 2, 3, 5, 8] MergeSort: [8, 5, 3, 2, 1]

Explanation:

  • The SortStrategy interface defines a sorting method, while the concrete strategies (QuickSort, MergeSort) implement it.
  • The SortContext class uses the SortStrategy to execute the appropriate sorting algorithm.
  • This pattern allows switching between sorting algorithms at runtime.

5. Decorator Pattern (Structural)

The Decorator Pattern allows you to add behavior to an object dynamically without altering its structure. It is useful when you want to add responsibilities to objects without affecting other objects.

Example: Decorator Pattern in Dart

// Component class abstract class Coffee { double cost(); } // Concrete component class SimpleCoffee implements Coffee { @override double cost() { return 5.0; } } // Decorator class class MilkDecorator implements Coffee { final Coffee coffee; MilkDecorator(this.coffee); @override double cost() { return coffee.cost() + 2.0; // Adding cost of milk } } // Another decorator class class SugarDecorator implements Coffee { final Coffee coffee; SugarDecorator(this.coffee); @override double cost() { return coffee.cost() + 1.0; // Adding cost of sugar } } void main() { Coffee coffee = SimpleCoffee(); print("Cost of Simple Coffee: \$${coffee.cost()}"); coffee = MilkDecorator(coffee); print("Cost of Coffee with Milk: \$${coffee.cost()}"); coffee = SugarDecorator(coffee); print("Cost of Coffee with Milk and Sugar: \$${coffee.cost()}"); }

Output:

Cost of Simple Coffee: $5.0 Cost of Coffee with Milk: $7.0 Cost of Coffee with Milk and Sugar: $8.0

Explanation:

  • The SimpleCoffee class is the base class, and MilkDecorator and SugarDecorator are decorators that modify the behavior of the cost() method.
  • The decorators add additional functionality to the SimpleCoffee object dynamically, without modifying the original class.

Conclusion

Design patterns provide proven solutions to common software design problems. In Dart, object-oriented design patterns like Singleton, Factory, Observer, Strategy, and Decorator help in organizing code for maintainability, flexibility, and scalability. These patterns allow you to build more robust and scalable applications by following best practices that are easy to implement and extend.