Skip to content
advanced Phase 9 · State Management & Architecture Patterns

Modular Architecture

Structure iOS apps as feature modules with SPM for independent development and testing.

1h
3 problems
Topic Progress 0%

Feature Modules

Why Modular Architecture?

Modular architecture splits your app into independent feature modules that can be developed, tested, and deployed separately. This improves build times, team independence, and code reuse.

Defining Module Boundaries

Each module should have a clear public API and hide internal implementation details.

// Module: AuthModule
// Public API
public protocol AuthServiceProtocol {
    func login(email: String, password: String) async throws -> User
    func logout() async
    func currentUser() -> User?
}

// Internal implementation (not public)
internal class AuthService: AuthServiceProtocol {
    private let apiClient: APIClient
    private let keychain: KeychainService
    
    init(apiClient: APIClient, keychain: KeychainService) {
        self.apiClient = apiClient
        self.keychain = keychain
    }
    
    public func login(email: String, password: String) async throws -> User {
        let response: LoginResponse = try await apiClient.post("/auth/login", body: ["email": email, "password": password])
        keychain.save(response.token, forKey: "auth_token")
        return response.user
    }
}

Module Structure

Organize each module with clear layers.

AuthModule/
  Sources/
    Public/
      AuthServiceProtocol.swift
      Models/
        User.swift
    Internal/
      AuthService.swift
      APIClient.swift
  Tests/
    AuthServiceTests.swift
  Package.swift

Swift Package Manager

Creating a Module with SPM

Define each module as a Swift package with public and internal targets.

// Package.swift
let package = Package(
    name: "AuthModule",
    platforms: [.iOS(.v16)],
    products: [
        .library(name: "AuthModule", targets: ["AuthModule"]),
    ],
    dependencies: [
        .package(url: "https://github.com/Alamofire/Alamofire", from: "5.8.0"),
    ],
    targets: [
        .target(
            name: "AuthModule",
            dependencies: ["Alamofire"]
        ),
        .testTarget(
            name: "AuthModuleTests",
            dependencies: ["AuthModule"]
        ),
    ]
)

Module Dependencies

Manage dependencies between modules explicitly.

// App-level Package.swift
let package = Package(
    name: "MyApp",
    dependencies: [
        .package(path: "./Modules/AuthModule"),
        .package(path: "./Modules/ProfileModule"),
        .package(path: "./Modules/ShopModule"),
    ],
    targets: [
        .target(
            name: "MyApp",
            dependencies: ["AuthModule", "ProfileModule", "ShopModule"]
        ),
    ]
)

Access Control

Use Swift access control to enforce module boundaries.

// Public: Available to other modules
public protocol ShopServiceProtocol {
    func fetchProducts() async throws -> [Product]
}

// Internal: Only within this module
internal class ShopService: ShopServiceProtocol {
    internal let cache: ProductCache
    
    func fetchProducts() async throws -> [Product] {
        if let cached = cache.get() { return cached }
        let products = try await apiClient.fetchProducts()
        cache.set(products)
        return products
    }
}

// Private: Only within this file
private class ProductCache {
    private var products: [Product] = []
}

Module Communication

Protocol-Based Communication

Modules communicate through protocols to avoid direct dependencies.

// Shared protocols module
public protocol NavigationService {
    func showProfile(userId: String)
    func showProduct(id: String)
}

// App module implements navigation
class AppNavigationService: NavigationService {
    func showProfile(userId: String) {
        // Route to profile module
    }
    func showProduct(id: String) {
        // Route to shop module
    }
}

Dependency Injection

Inject module dependencies through initializers or environment.

// Module registration
class ModuleRegistry {
    static let shared = ModuleRegistry()
    private var services: [String: Any] = [:]
    
    func register<T>(_ service: T, for type: T.Type) {
        services[String(describing: type)] = service
    }
    
    func resolve<T>(_ type: T.Type) -> T? {
        services[String(describing: type)] as? T
    }
}

// Registration at app launch
let registry = ModuleRegistry.shared
registry.register(AuthService() as AuthServiceProtocol, for: AuthServiceProtocol.self)
registry.register(ShopService() as ShopServiceProtocol, for: ShopServiceProtocol.self)

// Usage in modules
class ProfileViewModel: ObservableObject {
    private let authService: AuthServiceProtocol
    
    init(authService: AuthServiceProtocol = ModuleRegistry.shared.resolve(AuthServiceProtocol.self)!) {
        self.authService = authService
    }
}

Event Bus Pattern

Use an event bus for loose coupling between modules.

public protocol EventBus {
    func subscribe<T>(_ type: T.Type, handler: @escaping (T) -> Void)
    func publish<T>(_ event: T)
}

class DefaultEventBus: EventBus {
    private var handlers: [String: [Any]] = [:]
    
    func subscribe<T>(_ type: T.Type, handler: @escaping (T) -> Void) {
        let key = String(describing: type)
        handlers[key, default: []].append(handler)
    }
    
    func publish<T>(_ event: T) {
        let key = String(describing: T.self)
        handlers[key]?.forEach { handler in
            (handler as? (T) -> Void)?(event)
        }
    }
}

// Event definitions
public struct UserLoggedInEvent {
    public let user: User
}

// Publishing events
eventBus.publish(UserLoggedInEvent(user: user))

// Subscribing to events
eventBus.subscribe(UserLoggedInEvent.self) { event in
    print("User logged in: \(event.user.name)")
}

Modular Architecture Best Practices

  • Define clear module boundaries with public APIs
  • Use protocols for inter-module communication
  • Minimize direct dependencies between modules
  • Each module should be independently testable
  • Use dependency injection for flexibility
  • Keep shared code in a dedicated module

Quiz

1. What is the main benefit of modular architecture?

Question 1 options

2. How should modules communicate with each other?

Question 2 options

3. What Swift feature enforces module boundaries?

Question 3 options

4. What is a module registry used for?

Question 4 options

Flashcards

Question

What is modular architecture?

Answer

An app design pattern where features are split into independent modules with clear boundaries and public APIs.

Question

How do modules avoid direct dependencies?

Answer

Through protocol-based communication and dependency injection, modules depend on abstractions not implementations.

Question

What access control level exposes code to other modules?

Answer

public access control makes types and methods available to other modules.

Question

What is a module registry?

Answer

A service locator that registers and resolves module dependencies at runtime.

Revision Notes

Key Takeaways

  • 1. Modules have clear boundaries with public APIs
  • 2. Protocols enable loose coupling between modules
  • 3. Access control enforces module encapsulation
  • 4. Dependency injection provides flexibility
  • 5. Each module should be independently testable

Interview Tips

  • Explain how to split a monolith into modules
  • Discuss protocol-based communication patterns
  • Describe how to handle shared code between modules

Cheat Sheet

Modular Architecture Quick Reference

  • Feature Module - Independent feature with public API
  • Protocol-based communication - Loose coupling between modules
  • Dependency injection - Inject dependencies through initializers
  • Access control - public/internal/private for boundaries
  • Module registry - Central dependency resolution
  • Event bus - Publish/subscribe for loose coupling
  • SPM - Package modules as Swift packages
  • Public API - Define clear module interfaces