Skip to content

Instantly share code, notes, and snippets.

@JadenGeller
Last active July 6, 2025 19:05
Show Gist options
  • Save JadenGeller/e9b109dc9d0f508d7d8d40151d2d58bf to your computer and use it in GitHub Desktop.
Save JadenGeller/e9b109dc9d0f508d7d8d40151d2d58bf to your computer and use it in GitHub Desktop.
Service Linker Pattern

The Service Linker Pattern

Build compositional service architectures where dependencies wire themselves, boundaries cost nothing, and testing happens at exactly the right level.

// Two services that both need users - automatically shared!
struct AppBundle: LinkOrder & LinkNotification {
    let db = PostgreSQL()
    let email = SendGrid()
}

// Both order and notification services will use:
// - The same database instance
// - The same user service instance (if both need users)
// - But have isolated access to only their declared dependencies

// For testing, replace shared dependencies surgically
struct TestBundle: LinkOrder & LinkNotification {
    let db = InMemoryDB()
    let user = MockUsers()  // Override just the user service
    let email = MockEmail()
}

// Everything else works normally, but with test doubles where needed

The Problem

Every service architecture faces the same dilemma: how to manage dependencies without drowning in boilerplate or sacrificing safety.

Hidden dependencies are easy to write but impossible to test:

func createOrder() async throws -> Order {
    let user = AuthManager.shared.currentUser      // What if not logged in?
    let inventory = await InventoryAPI.shared.reserve(cart)  // What if API is down?
    let payment = try await StripeAPI.shared.charge(total)  // What if in test environment?
    
    return Order(user: user, inventory: inventory, payment: payment)
}

Manual wiring is explicit but the boilerplate grows exponentially:

let db = PostgreSQL()
let cache = Redis()
let logger = ConsoleLogger()

// First layer of services
let userService = UserService(db: db, cache: cache, logger: logger)
let inventoryService = InventoryService(db: db, logger: logger)
let pricingService = PricingService(inventoryService: inventoryService, logger: logger)

// Second layer needs first layer + primitives
let orderService = OrderService(
    userService: userService,
    inventoryService: inventoryService,
    pricingService: pricingService,
    logger: logger,
    db: db
)

// Third layer needs everything
let checkoutService = CheckoutService(
    orderService: orderService,
    userService: userService,
    inventoryService: inventoryService,
    paymentService: paymentService,
    notificationService: notificationService,
    logger: logger
)

// Every new service multiplies the wiring

The Service Linker Pattern solves this using just Swift protocols. Dependencies wire themselves through structural typing, services compose naturally, and the whole thing compiles to zero overhead.

Core Concepts

The pattern treats dependency injection like a compile-time linker:

  • Services are stateless transformations from dependencies to capabilities
  • Resources are stateful external dependencies (databases, APIs, etc.)
  • Links are wiring rules that connect everything
  • Bundles provide resources and gain services through Links

Building a Service

Every service has exactly 5 components. Let's build a user service step by step.

1. Service Protocol - The Public API

Define what your service can do:

protocol UserService {
    func find(id: String) -> User?
    func create(email: String, name: String) async throws -> User
    func current() throws -> User
}

Pure capabilities. No implementation details.

2. Has Protocol - Declare the Capability

protocol HasUser {
    associatedtype UserImpl: UserService
    var user: UserImpl { get }
}

This says "I have user capabilities." Any type conforming to HasUser provides a user property.

3. Implementation - The Transformation

struct PostgresUser<D: PostgresUserDeps>: UserService {
    let deps: D  // ONLY property allowed - no state!
    
    func find(id: String) -> User? {
        // Check cache first
        if let cached: User = deps.cache.get("user:\(id)") {
            return cached
        }
        
        // Fall back to database
        if let user: User = deps.db.query(
            "SELECT * FROM users WHERE id = ?", id
        ).first {
            deps.cache.set("user:\(id)", user, ttl: 300)
            return user
        }
        
        return nil
    }
    
    func create(email: String, name: String) async throws -> User {
        let user = User(id: UUID().uuidString, email: email, name: name)
        try await deps.db.insert("users", user)
        return user
    }
    
    func current() throws -> User {
        throw AuthError.notLoggedIn
    }
}

The implementation is a pure function: PostgresUserDeps → UserService.

4. Deps Protocol - What's Needed

protocol PostgresUserDeps: HasDatabase, HasCache { }

Or if you need specific types:

protocol PostgresUserDeps {
    associatedtype DB: Database where DB.Element == User
    associatedtype Cache: Cache
    
