Introducing Service — a modern DI library designed for Swift Concurrency and SwiftUI

Hi Swift community! :waving_hand:

I’d like to introduce Service, a lightweight dependency injection (DI) library for modern Swift projects.

Service is inspired by Swinject (familiar container ergonomics) and swift-dependencies (modern dependency management/modeling ideas).
It aims to be a Swift 6 Concurrency-friendly alternative to Swinject: you keep the familiar register/resolve mental model and an engineering-friendly Assembly workflow, while adopting patterns that fit async/actors/MainActor UI naturally.

Repo + Docs:


Highlights

  • Lightweight & zero-dependency: easy to adopt, easy to ship.
  • Concurrency-oriented (Swift 6): designed for modern async code, actors, and explicit MainActor UI dependencies.
  • Familiar for Swinject users: register/resolve + Assembly-style organization for large apps.
  • Test-friendly: override dependencies cleanly (without global container mutation) using TaskLocal-based environments.

Familiar API: register / resolve

import Service

protocol DatabaseProtocol { /* ... */ }

ServiceEnv.current.register(DatabaseProtocol.self) {
    DatabaseService(connectionString: "sqlite://app.db")
}

Injection stays clean at call sites:

struct UserRepository {
    @Service var database: DatabaseProtocol

    func fetchUser(id: String) async throws -> User? {
        try await database.findUser(id: id)
    }
}

Assembly style for modular apps (Swinject-friendly)

This is the pattern I’m using in a real project today:

struct AppAssembly: ServiceAssembly {
    func assemble(env: ServiceEnv) {
        env.registerMain(AppContainer.self) { AppContainer() }

        env.register(Localization.self) { Localization() }
        env.register(ThemeManager.self) { ThemeManager() }

        env.registerMain(Router.self) { Router() }
        env.registerMain(Overlay.self) { Overlay() }
    }
}

Bootstrap:

ServiceEnv.current.assemble([
    AppAssembly()
])

This scales well when you split registrations by features/modules and keep composition explicit.


MainActor-safe dependencies (SwiftUI / UI)

UI-facing services (routers, overlays, view models, etc.) often must live on MainActor.
Service provides a dedicated pathway so intent is explicit:

ServiceEnv.current.registerMain(Router.self) { Router() }

struct ContentView: View {
    @MainService var router: Router
    var body: some View { /* ... */ }
}

Testing: switch environment and assemble the same modules

In tests you can switch to a .test environment at the outermost scope and keep the same Assembly structure:

await ServiceEnv.$current.withValue(.test) {
    ServiceEnv.current.assemble([
        AppAssembly()
        // ... other assemblies
    ])

    // Run your test logic inside the .test environment
}

And inside an Assembly you can branch on the environment to register mocks while keeping registrations structured:

struct AppAssembly: ServiceAssembly {
    func assemble(env: ServiceEnv) {
        if env.isTest {
            env.register(Localization.self) { MockLocalization() }
        } else {
            env.register(Localization.self) { Localization() }
        }

        // keep the rest identical
        env.register(ThemeManager.self) { ThemeManager() }
    }
}

If you’re using Swinject and want a Swift 6 Concurrency-friendly DI with zero dependencies and a familiar workflow, I’d love for you to try Service — feedback, edge cases, issues, and PRs are very welcome!
Thanks for reading!

3 Likes

All the same and inspired by Swinject, but without a singleton and without requiring MainActor-safe dependencies.. GitHub - NikSativa/DIKit: Swift library that allows you to use a dependency injection pattern in your project by creating a container that holds all the dependencies in one place

UIKit + SwiftUI at the same time

Hey as long as we’re showing off our dependency injection solutions, here’s mine! :wink:
No singleton, optional MainActor, and most importantly, possibility of overriding the value of a dependency in a single Task (TaskLocal). This last point allows testing two different configurations in parallels, which is convenient, I guess :slight_smile:

Obviously compatible with macOS, Linux and Windows.

