Mastering Polymorphism: Unlock the Power of Clean Code
Heads up! This post delves into some more advanced topics and is best suited for experienced developers. Don't worry if some of the concepts are new to you, you can always come back to this post later as you gain more experience. But if you're up for a challenge and want to take your skills to the next level, then this post is perfect for you!
Polymorphism is a concept that you may have heard before, but you may not have a clear idea of what it is and more importantly, how to take advantage of it. This is pretty much how it was for me, and it took me a few years to grasp the concept and start applying it in my codebases outside of the simpler examples we tend to came across.
It's usually explained with examples of animals, where a Dog and a Cat are both animals, but they have different behaviors. In this case, we can say that Dog and Cat are both Animals, but they are also Dogs and Cats, respectively.
protocol Animal {
func makeSound()
}
class Dog: Animal {
func makeSound() {
print("Woof!")
}
}
class Cat: Animal {
func makeSound() {
print("Meow!")
}
}
This is a good example to showcase de absolute basics of it, but it can fall extremely short of showing what it can be used for, leading to people thinking that it's a purely academic concept that has no real world application.
However, polymorphism is not just limited to this basic definition, it can be used as a powerful tool to create clean and modular codebases
Polymorphism does come around in most of the codebases we write, but it's usually not used in a way that takes advantage of it's full potential. Let's take a look at a few examples of how we can use it to write better code.
The bad way
Let's checkout an example of a GetAllProductsUseCase
. We'll introduce a ProductsRepository
protocol to fetch products and an implementation for it. We will then refactor this code to take advantage of polymorphism as much as possible, please follow along.
protocol ProductsRepository {
func getAll() async throws -> [Product]
}
class ProductsRepositoryImpl: ProductsRepository {
func getAll() async throws -> [Product] {
// Get products from the network
// or throw an error if something goes wrong
try await urlSession.get(url)
}
}
class GetAllProductsUseCase {
let productsRepository: ProductsRepository
func getProducts() async throws -> [Product] {
let products = try await productsRepository.getAll()
// some business logic
products.map { $0.isPopular = $0.sales > 100 }
return products
}
}
This works fine as it is, so we ship it to production so our users can start using the new feature. A few days later our business team comes to us and asks to implement some way of offline mode, so that users can still use the app even if they don't have internet connection. We start working on it and we come up with the following (and extra-simplified) solution:
class ProductsRepositoryImpl: ProductsRepository {
func getAll() async throws -> [Product] {
if !network.isReachable {
return UserDefaults.standard.get("products")
}
let products = try await urlSession.get(url)
UserDefaults.standard.set("products", products)
return products
}
}
This should already be ringing some bells. We are now mixing concerns, we are not only fetching products and checking for network reachability but we are also storing them in the user defaults.
But this we are now asked by the business team to also have a way to know how many times caching has been used compared to the network. As we use Google Analytics in the app for other tracking purposes, we decide to add a new event for this.
class ProductsRepositoryImpl: ProductsRepository {
func getAll() async throws -> [Product] {
if !network.isReachable {
Analytics.log("products_cache_hit")
return UserDefaults.standard.get("products")
}
let products = try await urlSession.get(url)
Analytics.log("products_network_hit")
UserDefaults.standard.set("products", products)
return products
}
}
I hope you can see where this is going. We are now mixing concerns again and if we keep going this way, we will end up with a huge class that does everything and is hard to test and maintain.
Now from the UseCase's perspective, we're using a polymorphic interface to fetch products, so that's one reason that most codebases are doing things this way, neglecting to realise that abstraction is not only about putting an interface in place and calling it a day.
What's wrong with this?
Although the code may work, it definitely carries a lot of problems with it. Let's take a look at some of them:
It's hard to test
We are now mixing concerns, so we can't test the caching logic without the network logic, and viceversa. We can't test the analytics logic without the caching logic, and so on.
It's hard to maintain
The code got too big and it's hard to understand what's going on at a glance in the method, let alone in the whole class.
It's hard to reuse
We can't reuse the caching logic without the network logic or analytics code, and viceversa. If we want to reuse the caching logic, we have to copy-paste the whole class and remove the logic we don't need.
It's hard to extend
When new features come up, we have to add more logic to the class, adding to the already existing problems.
The light at the end of the tunnel
We can refactor this code to take advantage of polymorphism as much as possible, and we can do it in a way that makes it easier to test, maintain, reuse and extend.
The solution to this is to create separate classes for each concern, and then, compose the different classes together.
But first, Naming
Naming is one of the most important things in software development, and it plays a big role in how we structure our code. Giving classes (which are concrete implementations) a name that is too generic can lead to confusion towards what they actually do, and it can also lead to the creation of classes that do too many things.
In our previous example, we had a ProductsRepositoryImpl
class that was doing too many things, but also that by just readin the class name, we have no idea what that class does inside. We're forced to open the class to see what it does. This makes it just that much easier to add more logic to the class, and worsend the situation. I mean, you have the file open already, you may as well just write the code there, right? Whereas if we had a UserDefaultsProductsRepository
class, a URLSessionProductsRepository
class, you'll think twice before adding Google Analytics logic to those.
The solution
Polymorphism is the tool that we need to solve our problems, and we can use different patterns together to achieve this, like the Decorator
pattern, the Composition
pattern, and so on.
I'll start by defining a more specific protocol that defines what we are actually abstracting our Use Case from.
protocol ProductsProvider {
func getAll() async throws -> [Product]
}
class URLSessionProductsRepository: ProductsRepository {
func getAll() async throws -> [Product] {
try await urlSession.get(url)
}
}
Now, using the Strategy
pattern, we can create a ReachabilityProductsProviderStrategy
that will be responsible for checking for network reachability and deciding which ProductsProvider
to use.
class ReachabilityProductsProviderStrategy: ProductsProvider {
let remote: ProductsProvider
let local: ProductsProvider
let reachability: ReachabilityProvider
func getAll() async throws -> [Product] {
if !reachability.isReachable {
return try await local.getAll()
}
return try await remote.getAll()
}
}
We can then use the Decorator
pattern to add the caching logic to the ProductsProvider
interface.
class CachingProductsProviderDecorator: ProductsProvider {
let remote: ProductsProvider
let cache: ProductsSaver // New protocol to abstract the delivery mechanism of the cache
func getAll() async throws -> [Product] {
let products = try await remote.getAll()
try await cache.set(products)
return products
}
}
protocol ProductsSaver {
func save(_ products: [Product]) async throws
}
class UserDefaultsProductsProvider: ProductsProvider, ProductsSaver {
let userDefaults: UserDefaults
func getAll() async throws -> [Product] {
userDefaults.get("products") ?? []
}
func save(_ products: [Product]) async throws {
userDefaults.set("products", products)
}
}
And finally, we can use the Decorator
pattern again to add the analytics logic to the ProductsProvider
interface.
class AnalyticsProductsProviderDecorator: ProductsProvider {
let decoratee: ProductsProvider
let analytics: Analytics
let eventToEmit: String
func getAll() async throws -> [Product] {
let products = try await decoratee.getAll()
analytics.log(eventToEmit)
return products
}
}
Now, we can compose all of these together to create a ProductsProvider
that will be used by the Use Case.
let analytics = Analytics()
let userDefaultsProvider = UserDefaultsProductsProvider(userDefaults: .standard)
let remoteProvider = CachingProductsProviderDecorator(
remote: AnalyticsProductsProviderDecorator(
decoratee: URLSessionProductsRepository(urlSession: .shared, url: url),
analytics: analytics,
eventToEmit: "products_network_hit"
),
cache: userDefaultsProvider
)
let localProvider = AnalyticsProductsProviderDecorator(
decoratee: userDefaultsProvider,
analytics: analytics,
eventToEmit: "products_cache_hit"
)
let strategy = ReachabilityProductsProviderStrategy(
remote: remoteProvider,
local: localProvider,
reachability: ReachabilityProvider()
)
let useCase = ProductsUseCase(productsProvider: strategy)
Note that most of our code is just small classes that have one single reason to change each, but also most of our classes are
if
-free, and the ones that are not, are just delegating to other classes. This makes it easier to test, maintain, reuse and extend.
Is this not just overkill?
It depends. (Welcome to the world of software development)
If you're building a small app that will never grow, this is overkill. Say you're building a small app for a small business from around your neighbourhood, and you know that, apart from occasional bug fixes and maybe minor feature additions, the app will be pretty much the same (potentially) forever. Then this is overkill.
This is an advanced setup that sacrifices simplicity for maintainability, testability, reusability and extensibility. If you're building a small app, you probably don't need this.
But if you're building an app that will grow in size, that a large (and growing) team is involved in, and you want to make sure that you can easily and consistently add new features without breaking existing ones, being able to test the code easily, and so on, then this is NOT Overkill.
On Bigger Apps (And Modularisation)
If you work on a big enough app, you know that it's not uncommon to have a Networking
module, a Persistence
module, an Analytics
module, and so on. This is a good thing, because it allows us to separate concerns and make sure that we don't have to deal with the whole app when we want to make a change to a specific module.
It also helps to have our code separated into different modules to define clear boundaries and ownership between the different teams.
This is where applying this principles and patterns becomes even more important, because we can have different teams (aka modules) maintain specific parts of our app, for example, the networking module could be handled by networking team, while reachability and caching could be handled by the persistence team, and so on. Here, having the code separated into smaller components is a must. The different classes we defined could potentially end up living in completely different modules or even separate github repositories.
Adding new features by composing existing ones
Lets say that we only want to use cache for premium users. We can compose the app in a completely different way for regular users and premium users, while reusing the same code.
// Regular Users
let remoteProvider = AnalyticsProductsProviderDecorator(
decoratee: URLSessionProductsRepository(urlSession: .shared, url: url),
analytics: analytics,
eventToEmit: "products_network_hit"
)
let useCase = ProductsUseCase(productsProvider: remoteProvider)
How about users opting out of analytics tracking? We can just remove the AnalyticsProductsProviderDecorator
from the chain.
// Users opting out of analytics tracking
let remoteProvider = URLSessionProductsRepository(urlSession: .shared, url: url),
let useCase = ProductsUseCase(productsProvider: remoteProvider)
Easier A/B Testing and Feature Flags
Using composition to build our app, we can easily add new features and test them in production without having to worry about breaking existing features.
class ABTestProductsProviderStrategy: ProductsProvider {
let a: ProductsProvider
let b: ProductsProvider
let abTest: ABTestProvider
func getAll() async throws -> [Product] {
if abTest.isInTestGroup("test_group") {
return try await a.getAll()
}
return try await b.getAll()
}
}
let publicProvider = URLSessionProductsProvider(
urlSession: .shared,
url: url
)
let privateProvider = URLSessionProductsProvider(
urlSession: AuthenticatedURLSession(urlSession: .shared),
url: url
)
let abTestProvider = ABTestProductsProviderStrategy(
a: publicProvider,
b: privateProvider,
abTest: ABTestProvider()
))
let useCase = ProductsUseCase(productsProvider: abTestProvider)
Conclusion
In conclusion, polymorphism is a powerful concept that can be used to create clean and modular codebases. By using protocols and abstract classes, we can create a flexible and reusable codebase that is easy to maintain and extend. In the example provided, we saw how a simple use case of fetching products can evolve into a complex problem, when we start mixing concerns and adding extra functionality. By taking advantage of polymorphism, we can keep our codebase clean and easy to understand, making it easier to maintain and extend in the future.