Now that we have a more robust MVVM codebase that uses a Service, and we’ve ensured its testability, it’s time to address the elephant in the room.



This is the second part of a two-part blogpost. Follow this link if you were looking for the first part.

If you’re a visual learner I also provide some presentation slides here.

If you just want to fiddle around with the code, you can find it here.

And now, on with the show 🎩


# What are the pitfalls of our DI implementations? 🚯

The keen eyed developers might have been screaming at the screen this whole time. “You’re using the singleton pattern!”. The singleton pattern should be avoided yes, but it’s a cardinal sin if used properly (which I’d argue is the case). The real danger is that it nudges into using too many static properties too easily, particularly for our Services.

But why is that a problem? What’s the lifecycle of a static property? Well, for Swift

Stored type properties (such as static ones) are lazily initialized on their first access. They’re guaranteed to be initialized only once, even when accessed by multiple threads simultaneously, and they don’t need to be marked with the lazy modifier.

Ok sure, but why does this matter?

Well, they never get cleaned up after being accessed the first time, at least not until you terminate the app! So if we have many or huge services being allocated as static properties… 💣⏲️💥 We might just run out of memory from keeping too much stuff around. Is there a fix for this?

# Service Locators

What we want is some way to:

  • Hold all of the services that should be deallocated at some point (stored in a collection, e.g. Dictionary key-value)
  • Have some way to manage (add and remove) and search for services in that collection

We call this a Service Locator, and its pseudo-code implementation ServiceLocator.swift should look something like this:

  • 🗂️ var services = [ServiceName : Any]()
  • 🆔 register(Service)
  • 🔍 get(Service)
  • 🚮 unregister(Service)

On top of that we have to consider a couple of scenarios, what should happen if:

  • we try to re-register a service? (duplicate entries)
  • we try to get an unregistered service? (nil checking)
  • we fetch and try to assign the wrong type of service? (type safety)

Before you proceed any further, how many lines of code do you think it will take to implement this Service Locator?

# Simple implementation

A fleshed-out implementation can look something akin to this:

final class ServiceLocator {

    enum Error: Swift.Error {
        case duplicateService(ServiceName)
        case inexistentService(ServiceName)
        case typeMismatch(ServiceName)
    }

    typealias ServiceName = String

    static let shared = ServiceLocator()

    private var services = [ServiceName : Any]()

    // MARK: - Public Methods

    @discardableResult
    func register<Service>(
        service: Service,
        name serviceName: ServiceName? = nil
    ) throws -> ServiceName {

        let name = buildName(for: Service.self, serviceName)

        guard services[name] == nil
        else { throw Error.duplicateService(name) }

        services[name] = service

        return name
    }

    func get<Service>(
        name serviceName: ServiceName? = nil
    ) throws -> Service {

        let name = buildName(for: Service.self, serviceName)

        guard let registeredService = services[name]
        else { throw Error.inexistentService(name) }

        guard let service = registeredService as? Service
        else { throw Error.typeMismatch(name) }

        return service
    }

    func unregister<Service>(
        _ type: Service.Type,
        name serviceName: ServiceName? = nil
    ) throws {

        let name = buildName(for: type, serviceName)

        guard let _ = services[name]
        else { throw Error.inexistentService(name) }

        services[name] = nil
    }

    // MARK: - Private Methods

    private func buildName<Service>(
        for _: Service.Type,
        _ serviceName: ServiceName? = nil
    ) -> ServiceName {
        return serviceName ?? "\(Service.self)"
    }
}

# Sample Usage

And with this powerful pattern we can improve our Service once again 💪.

protocol ServiceProtocol {
    func toggle(bool: Bool) -> Bool
}

class ServiceImplementation: ServiceProtocol {

    static let singleton = ServiceImplementation() // 👈

    func toggle(bool: Bool) -> Bool {
        !bool
    }
}

Let’s get rid of that singleton and replace it with a proper registration through our shiny Service Locator.

protocol ServiceProtocol {
    func toggle(bool: Bool) -> Bool
}