@NikSativa Thanks for the link — DIKit looks very close to the classic Swinject-style “container + assemblies” workflow, and I can see why that’s appealing.

In Service, Swift 6 concurrency is a first-class constraint at the DI boundary. The key idea is to make isolation explicit in the API so developers don’t have to “paper over” concurrency requirements.

Practically, this is why Service has an explicit registerMain / @MainService pathway: it’s designed for dependencies that are @MainActor-isolated but not Sendable (which is a very common shape for classes that must live on the main actor). Instead of forcing Sendable conformance just to satisfy injection, Service gives a low-friction, explicit route that preserves the isolation contract and keeps the cost of adoption low.

@Frizlab Thanks for sharing GlobalConfModule. The “override a dependency in a single Task (TaskLocal), and test two configurations in parallel” idea is genuinely useful.

I’m still thinking about whether to introduce a similar “fine-grained override” API in Service. I agree this is a strong practice in the ecosystem, but my concern is auditability at scale: in a large codebase, overrides scattered across many call sites can become hard to track. Today I’m leaning toward keeping mocks/replacements more centrally declared at the assembly/register layer (project-level control), but I do think it’s worth discussing — especially with people shipping strict concurrency in Swift 6.

If you have examples of how you keep per-task overrides discoverable/maintainable in larger projects, I’d love to learn.

A quick note on where I’d like Service to go next.

I’m not trying to “replace” Swinject in a marketing sense — Swinject is widely used, and its core API shape (register/resolve, plus assembly-based organization) is both elegant and consistent. The direction for Service is to stay compatible with that mental model, while treating Swift 6 strict concurrency as a first-class constraint from day one (instead of relying purely on internal locking/synchronization and allowing any type to be registered).

Today, Service uses TaskLocal environments (ServiceEnv.current) + a storage layer to provide scoped resolution and caching, and an explicit MainActor path (registerMain / @MainService) to make @MainActor but non-Sendable dependencies practical to use.

Roadmap

  • Lifetime semantics (Swinject-like): build on the existing TaskLocal + storage design to support familiar lifetimes such as weak, graph, and transient.
  • More injection ergonomics: expand property wrappers / entry points (e.g. @LazyService, @Provider) so callers can choose eager vs lazy vs provider-based resolution without losing isolation intent.
  • Richer error reporting: improve debuggability by surfacing structured error details (e.g. maxResolutionDepth, notRegistered, circularDependency). Some of this is already on main.
  • Large-app orientation: I previously used Swinject in a large codebase, and I want Service to stay usable at that scale — centralized declarations, explicit composition, and globally controllable behavior are deliberate design goals.
  • Scoped overrides / mocking: TaskLocal overrides are a strong practice in the ecosystem. My current concern is auditability at scale (overrides scattered across many call sites). I’m leaning toward a more centrally declared approach (assembly/register level) for mocks/replacements, and I haven’t committed to an “EnvValue-style injection API” yet — still exploring this.
  • Keep it lightweight: remain zero-dependency and keep the API surface familiar to Swinject users.

If anyone has real-world experience balancing strict concurrency, large-scale DI organization, and override/mocking strategies, I’m happy to compare notes — especially on what stays maintainable over time.

IMHO per-task overrides is not the biggest issue. They should pretty much only be used very locally while testing (for UI applications; for the server it’s different, having overrides is very convenient to have an environment for a given request, in which case the override should be done in the middleware).

My biggest issue, which I do not really know how to solve, is dependency discovery. Recently I created a new test target to test one of our module. This module used some dependencies implicitly, including one who could not have a default value that made sense. When running the tests I encountered a crash when this dependency was resolved.

I think solving this issue can only be done with DI solutions like GitHub - uber/needle: Compile-time safe Swift dependency injection framework which does some code generation to ensure nothing is left uninitialized. But this is usually complex to setup (I have not tried, maybe it is not).

