All writing

Why We Chose Swinject for a Document SDK

A reflection on dependency injection, object lifetimes, and SDK composition boundaries

An SDK Is Not a Smaller App

A Document SDK is easy to underestimate. From the outside, it can look like a collection of screens that a host application presents when needed.

In production, it behaves more like an application system running inside someone else’s lifecycle. The SDK is ultimately integrated into a larger collaboration product. The host owns identity, navigation, shared networking, analytics, and application lifecycle. The SDK delivers the complete document experience: home, recent and shared documents, search, detail, and editing, together with caching, offline operations, permissions, and collaboration state.

The difficulty is not simply the number of features. All of these capabilities depend on the same mutable context. Switching accounts changes credentials, cache namespaces, permission snapshots, and collaboration subscriptions at once. Recovering from an offline period means more than retrying a request: local operations must be replayed, remote revisions refreshed, and conflicts resolved. The same document also exposes different capabilities under view, edit, and sharing permissions.

That left us with three more fundamental questions than how to create a view controller:

  1. Who assembles an object graph that keeps growing?
  2. How can implementations vary across hosts, accounts, network conditions, and tests?
  3. How long should transports, caches, document sessions, and screen objects live?

Without consistent answers, dependencies spread in the same direction as the product.

Dependency Problems Rarely Appear All at Once

The first version is usually straightforward. A screen creates a view model, the view model creates a repository, and the repository reaches for a network client and local cache.

final class DocumentListViewModel {
    private let repository = DocumentRepository(
        remote: HTTPDocumentService.shared,
        cache: SQLiteDocumentCache.shared
    )
}

This works, but it mixes business orchestration with object construction. As the product grows, the costs become visible:

  • Unit tests cannot replace the network or cache.
  • Preview, demo, and production hosts need different implementations, so environment checks accumulate.
  • After an account switch, old singletons may still hold credentials, cache paths, or subscriptions.
  • Offline behavior, permission policy, and analytics begin reading global state independently.
  • Initialization order becomes scattered across screens, making SDK startup difficult to reason about.

The real risk is not a long initializer. It is an object deciding where its own dependencies come from. Once dependencies become implicit state, a type signature no longer describes the conditions under which that type actually runs.

Two Convenient Options We Rejected

Global singletons

Singletons reduce parameter passing by fixing an object’s lifetime to the process. That lifetime is usually too long for an SDK that changes accounts, data domains, or host environments. Tests can also inherit state left behind by earlier tests.

Service Locator

Exposing a global container makes every object appear flexible because anything can call resolve. The cost is that dependencies disappear from initializers. A missing registration is only discovered when a particular runtime path is reached, while the container becomes another global variable.

We did not need a warehouse from which any object could retrieve a service. We needed a composition root that described the object graph in one place. The container would remain at the system boundary, while business objects would continue to use initializer injection.

Why We Did Not Stop at Manual Dependency Injection

Manual dependency injection was our default choice. It remains the better option when the number of modules is small and the object graph is stable.

The Document SDK varied across too many dimensions: multiple hosts, remote and offline data sources, account and permission contexts, production and test infrastructure, and independently evolving list, detail, search, sync, and collaboration modules. A handwritten composition root was quickly becoming a large collection of repetitive factories with its own rules for reuse and assembly order.

We chose Swinject not because Swift was unable to express dependency injection, but because composing the graph had become recurring work that needed consistent rules. Swinject 2.7.1 offered a model that matched the problem at the time:

  • Protocols and implementations could be registered with Swift types and closures.
  • Factories could resolve downstream dependencies and accept runtime arguments.
  • Assembly grouped registrations by module, while Assembler composed them.
  • Graph, Container, and Weak scopes described instance-sharing behavior.
  • Container hierarchies allowed shared host registrations and local overrides.
  • A synchronized resolver supported concurrent resolution after registration was complete.

Swinject did not require business types to inherit from framework classes, and it did not require us to pass a container through the object graph. As long as it remained inside the composition root, it was an assembly tool rather than an architectural dependency.

Define the Host Boundary First

Before discussing registrations, we divided the integration contract into two sides: the host provides the runtime environment, while the SDK owns the document domain. This boundary mattered more than any container API.

The host providesThe Document SDK owns
Current account, credentials, and organization contextDocument identity, list indexes, and presentation state
Shared transport, connectivity, and application lifecycleCache namespaces, offline operation queues, and synchronization policy
Navigation exits, deep links, logging, and analyticsPermission projections, collaboration events, and document sessions
System storage capabilities and security policyHome, recent, shared, search, detail, and editing features

The SDK should not reach back into host singletons. The host should not know which cache or synchronization implementation the SDK uses. The two sides exchange protocols and configuration only at the entry point. That makes the SDK a real domain boundary rather than a group of view controllers extracted from the main project.

The object graph then falls into three natural layers:

  1. Host scope follows the SDK instance and owns adapters for navigation, transport, and logging.
  2. Account scope follows the active account and owns authentication context, cache partitions, permissions, and synchronization.
  3. Document session follows one open document and owns editing state, collaboration connections, and pending operations.