    var db: DB { get }
    var cache: Cache { get }
}

5. Link Protocol - The Wiring Rule

protocol LinkPostgresUser: HasUser, PostgresUserDeps { }

extension LinkPostgresUser {
    var user: some UserService {
        PostgresUser(deps: self)
    }
}

The Link combines Has + Deps and provides the implementation. Any type conforming to LinkPostgresUser automatically gets the user property.

Composing Services

Services can depend on other services:

// OrderService needs users and inventory
protocol OrderService {
    func create(userId: String, items: [Item]) async throws -> Order
}

protocol HasOrder {
    associatedtype OrderImpl: OrderService
    var order: OrderImpl { get }
}

struct StandardOrder<D: StandardOrderDeps>: OrderService {
    let deps: D
    
    func create(userId: String, items: [Item]) async throws -> Order {
        // Uses other services through deps
        guard let user = deps.user.find(id: userId) else {
            throw OrderError.userNotFound
        }
        
        try await deps.inventory.reserve(items)
        
        let order = Order(id: UUID().uuidString, userId: user.id, items: items)
        try await deps.db.insert("orders", order)
        
        return order
    }
}

protocol StandardOrderDeps: HasUser, HasInventory, HasDatabase { }

protocol LinkStandardOrder: HasOrder, StandardOrderDeps { }
extension LinkStandardOrder {
    var order: some OrderService {
        StandardOrder(deps: self)
    }
}

Order doesn't redeclare Database - that's User's concern. It only declares direct dependencies.

Creating Bundles

Bundles are concrete types that provide resources and conform to Links:

struct AppBundle: LinkPostgresUser, LinkStandardOrder, LinkRedisInventory {
    // Resources (stateful)
    let db = PostgreSQL()
    let cache = Redis()
    
    // Services come from Links
    // - user (from LinkPostgresUser)
    // - order (from LinkStandardOrder)  
    // - inventory (from LinkRedisInventory)
}

// Always use through services or views
struct CheckoutView<S: HasOrder & HasUser>: View {
    let services: S
    
    var body: some View {
        Button("Checkout") {
            Task {
                let user = try services.user.current()
                let order = try await services.order.create(
                    userId: user.id, 
                    items: cartItems
                )
            }
        }
    }
}

// Usage
let bundle = AppBundle()
CheckoutView(services: bundle)

How Dependencies Connect

Structural Typing Enables Sharing

Dependencies with the same property name automatically share instances:

protocol UserDeps {
    var db: Database { get }
}

protocol OrderDeps {
    var db: Database { get }  // Same name = same instance
}

struct AppBundle: LinkUser, LinkOrder {
    let db = PostgreSQL()  // Shared by both services
}

No configuration needed. The structure is the wiring.

Three Sources of Dependencies

1. Resources - Stateful components you provide:

struct AppBundle: LinkServices {
    let db = PostgreSQL()      // External dependency
    let cache = Redis()        // External dependency
    let logger = FileLogger()  // External dependency
}

2. Services - Created automatically by Link protocol extensions:

struct AppBundle: LinkPostgresUser, LinkStripePayment {
    let db = PostgreSQL()
    let stripe = StripeAPI()
}

// Where do services come from? Link protocol extensions!

// LinkPostgresUser provides:
extension LinkPostgresUser {
    var user: some UserService {
        PostgresUser(deps: self)  // Created here
    }
}

// LinkStripePayment provides:
extension LinkStripePayment {
    var payment: some PaymentService {
        StripePayment(deps: self)  // Created here
    }
}

// So AppBundle automatically has user and payment services

3. Overrides - For testing and customization:

struct TestBundle: LinkOrder {
    let db = InMemoryDB()
    let user = MockUsers()  // Override Link's version
    // order still comes from LinkOrder
}

Advanced Patterns

Conditional Links

Choose implementations based on type markers:

// Markers
protocol Production { }
protocol Development { }
protocol Testing { }

// Conditional link
protocol LinkLogger: HasLogger { }

extension LinkLogger where Self: Production {
    var logger: some Logger { CloudLogger(deps: self) }
}

extension LinkLogger where Self: Development {
    var logger: some Logger { ConsoleLogger() }
}

extension LinkLogger where Self: Testing {
    var logger: some Logger { NoOpLogger() }
}

// Different bundles in same binary
struct ProdBundle: LinkLogger, Production { }
struct DevBundle: LinkLogger, Development { }

Resource vs Service

The same component can be provided either way:

