Object-Oriented Design Patterns in dart

Design patterns are like recipes for solving recurring problems in software development. They are tried-and-true solutions that make our code more flexible, maintainable, and scalable.

Design patterns:

  • Reusable solutions to recurring problems in software design.

  • Provide adaptable blueprints for building well-structured, maintainable, and efficient code.

  • Act as a shared vocabulary among developers.

There are three categories of software design patterns:

  1. Creational Patterns - which focuses on how objects are created.

  2. Structural Patterns - which focuses on how objects are combined or connected.

  3. Behavioural Patterns - which focuses on how objects communicate or talk with each other.

This article explores several dart design patterns such as Strategy, Singleton, Factory and Abstract Factory, Prototyping, Adapter, Builder, Decorator, Command and Proxy.

Singleton Pattern

The single pattern is one of the creational patterns. It is a common way of defining manager classes in Dart. A singleton class ensures that only one instance of itself should exist and provides a global access point to itself.

Assuming you have a class responsible for managing database configuration, you can use a singleton class because you may need just one instance of that class to hold all information about your DB.

Here is an example:

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

  static Singleton getInstance() {
    return _instance;
  }

  Singleton._internal();
}

void main() {
  var singleton1 = Singleton.getInstance();
  var singleton2 = Singleton.getInstance();

  print(identical(singleton1, singleton2)); // true
}

Strategy Pattern

The strategy pattern is a behavioural pattern and is used in day-to-day programming and can be used out of intuition even without knowing it.

The strategy patterns allow you to define a class that encapsulates methods that can be implemented in several ways.

As the name suggests, it involves selecting an algorithm that can be interchanged by defining the methods that all strategies must implement.

Here is an example:

abstract class EthSignStrategy {
  String getAddress();
  Uint8List sign<T>(Uint8List hash, {T? option});
}

class HDWalletSignStrategy implements EthSignStrategy {
  final String _hdWalletSeed;

  HDWalletSignStrategy()
      : _hdWalletSeed = bip39.mnemonicToSeedHex(bip39.generateMnemonic());

  EthPrivateKey _getPrivateKey(int option) {
    final path = "m/44'/60'/0'/0/$option";
    final chain = bip44.Chain.seed(_hdWalletSeed);
    final hdKey = chain.forPath(path) as bip44.ExtendedPrivateKey;
    return EthPrivateKey.fromHex(hdKey.privateKeyHex());
  }

  @override
  Uint8List sign<T>(Uint8List hash, {T? option}) {
    return _getPrivateKey(option as int? ?? 0)
        .signPersonalMessageToUint8List(hash);
  }

  @override
  String getAddress() {
    return _getPrivateKey(0).address.hex;
  }
}

class PrivateKeySignStrategy implements EthSignStrategy {
  final privateKey = EthPrivateKey.createRandom(Random.secure());

  @override
  String getAddress() {
    return privateKey.address.hex;
  }

  @override
  Uint8List sign<T>(Uint8List hash, {T? option}) {
    return privateKey.signPersonalMessageToUint8List(hash);
  }
}

Here, we define an interface for signing an Eth message hash and then provide varying implementations. One uses a simple credential or private key, and the other uses a hierarchical deterministic wallet.

Builder Pattern

The builder pattern also a creational pattern, follows a block-building approach. It lets you construct complex dart objects step-by-step. The construction process uses easy-to-communicate method names while hiding the object's construction details, promoting encapsulation and maintainability.

Assuming you want to query blockchain data in a particular provider from a group of providers that provide different ways to serve blockchain data, you can use the builder to reach your target query.

example:

class AlchemyApi {
  final String? rpc;
  final String? api;

  AlchemyApi(this.rpc, this.api);

  String getBalance(String address) {
    return 'Balance for $address using Alchemy API';
  }
}

class ChainBaseApi {
  final String? api;

  ChainBaseApi(this.api);

  String getBalance(String address) {
    return 'Balance for $address using Chainbase API';
  }
}

class QueryBuilder {
  String? _network;
  String? _apiKey;
  String? _rpc;

  QueryBuilder withMainnet() {
    _network = 'mainnet';
    return this;
  }

  QueryBuilder withTestnet() {
    _network = 'testnet';
    return this;
  }

  QueryBuilder withRpc(String rpc) {
    _rpc = rpc;
    return this;
  }

  AlchemyApi withAlchemyApi({String? apiKey}) {
    _apiKey = apiKey;
    return AlchemyApi(_rpc, _apiKey);
  }

  ChainBaseApi withChainBaseApi() {
    return ChainBaseApi(_apiKey);
  }
}

void main() {
  // usage
  var balance = QueryBuilder()
      .withMainnet()
      .withRpc('https://eth-mainnet.alchemyapi.io/v2/YOUR_ALCHEMY_API_KEY')
      .withAlchemyApi()
      .getBalance('0x123456789');
}

Proxy Pattern

