Dependency injection is one of those things that sounds complicated until it clicks — and once it clicks, you can’t imagine building without it. The problem is that most DI frameworks make it feel harder than it should be.
So I built Forge. Register a dependency, resolve it, move on. That’s the whole pitch — with first-class support for Xcode Previews, clean testing, and modular SPM architectures baked in.
What Is Dependency Injection, and Why Should You Care?
Dependency injection means: don’t let your objects create their own dependencies. Give them what they need from the outside.
Here’s the problem without it:
final class LoginViewModel {
private let authService = AuthService() // hardcoded
private let analytics = AnalyticsService() // hardcoded
}
This looks fine until you write tests. AuthService makes real network requests. AnalyticsService fires real events. Your tests are now coupled to the outside world — slow, unpredictable, and fragile.
With DI:
final class LoginViewModel {
private let authService: any AuthServiceProtocol // injected
private let analytics: any AnalyticsProtocol // injected
}
Tests get mocks. Previews get stubs. Production gets the real thing. The ViewModel is identical in all three cases.
So Why Not Just Use Initializer Injection?
For simple cases, you should. But as your app grows, you end up passing dependencies through multiple layers just to get them where they’re needed. That’s where a DI container comes in. Think of it as a lookup table for your app’s dependencies: instead of threading them through every layer by hand, objects just ask the container. It builds what they need and hands it over.
Why I Built Forge
There are already good DI frameworks in the Swift ecosystem. Factory is well-designed and widely used. Swinject has been around for years and has a large community. If either of those works well for you, keep using them.
I built Forge because I wanted something that felt more native to the way I think about Swift — dependencies declared as plain computed properties, injection via a single property wrapper, and first-class support for the things I reach for every day: modular SPM architecture, Xcode Previews, and clean testing patterns. Forge is my take on what DI should feel like in a modern Swift codebase.
Getting Started: The Simple Path
Add Forge via Swift Package Manager:
.package(url: "https://github.com/tatejennings/forge.git", from: "0.3.0")
Forge ships a built-in AppContainer and a ready-to-use @Inject typealias that resolves from it. Just extend AppContainer with your dependencies:
import Forge
extension AppContainer {
var authService: any AuthServiceProtocol {
provide(.singleton) {
AuthService()
} preview: {
MockAuthService()
}
}
var analytics: any AnalyticsProtocol {
provide(.singleton) { AnalyticsService() }
}
}
That’s your entire registration. One computed property per dependency. Now inject anywhere in your ViewModels and services:
@Observable
final class LoginViewModel {
@ObservationIgnored
@Inject(\.authService) private var authService
@ObservationIgnored
@Inject(\.analytics) private var analytics
func login(username: String, password: String) async {
try? await authService.login(username: username, password: password)
analytics.track("login_tapped")
}
}
No initializer parameters. No passing things around. @Inject resolves lazily on first access.
Note on
@Observableand SwiftUI Views:@Injectuses amutating getfor lazy resolution, which works great in classes like ViewModels and services. In SwiftUI Views (which are structs), use@Statewith direct resolution instead:@State private var viewModel = AppContainer.shared.myViewModel. Also mark injected properties in@Observableclasses with@ObservationIgnored— the injected reference never changes after injection so there’s nothing to observe, but properties inside the injected@Observableobject remain fully reactive.
The Three Scopes
Scope controls how long an instance lives. Forge keeps it simple:
extension AppContainer {
// New instance every time
var analyticsEvent: AnalyticsEvent {
provide(.transient) { AnalyticsEvent() }
}
// One instance for the lifetime of the container
var networkClient: any NetworkClientProtocol {
provide(.singleton) { URLSessionNetworkClient() }
}
// One instance until explicitly reset
var taskListViewModel: TaskListViewModel {
provide(.cached) { TaskListViewModel() }
}
}
.cached is particularly handy in SwiftUI apps — a ViewModel cached in the container survives tab switches without recreating itself, but calling AppContainer.shared.resetCached() forces a fresh one. Great for “clear and refresh” actions.
Xcode Preview Support — For Free
Define a preview factory alongside your real factory using the trailing preview: closure:
var authService: any AuthServiceProtocol {
provide(.singleton) {
AuthService() // used in the real app
} preview: {
MockAuthService() // used automatically in Xcode Previews
}
}
Forge automatically uses the preview factory inside Xcode Previews and the real factory everywhere else. No conditional compilation, no #if DEBUG blocks in your views:
#Preview {
LoginView()
// MockAuthService() used automatically — no setup needed
}
Testing with Forge
The withOverrides API replaces dependencies for exactly the duration of a test, with automatic cleanup. And unlike string-based APIs, overrides use KeyPath syntax — compile-time safe and works with Xcode’s rename refactoring:
@Test("Login calls auth service")
func loginCallsService() async throws {
let mock = MockAuthService(shouldSucceed: true)
try await AppContainer.shared.withOverrides {
$0.override(\.authService) { mock } // ← KeyPath, not a string
} run: {
let viewModel = LoginViewModel()
await viewModel.login(username: "test", password: "pass")
#expect(mock.loginCalled)
}
// overrides automatically restored — no defer needed
}
For full test isolation, swap the container per test suite:
final class LoginViewModelTests: XCTestCase {
override func setUp() { AppContainer.shared = AppContainer() }
override func tearDown() { AppContainer.shared = AppContainer() }
}
And unimplemented makes test contracts explicit — any dependency you didn’t mock fails loudly if accidentally called:
final class TestAppContainer: AppContainer {
override var analytics: any AnalyticsProtocol {
provide { unimplemented("analytics") }
}
}
Building a Modular App with SOLID Principles
This is where Forge gets interesting. For modular SPM-based apps, Forge is designed to make SOLID principles the path of least resistance.
The Structure
AppTarget
├── extends AppContainer with shared deps
└── wires feature containers at startup
FeatureAuth (SPM module)
├── AuthContainer + DI.swift
└── LoginViewModel, LoginView
CoreModels (SPM module)
└── Protocols, domain models — no Forge import
Each feature module owns its own container. The app target is the composition root — the only place that knows about everything.
One Container, One Module
In FeatureAuth, create a dedicated container and a one-line DI.swift that shadows Forge’s built-in Inject typealias:
// AuthContainer.swift
public final class AuthContainer: Container, SharedContainer {
public static var shared = AuthContainer()
public var authService: any AuthServiceProtocol {
provide(.singleton) {
unimplemented("wire this in AppTarget")
} preview: {
MockAuthService()
}
}
}
// DI.swift — shadows @Inject to resolve from AuthContainer in this module
typealias Inject<T> = ContainerInject<AuthContainer, T>
Every @Inject in FeatureAuth now resolves from AuthContainer automatically:
@Observable
final class LoginViewModel {
@ObservationIgnored
@Inject(\.authService) private var authService // clean, no container reference
}
The Composition Root
The app target wires everything together at startup using KeyPath syntax — type-safe, autocomplete-friendly, and refactoring-proof:
@main
struct MyApp: App {
init() {
AuthContainer.shared.override(\.authService) {
AppContainer.shared.authService
}
}
}
Feature modules depend on abstractions. The app target provides concrete implementations. Feature modules never import each other. That’s the Dependency Inversion Principle — and it falls out naturally from this architecture.
SOLID at a Glance
| Principle | How Forge Guides You |
|---|---|
| Single Responsibility | One container per module. AuthContainer doesn’t register analytics. |
| Open/Closed | Override at the container boundary — never add isTesting flags to production types. |
| Liskov Substitution | Always use protocol return types (any ServiceProtocol). Concrete types break mock substitution. |
| Interface Segregation | Keep protocols narrow. NetworkClientProtocol shouldn’t carry auth methods. |
| Dependency Inversion | Feature modules depend on protocols. The app target provides concretions. |
Try It
Forge is open source:
GitHub: github.com/tatejennings/Forge
Documentation: tatejennings.github.io/Forge/documentation/forge
.package(url: "https://github.com/tatejennings/forge.git", from: "0.3.0")
If you try it, find a bug, or have ideas — open an issue or reach out. This is a framework built by an iOS developer for iOS developers, and real-world feedback is how it gets better.
Happy injecting. 🔨