Design Patterns: Essential Techniques for Writing High-Quality Code

When it comes to writing code, there are a lot of factors to consider. You want your code to be efficient, scalable, and easy to maintain. One way to achieve these goals is by using design patterns.

What is Design Pattern

Design patterns are proven solutions to recurring problems in software design. They provide a template for solving a particular problem that can be adapted to different situations. Design patterns can help you write code that is more modular, easier to understand, and less prone to errors. In OOP Paradigm, Design patterns are reusable solutions to common software design problems. Object-oriented patterns are design patterns specifically geared towards object-oriented programming.

Object-oriented patterns can be divided into several categories based on their purpose and scope. Some common categories include:

  1. Creational patterns: These patterns provide ways to create objects and class instances in a flexible and reusable manner. Examples include the Singleton pattern, Factory pattern, and Builder pattern.
  2. Structural patterns: These patterns deal with the composition of classes and objects to form larger structures. Examples include the Adapter pattern, Bridge pattern, and Decorator pattern.
  3. Behavioral patterns: These patterns are concerned with the communication between objects and the distribution of responsibilities between them. Examples include the Observer pattern, Strategy pattern, and Template Method pattern.

Definition of Each Patterns & Example

Singleton Pattern – Ensures that only one instance of a class exists in the system and provides a global point of access to it.

class Singleton private constructor() {
    companion object {
        private var instance: Singleton? = null

        fun getInstance(): Singleton {
            if (instance == null) {
                instance = Singleton()
            }
            return instance as Singleton
        }
    }
}

In this Kotlin implementation, the Singleton class has a private constructor so that it cannot be instantiated outside the class. The companion object is used to hold the singleton instance. The getInstance() method is a static method that provides access to the singleton instance. It checks if the instance has been created, and if it hasn’t, it creates a new instance. Then, it returns the instance.

You can use the Singleton pattern like this:

val s1 = Singleton.getInstance()
val s2 = Singleton.getInstance()

println(s1 === s2) // Output: true

In this example, s1 and s2 are both references to the same instance of the Singleton class. Because the getInstance method always returns the same instance, the === operator returns true, indicating that s1 and s2 are the same object.

Factory Pattern – Allows you to create objects without exposing the instantiation logic to the client.

interface Shape {
    fun draw()
}

class Circle : Shape {
    override fun draw() {
        println("Circle.draw")
    }
}

class Rectangle : Shape {
    override fun draw() {
        println("Rectangle.draw")
    }
}

class ShapeFactory {
    companion object {
        fun getShape(shapeType: String?): Shape? {
            return when (shapeType?.toLowerCase()) {
                "circle" -> Circle()
                "rectangle" -> Rectangle()
                else -> null
            }
        }
    }
}

In this example, we have an interface called Shape that defines a draw method. We also have two classes that implement the Shape interface: Circle and Rectangle. Finally, we have a ShapeFactory class that creates instances of Shape subclasses based on a given shapeType string.

The ShapeFactory class has a static getShape method that takes a shapeType string parameter and returns a corresponding Shape instance. If the shapeType is "circle", it returns a Circle instance. If the shapeType is "rectangle", it returns a Rectangle instance. Otherwise, it returns null.

You can use the ShapeFactory like this:

val circle = ShapeFactory.getShape("circle")
circle?.draw() // Output: Circle.draw

val rectangle = ShapeFactory.getShape("rectangle")
rectangle?.draw() // Output: Rectangle.draw

val square = ShapeFactory.getShape("square")
square?.draw() // Output: null

In this example, we use the ShapeFactory to create instances of Circle and Rectangle objects by passing the corresponding shape type strings to the getShape method. We then call the draw method on each object to verify that the correct shape is drawn. Finally, we pass an invalid shape type string to the getShape method, which returns null.

Builder Pattern – Provides a flexible way to create complex objects by separating the construction of an object from its representation.

Example:

class Person private constructor(
    val firstName: String,
    val lastName: String,
    val age: Int,
    val address: String,
    val phoneNumber: String
) {
    class Builder {
        private var firstName: String = ""
        private var lastName: String = ""
        private var age: Int = 0
        private var address: String = ""
        private var phoneNumber: String = ""

        fun setFirstName(firstName: String): Builder {
            this.firstName = firstName
            return this
        }

        fun setLastName(lastName: String): Builder {
            this.lastName = lastName
            return this
        }

        fun setAge(age: Int): Builder {
            this.age = age
            return this
        }

        fun setAddress(address: String): Builder {
            this.address = address
            return this
        }

        fun setPhoneNumber(phoneNumber: String): Builder {
            this.phoneNumber = phoneNumber
            return this
        }

        fun build(): Person {
            return Person(firstName, lastName, age, address, phoneNumber)
        }
    }
}

