DEV Community

Filip Kisić
Filip Kisić

Posted on • Updated on

Design patterns - Part I

What are design patterns?

On a daily basis, we encounter problems that we solve as engineers, that is our passion. With some experience, you'll see that some problems share the same pattern and therefore we have solution patterns and design patterns that serve as a blueprint to solve those problems.

Difference between the algorithm and the design pattern

From the text above, you could also say the same for the algorithms, but the difference is that a design pattern is a higher level concept or a guide how to solve the problem. You can't simply copy/paste the pattern, but you have to think and follow the steps to implement it. On the other hand an algorithm can be copy/pasted in the code to make it work and it is a low level, detailed solution.

Types of patterns

There are three types of design patterns:

  • Creational
  • Structural
  • Behavioural

Creational patterns provide various creational methods that can result in more flexible and reusable code. When you need large data structures made of multiple objects and classes, structural patterns are there to help. Lastly, there are behavioural patterns which can help solve algorithmically problems as well as responsibilities between the objects. All of the examples below are written in Dart programming language.

#1 Factory method

From a standpoint of a mobile developer, cross-platform developer specifically, each OS has its components/widgets. Let's use Android and iOS as an example. Android has a button widget that follows Material design, so as iOS button follows Apple Human Interface Guidelines. That requires two different widgets which have the same properties and behaviours. For example these:

  Color color;
  String label;
  Function onPressed;
Enter fullscreen mode Exit fullscreen mode

Let's say the Windows Phone is still an alive project, it would require another widget, you see where I am going. We write the code for those, but new platforms arrive on the market, and therefore new code to write. It is repetitive, with lot of the same code and that is a problem. A solution for this kind of problem is a factory method pattern. What the factory method does is it extracts common properties and behaviours to an abstract class. Why? Well, as always, we should lean towards abstractions, that make our code more flexible and depend upon abstractions, not concrete implementations. This way, we define what one button should be and have, hence every button on the new platform should extend that abstract button. For better understanding, take a look at the following code.

abstract class AdaptiveButton {
  final VoidCallback? onPressed;
  final String label;
  Widget create();

  AdaptiveButton({required this.onPressed, this.label = 'Button'});
}
Enter fullscreen mode Exit fullscreen mode

Every button on every platform should have its own onPressed callback, label and method create in which later we'll define how to create the button for the given platform. The create() method is the most important one. Let's see what would Android button look like:

class AndroidButton extends AdaptiveButton {
  AndroidButton({required VoidCallback? onPressed, required String label,}) : super(onPressed: onPressed, label: label,);