The proxy pattern is a structural pattern that allows a secondary object to delegate internal responsibilities to a primary object known as the real object. Proxies do not hold the implementation logic but forward requests to their respective implementation. A proxy Can introduce additional functionality or control access without modifying the original object.

Suppose you want to test your shiny new REST API in some parts of your code. You can add a proxy to redirect users to your new resource while keeping the old one.

abstract class ApiService {
  Future<String> fetchData();
}

class OldApiService implements ApiService {
  @override
  Future<String> fetchData() async {
    // Simulate old API response
    await Future.delayed(const Duration(seconds: 1));
    return "Data from old API";
  }
}

class NewApiService implements ApiService {
  @override
  Future<String> fetchData() async {
    // Simulate new API response
    await Future.delayed(const Duration(seconds: 2));
    return "Data from new API";
  }
}

class ApiProxy implements ApiService {
  final ApiService _targetService; 

  ApiProxy({bool useNewApi = false}) : _targetService = useNewApi ? NewApiService() : OldApiService();

  @override
  Future<String> fetchData() async {
    return await _targetService.fetchData();
  }
}

Adapter Pattern

In software development, there are cases where you need to interoperate between classes of no relationship or between legacy code and new code. Adapters do just that. They primarily allow different objects with different interfaces to interoperate, acting as a translator for these objects to work together.

Similar to decorators, adapters also fall into the structural category.

Assuming you have been working with a NoSql database like MongoDB, and now you want a change. You want to use Postgres instead, you can modify everywhere your code touches NoSql or you can write an adapter. Here is an example:

// Existing MongoDB interface
class MongoDB {
  Map<String, dynamic> findOne(String userId) {
    // MongoDB findOne logic for user retrieval
    print('MongoDB findOne executed for userId: $userId');
    return {'userId': '123', 'name': 'John Doe', 'email': 'john.doe@example.com'};
  }
}

// New PostgreSQL interface
class PostgreSQL {
  Map<String, dynamic> selectOne(String table, Map<String, dynamic> criteria) {
    // SELECT * FROM <table> WHERE <criteria> LIMIT 1;
    print('PostgreSQL selectOne executed on table: $table with criteria: $criteria');
    return {'result': 'PostgreSQL result'};
  }
}

// Adapter to make PostgreSQL compatible with MongoDB's findOne
class Adapter implements MongoDB {
  final PostgreSQL postgreSQL;

  Adapter(this.postgreSQL);

  @override
  Map<String, dynamic> findOne(String userId) {
    // Adapt the MongoDB findOne query to PostgreSQL's selectOne format
    final tableName = 'users'; // Assuming users table
    final criteria = {'userId': userId};

    // Call the PostgreSQL selectOne
    final postgresResult = postgreSQL.selectOne(tableName, criteria);

    // Adapt the PostgreSQL result to MongoDB format
    final res = {'userId': postgresResult['result']['userId']};

    return res;
  }
}

void main() {
  // Example usage
  final postgreSQL = PostgreSQL();
  final postgreSQLAdapter = Adapter(postgreSQL);

  final res = postgreSQLAdapter.findOne('123');
  print('Adapter result: $res');
}

Prototyping Pattern

The Prototyping Pattern is a creational pattern that deals with making photocopies of an object. It allows you to create new objects by copying an existing object, which can be incredibly useful for efficiently cloning objects.

abstract class Prototype {
  Prototype clone();
}

class ConcretePrototype implements Prototype {
  String property1;
  int property2;

  ConcretePrototype({this.property1, this.property2});

  @override
  ConcretePrototype clone() {
    return ConcretePrototype(
      property1: property1,
      property2: property2,
    );
  }
}

Command Pattern

The command pattern is a behavioural pattern whereby an object encapsulates all the information needed to perform an action or trigger an event at a later time. A request is wrapped under an object as a command and passed to the invoker object.

For instance, you want to create a video player that can pause, play, or stop a video. The actions in this context are pause, play and stop. Clicking pause means sending a request. The pause request is then wrapped as a command and sent to an invoker. The invoker executes the pause request.

abstract class Command {
  void execute();
}

// Concrete Command classes
class PlayCommand implements Command {
  final VideoPlayer videoPlayer;

  PlayCommand(this.videoPlayer);

  @override
  void execute() {
    videoPlayer.play();
  }
}

class PauseCommand implements Command {
  final VideoPlayer videoPlayer;

  PauseCommand(this.videoPlayer);

  @override
  void execute() {
    videoPlayer.pause();
  }
}

class StopCommand implements Command {
  final VideoPlayer videoPlayer;

  StopCommand(this.videoPlayer);

  @override
  void execute() {
    videoPlayer.stop();
  }
}

// Receiver class
class VideoPlayer {
  void play() {
    print('Video is playing');
  }

  void pause() {
    print('Video is paused');
  }

  void stop() {
    print('Video has stopped');
  }
}

// Invoker class
class RemoteControl {
  Command _command;

  void setCommand(Command command) {
    _command = command;
  }

  void pressButton() {
    _command.execute();
  }
}

Factory Method Pattern