That’s exactly what I’m saying: it’s the same idea, and even more. You don’t need to specify registerMain / @MainService during registration, because for DIKit it doesn’t matter which thread you’re on. Likewise, you don’t need to make all models conform to Sendable… and there’s more: DIKit doesn’t require a singleton, and it’s added to the SwiftUI environment, which is the canonical way to use dependencies.

Going forward, it’s enough to just write:

@main
struct MyApp: App {
    let container = Container(assemblies: [...])

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(container.toObservable())
        }
    }
}

// somewhere in the code
struct ContentView: View {
    @DIObservedObject var api: API
    
    var body: some View {
        Button("Make request") {
            api.request()
        }
    }
} 

final class API: ObservableObject { // not Sendable & not MainActor
    func request() {
    }
}




/// base contract for all view models
protocol ViewModeling: ObservableObject {}
typealias iVModel<T: ViewModeling> = DIKit.DIStateObject<T>

/// any manager
typealias iObsObj = DIKit.DIStateObject

/// any state or protocol
typealias iLazy = DIKit.DIState
/// anything
typealias iProvider = DIKit.DIProvider

I’m still not sure whether Service should grow into a full “EnvironmentValue-style defaults” model. There is a lightweight default mechanism today (via ServiceKey.swift), but short term I’m keeping the surface area small and leaning toward fail fast for anything that should be explicitly wired.

I think defaults only make sense when a fallback is genuinely acceptable (e.g. a no-op logger, or a standard JSON encoder/decoder). For business-configured dependencies (API base URL, auth/session, analytics/feature flags, etc.), a default usually hides wiring mistakes — those should fail early.

On overrides: the cost of “local override vs centralized declaration” can be similar; my concern is mostly readability—I want it to be obvious where a dependency was replaced when you read the code.

Long term I’d like the API to balance power with intuitive guardrails, so it’s hard to misuse.

Totally — my goal with Service is to treat Swift 6 strict concurrency/isolation as a first-class part of the DI API (so concurrency constraints are expressed at the boundary, not just “handled internally”), whereas Swinject/DIKit intentionally stay more general-purpose and allow “register anything” as the default model.

DI is needed to manage all application dependencies in the most developer-friendly way, and only for that purpose. These are not hacker-style workarounds for concurrency. It’s just regular thread safety at a lower level, nothing more. As a result, a developer shouldn’t have to think about concurrency when registering an object in the DI container—but inside the object itself, they absolutely must. DI doesn’t affect that in any way. That’s the advantage of this approach: constraints are removed at the dependency level, while everything inside the application’s business logic remains exactly the same :vulcan_salute:t2:

check Demo project how it works GitHub - NikSativa/Demo: This app is a simple task manager. It uses DIKit for dependency injection and SpryKit for testing. The main purpose of this app is to show how to use DIKit and SpryKit in a SwiftUI project. It is not a full-featured app, but it is a good starting point for your own project.

Concurrency awareness and explicitness would be a key feature for me when selecting a DI library.

However nowadays for auditability the Swift team for safe code seems to be using not centralized API but macros and attributes. This makes each site discoverable and programmatically auditable, and possibly controllable for purposes of version functionality upgrades.

Have any open-source libraries taken this path? Was it worth the complexity?

@wes1 I'm not aware of any Swift DI library that's taken the full macro/attribute route. Swift Macros can't do cross-file analysis or access type metadata across modules, so there's a ceiling on what you can validate at that level. Needle went with standalone codegen instead, which is a different trade-off.

For Service, it's basically two layers. Registration is centralized in Assembly files because I think dependency wiring is a composition decision, not intrinsic to a type (unlike @Sendable or @MainActor which naturally live at the definition site). Resolution is the opposite: @Service / @MainService are distributed markers at each call site, greppable, with concurrency semantics baked in. So both ends are auditable, just through different mechanisms.
On the "version functionality upgrades" angle, honestly haven't thought deeply about that in DI yet. Interesting direction though.

Are you looking at DI for a project with strict concurrency on?