In this example, we have a Person class that represents a person’s details. The Person class has a private constructor that takes five parameters: firstName, lastName, age, address, and phoneNumber.

We also have a Builder class that is nested inside the Person class. The Builder class has methods to set each of the Person class’s fields and a build method that constructs a new Person instance with the given field values.

You can use the Builder pattern like this:

val person = Person.Builder()
    .setFirstName("John")
    .setLastName("Doe")
    .setAge(30)
    .setAddress("123 Main St")
    .setPhoneNumber("555-1234")
    .build()

In this example, we use the Builder to construct a new Person instance with the given field values. We call each of the set methods on the Builder instance to set the corresponding field value. Finally, we call the build method to create a new Person instance with the given field values.

This pattern provides a clean and easy-to-use way to construct objects with many fields or optional fields, without requiring multiple constructors or constructor overloading.

Observer Pattern – Allows an object to notify other objects when its state changes, without having to know which objects need to be notified in advance.

The Observer pattern is a design pattern that establishes a one-to-many dependency between objects so that when one object changes its state, all its dependents are notified and updated automatically. In other words, it allows an object to notify other objects when its state changes, without having to know which objects need to be notified in advance.

The Observer pattern consists of two main components: the Subject (or Observable) and the Observer. The Subject maintains a list of its dependents (Observers) and notifies them automatically of any state changes, while the Observers register themselves with the Subject and receive notifications when the Subject’s state changes.

Observer Pattern

Here’s an example of the Observer pattern :

interface Observer {
    fun update(state: String)
}

interface Subject {
    fun registerObserver(observer: Observer)
    fun removeObserver(observer: Observer)
    fun notifyObservers()
}

class WeatherStation : Subject {
    private val observers = mutableListOf<Observer>()
    private var temperature: Float = 0.0f

    fun setTemperature(temperature: Float) {
        this.temperature = temperature
        notifyObservers()
    }

    override fun registerObserver(observer: Observer) {
        observers.add(observer)
    }

    override fun removeObserver(observer: Observer) {
        observers.remove(observer)
    }

    override fun notifyObservers() {
        val state = "Current temperature is $temperature degrees Celsius"
        observers.forEach { it.update(state) }
    }
}

class PhoneDisplay : Observer {
    override fun update(state: String) {
        println("Phone display updated: $state")
    }
}

class TabletDisplay : Observer {
    override fun update(state: String) {
        println("Tablet display updated: $state")
    }
}

In this example, we have a WeatherStation class that represents a weather station that measures and reports the temperature. The WeatherStation implements the Subject interface, which defines methods for registering, removing, and notifying observers.

We also have two classes that implement the Observer interface: PhoneDisplay and TabletDisplay. These classes represent displays that show the current temperature reported by the WeatherStation.

When the WeatherStation updates its temperature, it notifies all of its observers by calling their update method with the current temperature state. The observers then update their displays with the new temperature state.

Here’s an example usage of the Observer pattern with our WeatherStation, PhoneDisplay, and TabletDisplay classes:

val weatherStation = WeatherStation()
val phoneDisplay = PhoneDisplay()
val tabletDisplay = TabletDisplay()

weatherStation.registerObserver(phoneDisplay)
weatherStation.registerObserver(tabletDisplay)

weatherStation.setTemperature(25.0f)
// Output:
// Phone display updated: Current temperature is 25.0 degrees Celsius
// Tablet display updated: Current temperature is 25.0 degrees Celsius

weatherStation.removeObserver(phoneDisplay)

weatherStation.setTemperature(20.0f)
// Output:
// Tablet display updated: Current temperature is 20.0 degrees Celsius

In this example, we create a WeatherStation, a PhoneDisplay, and a TabletDisplay. We register both displays as observers of the WeatherStation. When the WeatherStation updates its temperature, it notifies both displays, which update their displays accordingly. We then remove the PhoneDisplay observer from the WeatherStation, and when the WeatherStation updates its temperature again, only the TabletDisplay observer is notified.

Strategy Pattern – Defines a family of algorithms, encapsulates each one, and makes them interchangeable at runtime.

Decorator Pattern – Allows you to add behavior to an individual object, either statically or dynamically, without affecting the behavior of other objects from the same class.

Adapter Pattern – Allows you to convert the interface of a class into another interface that clients expect.

These design patterns are just a few of the many that exist. By learning and applying these patterns to your code, you can write more efficient, scalable, and maintainable software.

In addition to these patterns, it’s important to remember that design patterns are not a silver bullet. They should be used judiciously and in the right context. Overuse of design patterns can lead to code that is overly complex and hard to understand.

Tagged with: