Skip to content

robert-northmind/SwiftiePod

Repository files navigation

SwiftiePod

SwiftiePod is a lightweight and easy-to-use Dependency Injection (DI) library for Swift. It’s designed to be straightforward, efficient, and most importantly safe!

Unlike many other DI libraries, SwiftiePod ensures you won’t ever run into a runtime exception for forgetting to register a dependency before trying to resolve it.

Why SwiftiePod?

  • Lightweight & Easy: SwiftiePod does exactly what you need without unnecessary complexity. Setup and usage are kept simple and intuitive.
  • Compile-time Safety: With SwiftiePod, you’ll never get a runtime exception for resolving an unregistered type. SwiftiePod’s approach helps you catch issues early, preventing nasty surprises at runtime.
  • Flexible State Management: SwiftiePod can cache your instances if you want, making it easy to manage singletons or reuse existing objects when appropriate.

Installation

Swift Package Manager (SPM)

Add SwiftiePod as a dependency in your Package.swift:

dependencies: [
    .package(url: "https://github.com/robert-northmind/SwiftiePod.git", from: "1.0.8")
]

CocoaPods

Add this to your Podfile:

pod 'SwiftiePod'

Quick Example

In some made up business logic called DataService.swift:

import SwiftiePod

// Define a `Provider` for the `DataService`.
// A `Provider` is a SwiftiePod thing.
// It's a thing which knows how to build your types.
let dataServiceProvider = Provider<DataService> { pod in
    return RemoteDataService(
        httpClient: pod.resolve(httpClientProvider)
    )
}

protocol DataService {
    func fetchData() -> [String]
}

class RemoteDataService: DataService {
    private let httpClient: HttpClient

    init(httpClient: HttpClient) {
        self.httpClient = httpClient
    }

    func fetchData() async throws -> [String] {
        return await httpClient.get("/some/endpoint")
    }
}

In the start code of your app, you setup your pod:

import SwiftiePod
let pod = SwiftiePod()

Finally, when you need an instance of your DataService you resolve it from the pod:

import SwiftiePod
let dataService = pod.resolve(dataServiceProvider)
// Now you can use the dataService...

For more in-depth examples, see the ExampleApp.

Documentation

Background

Most dependency injection libraries consists of some container, into which you register some builder for a given type. Something like this:

// Setup and register stuff
let container = DIContainer()
container.register(DataService.self) { _ in
    return RemoteDataService()
}

// Get your instance out of the container by passing the type
let dataService = container.resolve(DataService.self)!

This approach has some downsides:

  • Risk of crashing your app. What happens if you try to resolve a type which has not yet been registered? 💣💥
  • You end up with a huge "register" section in your app where you need to register all your types.

SwiftiePod - Core concept

SwiftiePod takes a different approach. Instead of registering the builder in the container, you define a variable for the builder, called a Provider, and then you use this Provider to resolve instances from your container. There is no registration part!

This way, the container always knows how to build your instances. There will never be any app crashes due to not-registered types.

The two core components in SwiftiePod is the SwiftiePod and the Providers.

The SwiftiePod is your container. You use this to resolve your types.

The Providers are your builders. You pass these into the pod to get instances. For everything which you want to have an instance of, you define a Provider. It looks something like this:

let myCoolServiceProvider = Provider { _ in
    return MyCoolService()
}

And that's it! 🥳

The Providers

As mentioned above, the Providers are your builders and the things you use to resolve a type. They are basically the building blocs which help you accomplish dependency injection.

For each type which you need instances from, you define a Provider. And then when you need an instance of that type you get it by passing that Provider to the pod.

let myCoolService = pod.resolve(myCoolServiceProvider)

If the type you are trying to build needs other types as input (dependency injection), then you can simply grab those types using the provided pod parameter passed into the builder method of your Provider.

let someServiceProvider = Provider { pod in // <-- Here you get a reference to your pod
    return SomeService(
        // Use that pod here to get any needed dependencies
        aDependency: pod.resolve(aDependencyProvider),
        anotherDependency: pod.resolve(anotherDependencyProvider)
    )
}

The typical flow of creating a Provider is usually at the top of the file where where you define your type:

let myCoolClassProvider = Provider { _ in
    return MyCoolClass()
}

class MyCoolClass {
    // Some interesting business logic
}

If you don't specify which type a Provider has, then it will implicitly get the same type as it's return value. You could also explicitly specify the type. For example if you have a protocol which you use as an abstraction layer.

// Will have this type `Provider<Int>`
let aNumberProvider = Provider { _ in
    return 123
}

// Will have this type `Provider<any CurrentUserServiceProtocol>`
let currentUserServiceProvider = Provider<CurrentUserServiceProtocol> { _ in
    return CurrentUserService()
}