  @override
  Widget create() {
    return ElevatedButton(
            onPressed: onPressed,
            child: Text(label),
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

We let the fellow developer define the label and callback method, but what we defined is how the Android button should be created. The same applies to the iOS button.

class IOSButton extends AdaptiveButton {
  IOSButton({required VoidCallback? onPressed, required String label})
      : super(onPressed: onPressed, label: label);

  @override
  Widget create() {
    return CupertinoButton.filled(
            child: Text(label),
            onPressed: onPressed,
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

If abstract classes aren't something you are good with, think of them like a contract. If you want to be a button, you have to sign the AdaptiveButton contract, by that, you have to have certain properties and behaviours.

We defined how to create buttons, now let's render them on the screen. In this demo app, based on which platform is being run, it will render an appropriate button, if the platform is not supported, an exception will be thrown.

abstract class ButtonFactory {
  AdaptiveButton showButton(final VoidCallback callback, final String label);
}

class ButtonFactoryImpl implements ButtonFactory {
  @override
  AdaptiveButton showButton(final VoidCallback callback, final String label) {
    if (Platform.isAndroid) {
      return AndroidButton(onPressed: callback, label: label);
    } else if (Platform.isIOS) {
      return IOSButton(onPressed: callback, label: label);
    } else {
      throw Exception('OS not supported');
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

And when we figured out which button to render, now we just have to create it and be done. This whole screen looks like this.

class LoginScreen extends StatelessWidget {
  final ButtonFactory buttonFactory;

  const LoginScreen({Key? key, required this.buttonFactory}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    ...
    buttonFactory.showAppropiateButton(login, 'Login').create();
    ...
  }

  void login() {
    ...
  }
}
Enter fullscreen mode Exit fullscreen mode

And there it goes, now to add support for a new platform, e.g. for Huawei's Harmony OS, all we have to do is create HarmonyButton class which must extend AdaptiveButton class and add a case in the showButton method. As a mobile developer, this pattern is quite a helpful and neat way to manage different platforms in a clean and easy-to-maintain way.

#2 Builder

We covered the Factory Method, now let's see what Builder Design Pattern is. First what it is used for is for creating complex objects. Cars are complex objects for example, and imagine having a special constructor for each type of car, a lot of code and not being flexible at all. The builder approach is to extract common building parts in methods. Let's do the example. The average car configuration has millions, probably billions options when combined, so let's create two cars, one is a daily driver, and the other is for weekend drives.

class Car {
  final String? color;
  final int? wheelSizeInInches;
  final bool? hasBadges;
  final EquipmentTrim? equipmentTrim;
  final ChassisType? chassisType;
  final EngineType? engineType;
  final InteriorTrim? interiorTrim;

  Car(
    this.color,
    this.wheelSizeInInches,
    this.hasBadges,
    this.equipmentTrim,
    this.chassisType,
    this.engineType,
    this.interiorTrim,
  );

  @override
  String toString() => 'This is $color $chassisType with $wheelSizeInInches inch wheels, $interiorTrim interior and $engineType engine under the hood.';
}
Enter fullscreen mode Exit fullscreen mode

In the example above, the constructor is too long, so the process of object creation will be too long, therefore not readable. When you want to create a new car, every time you have to set all those properties over and over through the constructor which can be a lot of work. This is where a builder pattern comes to the rescue.

class CarBuilder {
  String? color;
  int? wheelSizeInInches;
  bool? hasBadges;
  EquipmentTrim? equipmentTrim;
  ChassisType? chassisType;
  EngineType? engineType;
  InteriorTrim? interiorTrim;

  CarBuilder withColor(String? color) {
    this.color = color;
    return this;
  }

  CarBuilder withWheelSizeInInches(int? wheelSizeInInches) {
    this.wheelSizeInInches = wheelSizeInInches;
    return this;
  }

  CarBuilder withHasBadges(bool? hasBadges) {
    this.hasBadges = hasBadges;
    return this;
  }

  CarBuilder withEquipmentTrim(EquipmentTrim? equipmentTrim) {
    this.equipmentTrim = equipmentTrim;
    return this;
  }

  CarBuilder withChassisType(ChassisType? chassisType) {
    this.chassisType = chassisType;
    return this;
  }

  CarBuilder withEngineType(EngineType? engineType) {
    this.engineType = engineType;
    return this;
  }

  CarBuilder withInteriorTrim(InteriorTrim? interiorTrim) {
    this.interiorTrim = interiorTrim;
    return this;
  }

  Car build() {
    return Car(this); 
  }
}
Enter fullscreen mode Exit fullscreen mode

Because Dart is a null-safe language, we have to define every parameter to be nullable. Now let's modify the Car class which will take CarBuilder as a constructor parameter.

class Car {
  ...
  Car(CarBuilder builder)
      : color = builder.color,
        wheelSizeInInches = builder.wheelSizeInInches,
        hasBadges = builder.hasBadges,
        equipmentTrim = builder.equipmentTrim,
        chassisType = builder.chassisType,
        engineType = builder.engineType,
        interiorTrim = builder.interiorTrim;
  ...
}
Enter fullscreen mode Exit fullscreen mode

What we've done here is passed CarBuilder as a parameter by which we initialize all parameters that the Car class has, but this is one case, what if we don't have the freedom to edit the Car constructor and its constructor looks like this:

class Car {
  String? color;
  int? wheelSizeInInches;
  bool? hasBadges;
  EquipmentTrim? equipmentTrim;
  ChassisType? chassisType;
  EngineType? engineType;
  InteriorTrim? interiorTrim;

  Car(this.color, this.equipmentTrim, this.chassisType);

  @override
  String toString() => 'This is $color $chassisType with $wheelSizeInInches inch wheels, $interiorTrim interior and $engineType engine under the hood.';
}
Enter fullscreen mode Exit fullscreen mode

Now only three properties are needed to construct an object. With the builder pattern, all we have to do is modify the build method.

  Car build() {
    return Car(this.color, this.equipmentTrim, this.chassisType); 
  }
Enter fullscreen mode Exit fullscreen mode

We defined what the constructor asked us to do, but now we lost all other car properties, to fix we'll use setters and return the full object.

Car build() {
    Car car = Car(this.color, this.equipmentTrim, this.chassisType);
    car.wheelSizeInInches = this.wheelSizeInInches;
    car.hasBadges = this.hasBadges;
    car.engineType = this.engineType;
    car.interiorTrim = this.interiorTrim;
    return car;
  }
Enter fullscreen mode Exit fullscreen mode

The bottom line, the build() method must do all necessary steps to create an object that then returns.

With everything setup, we should build our cars:

final Car c63amg = CarBuilder()
    .withColor('Red')
    .withWheelSizeInInches(19)
    .withHasBadges(true)
    .withEquipmentTrim(EquipmentTrim.sport)
    .withChassisType(ChassisType.coupe)
    .withEngineType(EngineType.gasoline)
    .withInteriorTrim(InteriorTrim.alcantara)
    .build();

final Car granCoupe4 = CarBuilder()
    .withColor('Grey')
    .withWheelSizeInInches(18)
    .withHasBadges(false)
    .withEquipmentTrim(EquipmentTrim.luxury)
    .withChassisType(ChassisType.granCoupe)
    .withEngineType(EngineType.diesel)
    .withInteriorTrim(InteriorTrim.leather)
    .build();

print(c63amg.toString());
print(granCoupe4.toString());
Enter fullscreen mode Exit fullscreen mode

When we print the result of this, here is what we get:

This is Red coupe with 19 inch wheels, alcantara interior and gasoline engine under the hood.
This is Grey granCoupe with 18 inch wheels, leather interior and diesel engine under the hood.
Enter fullscreen mode Exit fullscreen mode

In conclusion, a builder pattern is used when you have a lot of object creation in your code and with different properties. The pattern wraps the creation process and makes object creation and customization easy step by step, but with the price of additional classes and functions.

#3 Singleton

Singleton is maybe the easiest design pattern to learn and implement. As the name suggests, there is a single instance of an object.
Before I knew about this pattern, I always created static variables, but sometimes it wasn't a good solution since I could overwrite the object with another. With NotificationService as an example, I'll try to explain why is Singleton good and why is not. I want to have only one instance of the NotificationService class, so when someone needs to notify a user about something by accessing OS API, there won't be multiple instances of the same object which eventually accumulates operations for a garbage collector, but we will reuse the one instance only. There Singleton acts as a solution. Singleton class has ONE PRIVATE instance of its class and its getter. That makes that instance global and protected from being overridden. Let's try to implement it.

class NotificationService {
  NotificationService._privateConstructor();
  static final NotificationService _instance = NotificationService._privateConstructor();
  static NotificationService get instance => _instance;

  void showNotification() {
    ...
  }
}
Enter fullscreen mode Exit fullscreen mode

In the code above, these first three lines are all we need. We need to make our constructor private, so no one from the outside cannot create a new instance, we need then to create a private instance and expose it using the getter. The method showNotification speaks with the OS and shows the notification to the user. Later, to test if the instance is a singleton, we check object references. Let's do it.

void notifyUser() {
    final NotificationService _notificationService = NotificationService.instance;
    final NotificationService _notificationServiceTwo = NotificationService.instance;    

    log('${_notificationService == _notificationServiceTwo}', name: 'ARE EQUAL');

    _notificationService.showNotification();
  }
Enter fullscreen mode Exit fullscreen mode

I used the most simple approach and just printed the equality result. Of course, it prints true. Since this is Dart, in Dart == operator checks if the given objects references are the same.

We covered why you should use the Singleton and what are the benefits of using it, but there are also some problems, so let's talk about them. Firstly and most importantly, it violates the Single Responsibility Pattern because its concerns are instance creation and lifecycle. Secondly, in the multithreaded environment using the Singleton pattern is not safe because if the data it holds is mutable when two threads access the data at the same time an error can happen. Let's look at an example, but this time in Java.

public class NotificationService {
    private static NotificationService instance;

    private NotificationService();

    public static NotificationService getInstance() {
        if (instance == null) {
            instance = NotificationService();
        }
        return instance;
    }
}
Enter fullscreen mode Exit fullscreen mode

The solution is to make the getInstance() method synchronized, that way only one thread can call a method at a time. It looks like this:

public class NotificationService {
    private static NotificationService instance;

    private NotificationService();

    public static synchronized NotificationService getInstance() {
        if (instance == null) {
            instance = NotificationService();
        }
        return instance;
    }
}
Enter fullscreen mode Exit fullscreen mode

Since Dart is a single-threaded language running in one isolate, there are no worries as long as you don't create another isolate. Moreover, when creating a Singleton class, lazy initialization is the preferred way of doing it, so an instance is created when it is first needed. If it is done eagerly, an instance will be created while classes are being loaded and there is a possibility that a singleton instance won't be used in the app lifecycle.

There is also one extreme, but very natural case of a singleton, it is a single-element enum. Look at this one here.

enum EiffelTower {
  instance
}

const EiffelTower _parisTowerOne = EiffelTower.instance;
const EiffelTower _parisTowerTwo = EiffelTower.instance;

void testTowers() {
  print('ARE EQUAL: ${_parisTowerOne == _parisTowerTwo}');
}
Enter fullscreen mode Exit fullscreen mode

There is only one original Paris Eiffel Tower, this enum ensures that is true. Fun fact, there are 12 Eiffel Towers around the world.

#4 Adapter

Our notebooks are the most important tool in our everyday lives, we use them for a lot of things, but sometimes to make things done we need an adapter. For example, some notebooks don't have a microSD card slot to import our B-rolls. Then the adapter comes to the rescue, a microSD adapter to be exact. The same problem can be found while programming, one example would be working with REST API that provides data in certain model objects, which are not compatible with your app. Firstly, the API most of the time sends data in JSON format and sometimes its property names are not compatible with yours too. Here is an example of a such JSON object:

{
  'userId': 1,
  'username': 'phillip.k',
  'email': 'phillip@gmail.com',
  'dateOfBirth': '07-01-2000'
}
Enter fullscreen mode Exit fullscreen mode

And this is how our model class looks like:

class User {
  final int id;
  final String username;
  final String email;
  final Date birthday;
}
Enter fullscreen mode Exit fullscreen mode

As you can notice, some property names are different and in JSON object dateOfBirth is a String, not a DateTime which we need. Here we can resolve our problem using the Adapter pattern in its simplest form, like a method. What we want is to create a method called fromJson which takes the JSON object and returns the model object that we need. Here is the code:

factory User.fromJson(Map<String, dynamic> json) {
  return User(
    id: json['userId'] as int,
    username: json['username'] as String,
    email: json['email'] as String,
    birthday: DateTime.parse(json['dateOfBirth'] as String),
  );
}
Enter fullscreen mode Exit fullscreen mode

What we did up there is we took JSON from REST API in the form of a Map where keys are strings and values are dynamic, we can't know their datatype. So, we got one data format, but we want data formatted as User class, that is why we return a new instance of the User class. The constructor call is where everything is happening, id takes value under the key of userId and converts it to the integer. The process repeats for each property. Eventually, we return that created instance. The main purpose of the Adapter is to adapt the data format received from one data source to the format we find suitable to use in our application. To explain the concept better, here is a more complex example using multiple classes.
We have a microSD card that can export data.

class MicroSDCard {
  void exportData() {
    print('Exporting B-roll from MicroSDCard...');
  }
}
Enter fullscreen mode Exit fullscreen mode

Our laptop will have only USB-C as input interface so let's create USBInput interface.

abstract class USBInput {
  void transferData();
}
Enter fullscreen mode Exit fullscreen mode

Lastly, we need our laptop which should import data through USB-C input, it will look like this:

class Laptop {
  final USBInput usbInput;

  const Laptop(this.usbInput);

  void importData() {
    usbInput.transferData();
    print('B-roll imported!');
  }
}
Enter fullscreen mode Exit fullscreen mode

Now the problem is that we cannot connect our microSD card into USB-C port on our laptop.

void main() {
  MicroSDCard card = MicroSDCard();
  Laptop laptop = Laptop(); //We cannot create laptop object without USBInput  
}
Enter fullscreen mode Exit fullscreen mode

Here is the solution, our MicroSDToUSBAdapter.

class MicroSDToUSBAdapter implements USBInput {
  final MicroSDCard card;

  const MicroSDToUSBAdapter(this.card);

  @override
  void transferData() {
    card.exportData();
  }
}
Enter fullscreen mode Exit fullscreen mode

What we've done here is we defined that our adapter takes MicroSDCard and it knows how to transfer data when connected to USB-C.

class MicroSDToUSBAdapter implements USBInput {
  final MicroSDCard card;

  const MicroSDToUSBAdapter(this.card);

  @override
  void transferData() {
    card.exportData();
  }
}
Enter fullscreen mode Exit fullscreen mode

Here is the final result.

void main() {
  MicroSDCard card = MicroSDCard();
  MicroSDToUSBAdapter adapter = MicroSDToUSBAdapter(card);
  Laptop laptop = Laptop(adapter);
  laptop.importData();
}
Enter fullscreen mode Exit fullscreen mode

This was an adapter, one of the most used patterns and also one of the easiest. In conclusion, the adapter design pattern is used when two incompatible classes cannot communicate, the adapter makes the communication possible. Let's go to the next one, the Bridge design pattern.

#5 Bridge

The bridge was for me one of the more difficult to understand, so I'll do my best to explain it to you as easily as possible. At first, I thought it is similar to the Adapter, but it is a bit more complex because here we have an abstraction that depends upon another abstraction. By doing that you decouple an abstraction from its implementation completely and it can change independently. To keep it short, Bridge is like an Adapter but instead of concrete implementations, they are abstract. Let's make an example with a weapon that can have with gadgets.

There are more types of weapons, like a handgun and an assault rifle. There are also plenty of gadgets, like a laser and a flashlight. Let's say we have abstract classes Weapon and Gadget, and concrete implementations like Handgun and Laser. Without the bridge pattern, what we would do is to create multiple classes, for each option possible, FlashlightHandgun, LaserHandgun, LaserRifle, etc. But in real life, the gadget should be able to mount, work and unmount on any weapon. That is why Weapon has a Gadget, abstraction depends upon another abstraction and therefore they are decoupled and can change without any changes.
Now here is the code example.

abstract class Weapon {
  void pickup();
  void fire();
  void store();
}

abstract class Gadget {
  void mount();
  void use();
  void unmount();
}
Enter fullscreen mode Exit fullscreen mode

Here are those two abstractions, all we know is what they can do. Now let's take a look at the implementations.

class Handgun extends Weapon {
  final Gadget _gadget;

  Handgun(this._gadget);

  @override
  void fire() {
    _gadget.use();
    print('Piu piu!');
  }

  @override
  void pickup() {
    _gadget.mount();
    print('Handgun in hands.');
  }

  @override
  void store() {
    _gadget.unmount();
    print('Handgun holstered.');
  }
}

Enter fullscreen mode Exit fullscreen mode

Here we have Handgun, an implementation of a Weapon which depends on the Gadget abstraction. That is the key difference compared to the Adapter. Handgun can have any type of the Gadget, now let's take a look at the Gadget implementation.

class Laser implements Gadget {
  @override
  void mount() => print('Laser mounted.');

  @override
  void unmount() => print('Laser unmounted.');

  @override
  void use() => print('Laser pointing...');
}
Enter fullscreen mode Exit fullscreen mode

This is the final result:

final Gadget laser = Laser();
final Weapon glock = Handgun(laser);

glock.pickup();
glock.fire();
glock.store();
Enter fullscreen mode Exit fullscreen mode

This is the output:

Laser mounted.
Handgun in hands.
Laser pointing...
Piu piu!
Laser unmounted.
Handgun holstered.
Enter fullscreen mode Exit fullscreen mode

Honestly hope this pattern explanation was clear and easy to understand. If you are wondering where this pattern can be applied, here is an example. A repository is an abstraction, an interface to be exact and its implementation often can have two data sources, one is from the API, and the second one is from the database. Both the API and database are interfaces, so we have one abstraction which depends upon two other abstractions. If we want, we can easily add one more data source in the repository without touching other classes because abstraction depends upon another abstraction, that is the Bridge design pattern. Five patterns done, five to go.

The next five patterns will be covered in Part II, there we shall see patterns like Decorator, Facade, Service Locator, Observer and State. Thank you for your time and attention, good luck with coding! :D

Top comments (0)