A way to thread dependencies through your app using Swift's protocol system.
You have services that need other services:
struct OrderService {
func create(userId: String, items: [Item]) -> Order {
// Need to look up user... somehow
// Need to check inventory... somehow
// Need to charge payment... somehow
}
}Option 1: Singletons. Easy to write, impossible to test.
let user = UserService.shared.find(userId) // Good luck mocking thisOption 2: Pass everything explicitly. Safe, but tedious.
struct OrderService {
let userService: UserService
let inventoryService: InventoryService
let paymentService: PaymentService
let db: Database
init(userService: UserService, inventoryService: InventoryService,
paymentService: PaymentService, db: Database) {
// ...
}
}
// Somewhere at the root of your app:
let db = PostgreSQL()
let userService = UserService(db: db)
let inventoryService = InventoryService(db: db)
let paymentService = PaymentService(stripe: stripe, db: db)
let orderService = OrderService(
userService: userService,
inventoryService: inventoryService,
paymentService: paymentService,
db: db
)
// ... repeat for every serviceEvery new service means more wiring. Every new dependency means updating constructors throughout the chain.
What we want: Pass one thing, get access to everything you need, but only what you declare.
Here's the fundamental move:
// 1. Declare "I need a database"
protocol HasDatabase {
var db: Database { get }
}
// 2. Write a service that works with ANY type that has a database
struct UserService<Deps: HasDatabase> {
let deps: Deps
func find(id: String) -> User? {
deps.db.query("SELECT * FROM users WHERE id = ?", id)
}
}
// 3. Create a bundle with a database
struct App: HasDatabase {
let db = PostgreSQL()
}
// 4. It just works
let app = App()
let userService = UserService(deps: app)
userService.find(id: "123")UserService doesn't know what Deps is. It just knows it has a db property. You could pass it App, or TestApp, or anything with a database.
This is the Reader pattern: instead of passing Database to every function, you pass an "environment" once and pull what you need from it.
Now let's add OrderService that needs UserService:
protocol HasUser {
var user: UserService<Self> { get }
}
struct OrderService<Deps: HasDatabase & HasUser> {
let deps: Deps
func create(userId: String, items: [Item]) -> Order {
guard let user = deps.user.find(id: userId) else {
throw OrderError.userNotFound
}
// ...
}
}But wait—how does App get a user property? We could add it manually:
struct App: HasDatabase, HasUser {
let db = PostgreSQL()
var user: UserService<App> { UserService(deps: self) }
}This works. But now every bundle needs to manually wire every service. We're back to boilerplate.
Swift lets us provide default implementations via protocol extensions:
protocol HasUser: HasDatabase {
var user: UserService<Self> { get }
}
extension HasUser {
var user: UserService<Self> { UserService(deps: self) }
}Now ANY type that conforms to HasUser automatically gets a user property, as long as it also has HasDatabase (which HasUser inherits).
struct App: HasUser { // Just declare the conformance
let db = PostgreSQL()
}
App().user.find(id: "123") // user property comes from the extensionThe wiring happens automatically through the protocol extension. self in the extension is App, so UserService captures App as its deps.
We've welded HasUser to a specific implementation. What if we want MongoUserService in production and MockUserService in tests?
// These can't both exist:
extension HasUser {
var user: UserService<Self> { UserService(deps: self) } // Postgres
}
extension HasUser {
var user: MockUserService { MockUserService() } // Mock
}We need to separate "I have user capabilities" from "use this specific implementation."
Split it into two protocols:
// Abstract: "I have user capabilities"
protocol HasUser {
associatedtype UserImpl: UserServiceProtocol
var user: UserImpl { get }
}
// Concrete: "Use PostgresUserService for HasUser"
protocol LinkPostgresUser: HasUser, HasDatabase { }
extension LinkPostgresUser {
var user: PostgresUserService<Self> { PostgresUserService(deps: self) }
}
// Alternative: "Use MockUserService for HasUser"
protocol LinkMockUser: HasUser { }
extension LinkMockUser {
var user: MockUserService { MockUserService() }
}Now bundles choose their implementation by which Link they conform to:
struct App: LinkPostgresUser {
let db = PostgreSQL()
}
struct TestApp: LinkMockUser {
// No database needed—mock doesn't use one
}This is the "linking" step. HasUser is like a header file declaring a symbol exists. LinkPostgresUser is like -lpostgres telling the linker which library provides it.
Three mechanisms working together:
1. Structural typing via protocol composition
Deps: HasDatabase & HasCacheThis means "any type with db and cache properties." No specific type required. The shape is the contract.
2. Environment capture
struct UserService<Deps: HasDatabase> {
let deps: Deps // Captured once at construction
func find(id: String) -> User? {
deps.db.query(...) // Accessed implicitly thereafter
}
}Services are functions from Deps → Capabilities. The struct just gives you named access to the captured environment.
3. Late binding via conformance
extension LinkPostgresUser {
var user: ... { PostgresUserService(deps: self) }
}The extension doesn't know what self is—the conforming type decides. Implementation is chosen when you write struct App: LinkPostgresUser, not at the call site.
Here's something subtle. If two services both need a database:
struct App: LinkUser, LinkOrder {
let db = PostgreSQL()
}Both LinkUser and LinkOrder extensions receive self = App. When UserService accesses deps.db and OrderService accesses deps.db, they get the same instance.
No configuration. Same property name = same instance. The sharing is structural.
Each service only sees what it declares:
struct UserService<Deps: HasDatabase & HasCache> {
let deps: Deps
// Can access: deps.db, deps.cache
// Cannot access: deps.payment (even if App has it)
}
struct OrderService<Deps: HasUser & HasInventory> {
let deps: Deps
// Can access: deps.user, deps.inventory
// Cannot access: deps.db directly (that's User's concern)
}OrderService uses UserService but doesn't know UserService uses a database. Transitive dependencies are hidden. If UserService later moves to a microservice, OrderService doesn't change.
Four concepts:
| Concept | Purpose | Example |
|---|---|---|
| Service | The capability interface | protocol UserService { func find(id:) -> User? } |
| Needs | What's required | HasDatabase, HasCache (compose them) |
| Impl | How it works | struct PostgresUser<D: HasDatabase & HasCache> |
| Link | Which impl to use | protocol LinkPostgresUser + extension |
A complete service:
// Service: what it does
protocol UserService {
func find(id: String) -> User?
func create(email: String) async throws -> User
}
// Needs: what's required (atomic, composable)
protocol HasDatabase {
associatedtype DB: Database
var db: DB { get }
}
// Impl: how it works
struct PostgresUser<Deps: HasDatabase>: UserService {
let deps: Deps
func find(id: String) -> User? {
deps.db.query("SELECT * FROM users WHERE id = ?", id).first
}
func create(email: String) async throws -> User {
let user = User(id: UUID().uuidString, email: email)
try await deps.db.insert("users", user)
return user
}
}
// Link: selects this implementation
protocol HasUser {
associatedtype U: UserService
var user: U { get }
}
protocol LinkPostgresUser: HasUser, HasDatabase { }
extension LinkPostgresUser {
var user: some UserService { PostgresUser(deps: self) }
}// Production
struct App: LinkPostgresUser, LinkStandardOrder, LinkStripePayment {
let db = PostgreSQL()
let stripe = StripeAPI(key: productionKey)
}
// Test: real database, mock payment
struct TestApp: LinkPostgresUser, LinkStandardOrder, LinkMockPayment {
let db = PostgreSQL.testContainer()
}
// View only knows about capabilities, not implementations
struct CheckoutView<Services: HasUser & HasOrder>: View {
let services: Services
var body: some View {
Button("Checkout") {
let user = services.user.current()
let order = services.order.create(userId: user.id, items: cart)
}
}
}You could thread dependencies as function parameters:
func findUser(db: Database, id: String) -> User?
func createOrder(findUser: (String) -> User?, db: Database, items: [Item]) -> OrderProblems:
createOrderneedsfindUserANDdb. ButfindUseralready usesdbinternally. You're passingdbtwice.- 10 services × 5 methods = 50 functions to wire manually.
- To mock
findUser, you rewire everything that captured it.
The bundle solves this. deps.db in UserService and deps.db in OrderService are the same because deps is the same object. Sharing is automatic. Mocking is surgical—swap one Link, everything else stays real.
Runtime DI containers work fine. They're easier to learn but:
- Errors at runtime, not compile time
- Can't see what depends on what in the code
- Overhead from runtime lookups
The Service Linker gives you compile-time checking. If a dependency is missing, it won't build. The wiring is visible in the type signatures.
Tradeoff: you learn the protocol mechanics upfront, but then the compiler catches mistakes forever.
Don't bother if:
- You have < 10 services (just wire manually)
- Team isn't comfortable with Swift generics/protocols
- You want minimal concepts to learn
Consider it if:
- Service graph is complex and growing
- You want surgical test mocking (swap one thing, keep rest real)
- You care about compile-time safety
- You're okay with upfront learning cost
Because everything is concrete at compile time:
// You write
app.user.find(id: "123")
// Compiler sees
PostgresUser<App>(deps: app).find(id: "123")
// After inlining
app.db.query("SELECT * FROM users WHERE id = ?", "123").firstThe abstraction exists in source code. The binary is the same as if you wrote it by hand.
The Service Linker Pattern is:
- Reader pattern: capture environment once, access implicitly
- Structural typing: protocol composition describes the shape
- Late binding: conformance chooses implementation
You get automatic dependency threading, automatic sharing, scoped visibility, and compile-time safety. You pay with protocol boilerplate and conceptual overhead.
It's not magic—it's just protocols and generics, composed carefully.