class LocatableServiceImplementation: ServiceProtocol {

    // 👀 Notice how we no longer need a singleton ref. here!

    func toggle(bool: Bool) -> Bool {
        !bool
    }
}

// MARK: Usage example:

let serviceLocator = ServiceLocator.shared
let service: LocatableServiceImplementation

// get or register the service
do {
    service = try serviceLocator.get(LocatableServiceImplementation.self)
} catch ServiceLocator.Error.inexistentService {
    service = .init()
    try? serviceLocator.register(service: instance)
} catch {
  // handle other unexpected errors
}

// use the service as we would normally
let viewModel: ViewModel = .init(
    model: .init(boolProperty: true),
    service: service
)

// and when we no longer need the service, we just get rid of it 🚮
serviceLocator.unregister(LocatableServiceImplementation.self)

# Putting it all together 🏁

All in all, here’s what we’ve accomplished for our codebase in one snippet of code.

// MARK: Service

protocol ServiceProtocol {
    func toggle(bool: Bool) -> Bool
}

class LocatableServiceImplementation: ServiceProtocol {

    func toggle(bool: Bool) -> Bool {
        !bool
    }
}

// MARK: ViewModel

protocol ViewModelRepresentable {

    var model: Model { get }
    var service: ServiceProtocol { get }

    func getUpdatedValue() -> Bool
}

struct ViewModel: ViewModelRepresentable {

    private(set) var model: Model
    private let service: ServiceProtocol

    init(
        model: Model,
        service: ServiceProtocol
    ) {
        self.model = model
        self.service = service
    }

    func getUpdatedValue() -> Bool {
        service.toggle(bool: model.boolProperty)
    }
}

// MARK: View

class View: UIView {

    private(set) var viewModel: ViewModelRepresentable

    required init(viewModel: ViewModelRepresentable) {
        self.viewModel = viewModel

        setupViewVisibility()
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    func setupViewVisibility() {
        isHidden = viewModel.getUpdatedValue()
    }
}

# Unit Tests

My hope is that by now you’re thinking to yourself “What about testing the Service Locator and its usage?”, and I’ve got you covered! Take a peek at the tests I used to develop the implementation I showcased above here. And a massive shoutout to the guys at Mindera for open-sourcing Alicerce’s ServiceLocator and the tests for it as well 💛

# Going the extra mile 🏃‍♂️

Before you leave, if earlier you found that whole Service Locator sample usage a bit bloated 🤬, rest assured you are not alone. Luckily, with a bit of ingenuity 👷‍♂️🪄 we can even begin expanding our simple Service Locator with a cool getOrRegister method that does the searching and error handling for us, delegating that awkward try-catch dance that we’ve seen before into the implementation details of the ServiceLocator.

// Conforming to this abstraction is the linchpin
protocol Serviceable {
    init() // 👈
}

final class ServiceLocator {

    // ...

    @discardableResult
    func register<Service>( /* ... */ ) throws -> ServiceName {
        // ...
    }

    func get<Service>( /* ... */ ) throws -> Service {
        // ...
    }

    // ...

    func getOrRegister<Service: Serviceable>( // 👈
      _ type: Service.Type
    ) -> Service {

        // perform the same try-catch dance as before, but with abstractions!
        do {
            return try get()
        } catch Error.inexistentService {
            let instance = Service.init() // 👈

            do {
                try register(service: instance)
            } catch {
                // 🔨 not thread safe... yet! 🔨
            }

            return instance

        } catch {
            // 🔨 not exhaustive... yet? 🔨
        }
    }

    // ...
}

And now, we can just do the following 💆‍♂️

let viewModel = ViewModel(
    model: .init(boolProperty: true),
    service: serviceLocator.getOrRegister(ServiceImplementation.self)
)

Thanks for sticking around, and be sure to check out part 1 if you haven’t already!

Hopefully this two-part blogpost helped you improve the testability of your codebase. 🤞 Until next time 👋




# Sources

Definitions:

Stored properties:

Service Locators: