How Abstractions Help Us Solve Tight Coupling
A recipe for flexible and maintainable code
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
ChefGordanRamsay
is our concrete object typeThe
MyRestaurant
object has a stored variable calledcurrentChef
which has the concrete typeChefGordanRamsay
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 ๐ฌ