An account switch should not mutate a token inside a global object. It should end the existing account graph and build a new one from the new account context. Closing a document should release its session as a unit. These lifecycle boundaries were the real reason to introduce a container.

The Dependency Direction We Wanted

The host supplies configuration and capabilities such as authentication, transport, storage location, logging, and presentation. The composition root turns those inputs into registrations and creates a small number of feature roots. The container travels no further.

A list view model therefore continues to declare its dependencies clearly:

final class DocumentListViewModel {
    private let repository: DocumentRepositoryProtocol
    private let permissionPolicy: PermissionPolicy
    private let analytics: AnalyticsTracking

    init(
        repository: DocumentRepositoryProtocol,
        permissionPolicy: PermissionPolicy,
        analytics: AnalyticsTracking
    ) {
        self.repository = repository
        self.permissionPolicy = permissionPolicy
        self.analytics = analytics
    }
}

Swinject satisfies this initializer at the system boundary. It does not change the design of the type itself.

Use Assemblies to Express Module Ownership

We did not place every registration in a several-hundred-line Container.swift. Registrations need module ownership just as implementations do.

import Swinject

final class DocumentDataAssembly: Assembly {
    private let account: AccountContext

    init(account: AccountContext) {
        self.account = account
    }

    func assemble(container: Container) {
        container.register(DocumentRemoteService.self) { resolver in
            HTTPDocumentRemoteService(
                transport: resolver.resolve(HostTransport.self)!,
                credential: self.account.credential
            )
        }
        .inObjectScope(.container)

        container.register(DocumentCache.self) { _ in
            SQLiteDocumentCache(namespace: self.account.cacheNamespace)
        }
        .inObjectScope(.container)

        container.register(OfflineOperationStore.self) { resolver in
            SQLiteOfflineOperationStore(
                cache: resolver.resolve(DocumentCache.self)!
            )
        }
        .inObjectScope(.container)

        container.register(DocumentRepositoryProtocol.self) { resolver in
            DocumentRepository(
                remote: resolver.resolve(DocumentRemoteService.self)!,
                cache: resolver.resolve(DocumentCache.self)!,
                offlineOperations: resolver.resolve(OfflineOperationStore.self)!
            )
        }
        .inObjectScope(.container)
    }
}

List, detail, search, and synchronization modules each supplied their own Assembly. They declared the implementations they owned and the protocols they required without knowing where other registrations came from. Host and account capabilities lived in parent and child containers, while production, demo, and test targets could select different Assembly sets:

let hostAssembler = Assembler([
    HostInfrastructureAssembly(capabilities: hostCapabilities)
])

let accountAssembler = Assembler([
    DocumentDataAssembly(account: account),
    PermissionAssembly(account: account),
    CollaborationAssembly(account: account),
    DocumentListAssembly(),
    DocumentDetailAssembly(),
    SearchAssembly()
], parent: hostAssembler)

The value was not saving a few initializer calls. It was making what a module requires and provides visible in one reviewable place.

Scope Is Business Semantics, Not Just a Performance Option

Scope is one of the easiest container features to misuse. Registering every service with .container merely replaces one form of singleton with another.

We mapped object lifetime to business boundaries:

LifetimeSuitable objectsReason
TransientOne-shot commands and lightweight formattersEach use should receive independent state
GraphCoordinators and child view models created for one featureShared within one resolution graph, recreated for the next
ContainerTransport, cache, repository, and sync coordinator for the current environmentLives with its Host or Account container
WeakReusable helpers whose lifetime the container should not extendReleased when no external owner remains

When the account changes, we destroy the Account container and create a new account graph instead of mutating a global token. Credentials, cache handles, permission snapshots, cancellation mechanisms, and collaboration subscriptions now share one cleanup point rather than relying on several unrelated reset() calls to succeed.

When a document needs isolated session state, its feature factory accepts runtime arguments such as the document ID and creates a new graph. Lifetime becomes part of the architecture: whoever owns an object is also responsible for ending its world.

Container Hierarchy Represents Environments, Not Screens

A child container can resolve registrations from its parent while supplying local overrides. This fits an SDK boundary: the parent can own host logging, transport, and general storage, while the child owns one account or feature environment.

We did not create another container for every screen. Deep hierarchies make it difficult to determine which registration produced the final implementation. A new container boundary was justified only when both configuration and lifetime changed.

Host infrastructure
└── Account-scoped Document SDK
    └── Feature object graph

The view hierarchy is not the dependency hierarchy.

Offline Is Not a Boolean

If offline support is reduced to isOffline == true, conditionals quickly spread through repositories, view models, and views. A document system has several distinct offline states:

  • A readable local snapshot exists, but the remote revision is unknown.
  • The user has produced local operations that have not been committed.
  • Connectivity has returned and operations are being replayed in order.
  • The remote revision has changed and requires merge or conflict policy.
  • The account has changed, so the previous account’s queue must never enter the new account’s synchronization path.

