Skip to content

Instantly share code, notes, and snippets.

@JadenGeller
Last active December 18, 2025 08:11
Show Gist options
  • Select an option

  • Save JadenGeller/e9b109dc9d0f508d7d8d40151d2d58bf to your computer and use it in GitHub Desktop.

Select an option

Save JadenGeller/e9b109dc9d0f508d7d8d40151d2d58bf to your computer and use it in GitHub Desktop.
Service Linker Pattern

The Service Linker Pattern

A way to thread dependencies through your app using Swift's protocol system.

The Problem

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 this

Option 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 service

Every 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.

The Core Trick

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.

Adding More Services

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.

The Second Trick: Protocol Extensions

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 extension

The wiring happens automatically through the protocol extension. self in the extension is App, so UserService captures App as its deps.

The Problem With That

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."

The Third Trick: Link Protocols

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.

What's Actually Happening

Three mechanisms working together:

1. Structural typing via protocol composition

Deps: HasDatabase & HasCache

This 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.

Automatic Sharing

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.

Scoped Visibility

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.

The Full Pattern

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) }
}

Using It

// 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)
        }
    }
}

Why Not Just Functions?

You could thread dependencies as function parameters:

func findUser(db: Database, id: String) -> User?
func createOrder(findUser: (String) -> User?, db: Database, items: [Item]) -> Order

Problems:

  • createOrder needs findUser AND db. But findUser already uses db internally. You're passing db twice.
  • 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.

Why Not a DI Container?

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.

When To Use This

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

Zero Runtime 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").first

The abstraction exists in source code. The binary is the same as if you wrote it by hand.

Summary

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment