Mastering Polymorphism: Unlock the Power of Clean Code

Nicolas FrugoniNicolas Frugoni
10 min read
💡
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 URLSessionProductsProvider: ProductsProvider {
    let urlSession: URLSession

    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: URLSessionProductsProvider(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: URLSessionProductsProvider(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 = URLSessionProductsProvider(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.

0
Subscribe to my newsletter

Read articles from Nicolas Frugoni directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Nicolas Frugoni
Nicolas Frugoni

Greetings! I'm Nicolás Frugoni, a seasoned software developer with over 8 years of hands-on experience crafting mobile and web applications, backend services, and even delving into the world of video game development. While my recent focus has been on iOS development, my expertise spans across multiple platforms. My passion lies in the intricate realm of software architecture, recognizing its pivotal role in shaping robust and efficient software systems. Through this blog, my aim is to demystify software architecture, offering practical insights and examples that resonate with both novices and seasoned developers alike. I'll be using AI generation tools a little bit to help with my terrible narrating skills, but the ideas and concepts are all mine from what I've learned over the years through different mediums. Whether you're just embarking on your software development journey or seeking to refine your existing skills, I trust that the content shared here will equip you with valuable knowledge and perspectives to elevate your software architecture prowess. Thank you for embarking on this enlightening journey with me!