The Factory Design Pattern is a simple creational design pattern that provides an interface for creating instances of a class, but allows subclasses to alter the type of instances that will be created. In Dart, this can be achieved using a factory constructor.

Factory pattern removes the instantiation of actual implementation classes; We can create an object without exposing the creation logic to the client and refer to newly created object using a common interface.

class Mercedes {
  String engineType;
  int speed;
  String color;

  Mercedes(this.engineType, this.speed, this.color);

  void drive() {
    print("driving");
  }
}

class MercedesFactory {
  Mercedes createCar(String engineType, int speed, String color) {
    return Mercedes(engineType, speed, color);
  }
}

// usage
void main() {
  MercedesFactory factory = MercedesFactory();
  Mercedes car = factory.createCar('V8', 300, "red");
  car.drive();
}

Abstract Factory Pattern

The Abstract Factory Design Pattern (also known as Factory of Factories) is an extension of the Factory Design Pattern. It provides an interface for creating families of related or dependent objects without specifying their concrete classes.

It is a super-factory which creates other factories and can be implemented using an abstract classes or interface.

abstract class Car {
  String engineType;
  int speed;
  String color;

  Car(this.engineType, this.speed, this.color);

  void drive();
}

class Mercedes extends Car {
  Mercedes(String engineType, int speed, String color) : super(engineType, speed, color);

  @override
  void drive() {
    print('Mercedes Car is driving');
  }
}

class Ford extends Car {
  Ford(String engineType, int speed, String color) : super(engineType, speed, color);

  @override
  void drive() {
    print('Ford Car is driving');
  }
}

abstract class CarFactory {
  Car createCar(String engineType, int speed, String color);
}

class MercedesFactory implements CarFactory {
  @override
  Car createCar(String engineType, int speed, String color) {
    return Mercedes(engineType, speed, color);
  }
}

class FordFactory implements CarFactory {
  @override
  Car createCar(String engineType, int speed, String color) {
    return Ford(engineType, speed, color);
  }
}

void main() {
  CarFactory mercedesFactory = MercedesFactory();
  Car mercedesCar = mercedesFactory.createCar('V8', 200, 'Silver');
  mercedesCar.drive();

  CarFactory fordFactory = FordFactory();
  Car fordCar = fordFactory.createCar('V6', 180, 'Blue');
  fordCar.drive();
}

Decorator Pattern

Decorators (also known Higher Order Functions) in Dart are very handy. They let you extend the functionality of an existing object at compile time without directly modifying the underlying object. They wrap your component and inject their behaviour. Decorators are used to add authentication, logging or even modify data before and after its usage, similar to the functioning of hooks.

Decorators fall in the category of structural patterns.

For example, assuming you want to secure your core logic with biometric authentication without modifying any part of your core logic, you can use decorators this way:

class Authenticate {
  const Authenticate();

  Future<bool> call(Function() function) async {
    final localAuth = LocalAuthentication();

    try {
      bool canCheckBiometrics = await localAuth.canCheckBiometrics;
      if (canCheckBiometrics) {
        bool didAuthenticate = await localAuth.authenticate(
          localizedReason: 'Please authenticate to access this feature',
          options: const AuthenticationOptions(
            useErrorDialogs: true,
            stickyAuth: true,
          ),
        );
        if (didAuthenticate) {
          return function(); // Execute the decorated function
        } else {
          print('Authentication failed');
          return false;
        }
      } else {
        print('Biometric authentication is not available');
        return false;
      }
    } on PlatformException catch (e) {
      print('Error during authentication: $e');
      return false;
    }
  }
}

@Authenticate()
void accessSensitiveData() {
  // Sensitive code here
  print('Accessing sensitive data...');
}

Choosing Between Patterns in Dart

While design patterns can be helpful, they come with their set of best practices and potential pitfalls.

  • Strategy Pattern: Use when you need to switch between different algorithms dynamically.

  • Singleton Pattern: Ideal for scenarios where only one instance of a class is required, such as managing global configurations.

  • Factory and Abstract Factory Patterns: Employ when you want to delegate the responsibility of object creation to a separate class or when you are dealing with families of related objects.

  • Prototyping Pattern: Useful when you want to efficiently clone objects.

  • Adapter Pattern: Choose this when making interfaces compatible, acting as a bridge between incompatible interfaces.

  • Builder Pattern: Perfect for constructing complex objects step by step, especially when there are many configuration options.

  • Decorator Pattern: Dynamically enhances the functionality of an object without altering its structure.

  • Command Pattern: Use when you want to wrap a request under an object as a command and passed to an invoker object.

We have discovered how each pattern offers a unique solution to common programming challenges. You can create more robust, adaptable and efficient systems by weaving these patterns into your code.

Remember, there's no single pattern that fits every scenario. Experiment with different patterns to find the best fit for your projects and don't be afraid to venture beyond the ones covered here.

Subscribe to Anyaogu
Receive the latest updates directly to your inbox.
Mint this entry as an NFT to add it to your collection.
Verification
This entry has been permanently stored onchain and signed by its creator.