How Abstractions Help Us Solve Tight Coupling

How Abstractions Help Us Solve Tight Coupling

A recipe for flexible and maintainable code

ยท

7 min read

Tightly coupled code can pose challenges for developers, causing difficulties in making changes and scaling applications. When this type of code is discovered later in a project's development, it can cause frustration and slow progress due to the necessary refactoring.

One very powerful way to avoid this is by using abstraction. It provides us with a means of breaking down complex systems into simpler components that can be managed and modified more easily.

In this article, we're going to take a look at how tightly coupled code can be resolved with abstraction and help keep our codebases flexible and maintainable.

What are Abstractions and Why Use Them?

Abstractions and concretions are two distinct types of components that exist within a codebase. Abstract types represent a concept or idea, outlining the functions and properties that any concrete implementation must possess, without being tied to a specific implementation. As a result, abstract types cannot be instantiated. On the other hand, concretions are actual objects that can be created and used.

So, what is a practical example of why we'd want abstraction? ๐Ÿค”

Let's imagine we're planning to start a restaurant. All restaurants, one way or another, will typically require a chef to prepare meals. So, we would need to assign someone to that role.

// 1.
struct ChefGordanRamsay {
    func prepare(recipe: Recipe) -> Meal { 
        // Chef Gordan's implementation
    }
}

struct MyRestaurant {
    // 2.
    var currentChef: ChefGordanRamsay = ChefGordanRamsay()

    func cook(recipe: Recipe) -> Meal {
        currentChef.prepare(recipe: recipe)
    }
}

In the above example, there are a couple of things to take note of

  1. ChefGordanRamsay is our concrete object type

  2. The MyRestaurant object has a stored variable called currentChef which has the concrete type ChefGordanRamsay initialized and assigned to it.

It may not be clear yet, but this code is an example of tight coupling. As mentioned at the beginning of this article, this code can cause some headaches for us down the road. This is because, by definition, when components are tightly coupled, they depend on each other, making it difficult to change or replace one component without affecting the others.

Let's take a closer look at this specific part of the code above.

var currentChef: ChefGordanRamsay = ChefGordanRamsay()

The currentChef has become a hard-coded dependency for the restaurant because it only allows ChefGordonRamsay to be its stored value. In other words, MyRestaurant cannot function or exist without the existence of ChefGordonRamsay. Though this code works fine now, this is a prime example of tight coupling that can burn us later on.

We can probably agree that it's not realistic that the chef will always be ChefGordanRamsay...or that he'd work at our restaurant at all ๐Ÿ˜… We'd be better off not depending on that and also assume we could have a different chef working at the restaurant at any point in time. Ideally, we only need to ensure that whoever takes on the role can fulfill the requirements for a chef in our restaurant.

How do we do this?...

Decoupling the Dependency with Abstraction

For us to remove our hard-coded dependency on ChefGordanRamsay, we're going to start by creating a protocol called Chef. This protocol will be the main component that introduces abstraction by being the interface that sits between the dependent and its dependency, which acts as an agreement or contract that you need to be fulfilled. With that in mind, let's build it out.

First, we'll create a protocol for a Chef that defines what functionality we need. We expect that anything fulfilling the requirements for Chef at MyRestaurant will be able to use a recipe to prepare a meal.

protocol Chef {
    func prepare(recipe: Recipe) -> Meal
}

We now have a Chef protocol. This is our new abstract type/interface that is outlining what we need to be fulfilled by some concrete type. Keep in mind that this does not implement the actual functionality. That is the responsibility of the concrete object. We'll cover this later.

Now that we have our abstract type defined, we can have MyRestaurant update its stored currentChef variable so that we no longer depend on ChefGordanRamsay.

protocol Chef {
    func prepare(recipe: Recipe) -> Meal
}

struct ChefGordanRamsay {
    func prepare(recipe: Recipe) -> Meal { 
        // Chef Gordan's implementation
    }
}

struct MyRestaurant {
    var currentChef: Chef = ChefGordanRamsay()

    func cook(recipe: Recipe) -> Meal {
        currentChef.prepare(recipe: recipe)
    }
}

You may be confused because things don't really appear to have changed much for MyRestaurant. After all, we are still assigning an instance of ChefGordanRamsay to currentChef. So, how are we not still depending on this concrete type?

Well, if you haven't noticed, there is a problem with this code. In fact, this won't even compile because the Swift compiler is producing this error for the line with var currentChef: Chef = ChefGordanRamsay()

Value of type 'ChefGordanRamsay' does not conform to specified type 'Chef'