Instead of injecting a global Bool, we defined stable capabilities such as ConnectivityMonitoring, OfflineOperationStore, DocumentSyncing, and ConflictResolving. Online and offline were not separate screens. They were different compositions of the same business objects.

Swinject allowed local storage, remote services, synchronization, and conflict policy to be composed only inside the Account composition root. A feature depended on DocumentRepositoryProtocol and explicit synchronization state; it did not need to know whether data came from a cache, the network, or a recovering operation queue.

Tests could now construct scenarios such as editing offline and reconnecting, or switching accounts with pending operations, without modifying global connectivity state in production code.

Permissions and Collaboration Are Mutable Environments

Permission checks do not end when a screen opens. A document may be view-only, editable, or manageable. When sharing rules or collaborators change, the available actions on the current screen must change as well. Real-time collaboration also introduces a continuous stream of remote changes into the document session.

We separated these responsibilities into PermissionProviding, CollaborationStreaming, and DocumentSession. The Account graph supplied account- and organization-level permission context. A Document session then combined the permissions, remote events, and local editing state for one document.

This established an important constraint: permissions and collaboration are inputs, not facts hidden in a global object. View models can declare them explicitly, and tests can cover revoked permissions, collaborator updates, and reconnecting sessions without launching the entire host application.

What This Changed for the Product

Multiple hosts no longer duplicate initialization

Each host supplies configuration and host capabilities. The SDK builds its internal graph from the same Assemblies, avoiding subtly different startup sequences across integrations.

Offline, reconnecting, and testing become first-class compositions

Tests can replace the remote service, cache, offline operation store, clock, and analytics without modifying business objects. A fully working offline demo can be assembled without scattering if isOffline through production implementations. Reconnect and conflict cases become deterministic test inputs.

Account switching no longer depends on clearing global state

Account-related objects remain inside the Account container. Signing out releases credentials, cache partitions, permission context, and collaboration subscriptions through one ownership boundary.

The host contract can be verified independently

The SDK entry accepts an explicit set of host capabilities. A demo host supplies minimal implementations, the main project supplies shared infrastructure, and integration tests can report missing capabilities during startup. The SDK is no longer coupled to one host through invisible global state.

Modules can evolve independently

The list module changes its own Assembly and implementation without editing a global factory file. Modules communicate through protocols, and only the composition root knows concrete types.

Architecture tests have an explicit entry point

In addition to testing individual business objects, we can build the complete Assembler and verify that critical feature roots resolve. Missing registrations fail before a user reaches a deeply nested screen.

The Boundaries We Set Around Swinject

Adopting a container introduces new failure modes. Registrations can be missing, named registrations can be mistyped, an incorrect scope can extend an object’s lifetime, and a circular dependency can remain hidden because the framework happens to resolve it.

We adopted several rules:

  1. Container appears only in the composition root and Assemblies.
  2. Business types use initializer injection and never depend directly on Resolver.
  3. Registration constructs objects but performs no network requests, database migrations, or other side effects.
  4. Important business variants use semantic protocols rather than string registration names.
  5. Every .container scope must justify its business lifetime; it is never chosen merely because it might be faster.
  6. A dependency cycle is treated as a design signal, even when Swinject can resolve it through property injection.
  7. Registration happens during single-threaded startup; concurrent consumers receive only a synchronized Resolver.
  8. Critical feature roots have resolution tests with actionable errors for missing registrations.

These rules kept Swinject at the edge of the architecture. The framework could be replaced without rewriting business modules.

The Design Philosophy Behind Dependency Injection

We did not choose automatic object construction. We chose a way of thinking about boundaries.

Dependencies should be provided from the outside in. Business objects should not search outward for their runtime environment.

Networking, caching, permissions, collaboration, and analytics are policies. The Document SDK core describes the capabilities it needs, but does not decide which host, account, or infrastructure supplies them. Change remains at the outside of the system; stable business rules remain inside.

Object lifetime should express business lifetime.

The process, SDK instance, account, document session, and screen are not the same world. Creating and destroying object graphs with those boundaries is easier to reason about than repeatedly resetting long-lived singletons. An account switch becomes graph replacement, offline recovery becomes policy collaboration, and closing a document ends a session.

Explicit does not mean every line must be handwritten.

An initializer makes the dependencies of one object explicit. An Assembly makes the composition of the whole system explicit. Swinject handles repetitive resolution, but it cannot decide our module boundaries for us.

Looking Back

If the Document SDK had only a few stable services, we would have continued with manual dependency injection. Swinject is valuable only when the object graph, environment combinations, and lifetimes are genuinely complex.

That complexity already existed in our case. The SDK had to live inside a large host while crossing account, cache, offline synchronization, permission, and real-time collaboration boundaries. Swinject concentrated that complexity into a composition layer that was replaceable and testable, allowing business objects to focus on their own responsibilities again.

The important result was not how many objects the container created for us. It was that those objects no longer needed to know which world they were running in.

References