// Option 1: Direct resource injection
struct Bundle1: LinkAPI {
    let rateLimiter = TokenBucket()  // Stateful, injected directly
}

// Option 2: Service with separated state
struct Bundle2: LinkAPI, LinkTokenBucketRateLimiter {
    let buckets = BucketStorage()  // State separated from logic
}

Choose based on your testing needs.

Has Protocols for Resources

For consistency, resources can use Has protocols too:

protocol HasDatabase {
    associatedtype DatabaseImpl: Database
    var db: DatabaseImpl { get }
}

protocol HasCache {
    associatedtype CacheImpl: Cache  
    var cache: CacheImpl { get }
}

// Now deps protocols compose nicely
protocol PostgresUserDeps: HasDatabase, HasCache { }

Testing Strategies

Unit Testing - Mock Services

struct MockDeps: HasUser, HasInventory {
    let user = MockUsers(testData: [...])
    let inventory = MockInventory(stock: ["widget": 10])
}

@Test func orderRequiresStock() async throws {
    struct TestBundle: LinkOrderProcessor {
        let order = MockOrder(inventory: MockInventory(stock: ["widget": 0]))
        let notification = MockNotification()
    }
    
    let bundle = TestBundle()
    
    // In tests, accessing services directly is fine
    await #expect(throws: OrderError.outOfStock) {
        try await bundle.orderProcessor.process(userId: "123", items: [Item(id: "widget")])
    }
}

Integration Testing - Test Infrastructure

struct IntegrationBundle: LinkOrder, LinkUser, LinkInventory {
    let db = PostgreSQL.testContainer()
    let cache = Redis.testContainer()
}

@Test func orderFlowIntegration() async throws {
    struct IntegrationBundle: LinkUser, LinkOrderProcessor {
        let db = PostgreSQL.testContainer()
        let cache = Redis.testContainer()
        let notification = EmailService.testMode()
    }
    
    let bundle = IntegrationBundle()
    
    // Real services, test infrastructure
    let user = try await bundle.user.create(email: "[email protected]", name: "Test User")
    let result = try await bundle.orderProcessor.process(userId: user.id, items: testItems)
    
    // Verify through real queries
    #expect(result.order.id != nil)
    #expect(result.notificationSent)
}

Surgical Mocking - The Killer Feature

Override exactly what you need:

struct PaymentTestBundle: LinkCheckout {
    let db = PostgreSQL.testContainer()  // Real
    let cache = Redis.testContainer()    // Real
    let payment = FailingPayment()       // Mocked!
}

@Test func paymentFailureRollsBackInventory() async throws {
    struct PaymentTestBundle: LinkCheckout, LinkInventory {
        let db = PostgreSQL.testContainer()  // Real
        let cache = Redis.testContainer()    // Real
        let payment = FailingPayment()       // Mocked!
    }
    
    let bundle = PaymentTestBundle()
    
    let stockBefore = await bundle.inventory.checkStock(item: "widget")
    
    await #expect(throws: PaymentError.declined) {
        try await bundle.checkout.process(items: [Item(id: "widget")])
    }
    
    let stockAfter = await bundle.inventory.checkStock(item: "widget")
    #expect(stockAfter == stockBefore)  // Rolled back!
}

Why It Compiles to Nothing

The pattern uses three Swift optimizations:

1. Protocol Extensions Inline

// You write
services.user.find(id: "123")

// Compiler inlines to
PostgresUser(deps: services).find(id: "123")

2. Generic Specialization

// Generic code becomes concrete
PostgresUser<AppBundle>  PostgresUser_AppBundle_Specialized

3. Struct Allocation Optimizes Away

// Final result - same as manual wiring
services.db.query("SELECT * FROM users WHERE id = ?", "123").first

Zero-cost abstraction in practice.

Key Insights

Services are pure transformations. They have no state, only a deps parameter. State lives in resources.

Structure is wiring. Properties with the same name automatically share instances. No configuration needed.

Boundaries cost nothing. Create as many services as makes sense for your domain. They compile away.

Testing is surgical. Override exactly what you need. Keep everything else real.

The compiler is your service linker. Type safety, zero overhead, no magic.

Summary

The Service Linker Pattern turns Swift's type system into a compile-time dependency injection framework:

  • Define services as protocols
  • Implement as stateless transformations
  • Wire with Link protocols
  • Provide resources in bundles
  • Use through services or views

No frameworks. No runtime overhead. No boilerplate.

Just protocols and the Swift compiler acting as your service linker.

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