This is happening because ChefGordanRamsay can no longer be assigned to currentChef due to the variable's type declaration changing to var currentChef: Chef. This is a new requirement that now only allows types of Chef to be stored in the variable. Let's fix that.

struct ChefGordanRamsay: Chef { ...

Now ChefGordanRamsay has Chef as its declared type. This is a very subtle difference, but what we have done is require this concrete object to conform to the Chef protocol. Since ChefGordanRamsay already has a function implemented that matches the Chef protocol's function requirements, we don't need to do anything else for this code to compile โœ…

Okay. That's cool and all...but where is the actual value found with these changes?

Exploring Newfound Flexibility

What we've done above has created a foundation for much more flexible code. However, there aren't very visible benefits of this quite yet. So, let's make some changes to our restaurant.

Remember, we didn't want to count on the idea of CheftGordanRamsay joining the team at our restaurant. I'm sure he's got bigger fish to fry ๐Ÿฅ. Let's remove him for now and instead assign a chef via the initializer when we create an instance of MyRestaurant.

struct MyRestaurant {
    var currentChef: Chef // No more default value

    func cook(recipe: Recipe) -> Meal {
        currentChef.prepare(recipe: recipe)
    }
}

Alright, now we're going to make some new chefs to have on staff.

struct ChefMatty: Chef {
    func prepare(recipe: Recipe) -> Meal {
        // Chef Matty's implementation
    }
}

struct ChefClaire: Chef {
    func prepare(recipe: Recipe) -> Meal {
        // Chef Claire's implementation
    }
}

struct ChefRita: Chef {
    func prepare(recipe: Recipe) -> Meal {
        // Chef Rita's implementation
    }
}

Can you see what the pattern is here? We've created three new chefs who all conform to the same abstract type of Chef. Notice that each chef's function body mentions its implementation. This is indicating that each chef can have its own unique implementation. As mentioned before, protocols outline the high-level requirements of what concrete types must have, not how they implement them.

Since they all meet our restaurant's currentChef type requirement, we now can utilize any one of these new chefs to fill the position we currently had planned for ChefGordanRamsay.

It's time to open the doors of our restaurant. Lets initialize an instance of MyRestaurant. Since we removed the hard-coded chef, we'll also need to pass in an instance of some Chef for the currentChef parameter of the initializer.

let firstShiftChef = ChefClaire()
var restaurant = MyRestaurant(currentChef: firstShiftChef)

Since ChefMatty or ChefRita can seamlessly take over for ChefClaire at any point now, we don't have to worry about refactoring any code in our restaurant or chefs because of our abstraction.

// Shift change
restaurant.currentChef = ChefMatty()

// Shift change
restaurant.currentChef = ChefRita()

As mentioned before, this abstract type of Chef can be thought of as the agreement or contract of fulfillment we can now depend on rather than a singular, concrete type. So, if anyone/any object wants to come onboard at MyRestaurant, they just need to conform to our abstract type.

Here is what all of this should look like now

protocol Chef {
    func prepare(recipe: Recipe) -> Meal
}

struct ChefGordanRamsay: Chef {
    func prepare(recipe: Recipe) -> Meal { 
        // Chef Gordan's implementation
    }
}

struct ChefMatty: Chef {
    func prepare(recipe: Recipe) -> Meal {
        // Chef Matty's implementation
    }
}

struct ChefClaire: Chef {
    func prepare(recipe: Recipe) -> Meal {
        // Chef Claire's implementation
    }
}

struct ChefRita: Chef {
    func prepare(recipe: Recipe) -> Meal {
        // Chef Rita's implementation
    }
}

struct MyRestaurant {
    var currentChef: Chef

    func cook(recipe: Recipe) -> Meal {
        currentChef.prepare(recipe: recipe)
    }
}

let firstShiftChef = ChefClaire()
var restaurant = MyRestaurant(currentChef: firstShiftChef)

// Shift change
restaurant.currentChef = ChefMatty()

// Shift change
restaurant.currentChef = ChefRita()

What We Learned

Abstraction is a fundamental aspect of programming that helps us build better codebases. By using abstractions and removing dependencies on concretions, we can avoid tight coupling. This earns us more maintainable, reusable, and adaptable code that is easier to understand and modify over time.

We've only scratched the surface of abstractions in programming. In future articles, I'll delve deeper into the benefits of utilizing abstractions for things such as dependency injection/inversion, unit testing, and more!

If youโ€™ve found this article helpful, or maybe even want to share your experiences on this topic, pleased drop a comment ๐Ÿ“ฌ

ย