protocol CurrentUserServiceProtocol {
    func username() -> String
}

class CurrentUserService: CurrentUserServiceProtocol {
    func username() -> String {
        return "Jane Doe"
    }
}

When you create your Provider you can also specify how its instances should be cached. You control this using the Scope parameter.

Out of the box, SwiftiePod provides you with 2 predefined scopes: AlwaysCreateNewScope and SingletonScope.

As you might be able to guess from the names, the AlwaysCreateNewScope will never cache instances. Every time you resolve a provider with this scope, it will run the builder and create a new instance.

And the SingletonScope will make sure to cache instances throughout the lifetime of your pod.

If you don't pass in a scope parameter to your Provider, then it will default to use SingletonScope.

You can also create your own custom scopes by implementing the ProviderScope protocol. This way, you could define a scope for a given flow of your app. For example you could have a specific scope for a sign-up flow in your app.

final class SignUpScope: ProviderScope {
    let children: [any ProviderScope] = []
}

let someSignUpProvider = Provider(scope: SignUpScope()) { _ in
    return SomeSignUp()
}

When the user completes the sign-up flow in your app, then you could easily clear all cached instances with that scope. So that the next time the user enters the sign-up flow, he gets new "clean" instances.

You can even layer custom scopes. Meaning that you could assign child scopes to your custom scopes. And then choose to clear only the child scopes or clear the parent scope (which would then make sure to also clear any child scopes)

final class SignUpScope: ProviderScope {
    let children: [any ProviderScope] = [SomeChildSignUpScope()]
}

final class SomeChildSignUpScope: ProviderScope {
    let children: [any ProviderScope] = []
}

let someChildSignUpProvider = Provider(scope: SomeChildSignUpScope()) { _ in
    return SomeChildSignUp()
}

// Clears all cached instances for SomeChildSignUpScope and SignUpScope
pod.clearCachedInstances(forScope: SignUpScope())

// Clears all cached instances for SomeChildSignUpScope
pod.clearCachedInstances(forScope: SomeChildSignUpScope())

One final thing to note about Providers:
You could define multiple providers for the same type. Meaning you could for example have 2 Providers both returning a String type.
And both of these would live independently and have their own cache and lifecycle.

let myAppTitleProvider = Provider { _ in
    return "My cool app"
}

let myAppDescriptionProvider = Provider { _ in
    return "This is a really exciting and cool app"
}

let string1 = pod.resolve(myAppTitleProvider) // <-- "My cool app"
let string2 = pod.resolve(myAppDescriptionProvider) // <-- "This is a really exciting and cool app"

The SwiftiePod

The main task of the pod is to let you resolve providers to get some instances. 💃🪩🕺

Somewhere in your app you will need to define your pod by doing:

import SwiftiePod
...
let pod = SwiftiePod()

This is then the pod which you will use through your application.

Aside from resolving providers, the pod has 2 other functionalities. Overriding providers and clearing cached instances.

Overriding Providers

Providers are defined at compile time. But sometimes it might be useful to override/change the behavior of a Provider and change how it builds stuff. For example if you want to provide mock instances during testing or during ui previews.

The pod offers a overrideProvider(...) for this. You pass in which provider you want to override, and then a new build method which you want to use instead. Here is how it looks like:

pod.overrideProvider(myCoolServiceProvider) { _ in
    return MockMyCoolService()
}

Now, every time you call pod.resolve(myCoolServiceProvider) you will get back the MockMyCoolService instead of the real service.

When you want to remove an override for a given Provider you can call pod.removeOverrideProvider(...). This will effectively remove the override and the provider will again return the original instance.

The instances you get from overridden providers can also be cached. By default, the override will use the same Scope as the provider you override. But you can change so that the override has its own Scope. To do this, just pass along a scope parameter when calling overrideProvider(...).

Clearing cached instances

If your Provider has a Scope which is not AlwaysCreateNewScope, then the instances of your Provider will be cached in the pod. This means that the first time you call pod.resolve(...), it will run the builder and create a new instance. But the second time you call pod.resolve(...), then it will just return the cached instance.

Sometimes you might want to clear these caches, for example if some instances should only be active during a given flow of your app, like a sign-up flow. Then you could assign all those Providers a SignUpFlowScope and when you completed the sign-up flow, you could call pod.clearCachedInstances(forScope: SignUpFlowScope()).

This will make sure that all cached (with the SignUpFlowScope) are cleared from cache. So the next time a user enters the sign-up flow, all those instances would be recreated and "clean".

NOTE:
Providers which are using the SingletonScope will ignore the clearCachedInstances, meaning that these instances will never be cleared. They are cached for the lifetime of your pod.

Contributing

If you find a bug, want to request a new feature, or contribute in any other way, please open an issue or submit a pull request.

License

Distributed under the MIT license.