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?
2. How should modules communicate with each other?
3. What Swift feature enforces module boundaries?
4. What is a module registry used for?
Flashcards
Question
What is modular architecture?
Click to reveal answer
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?
Click to reveal answer
Answer
Through protocol-based communication and dependency injection, modules depend on abstractions not implementations.
Question
What access control level exposes code to other modules?
Click to reveal answer
Answer
public access control makes types and methods available to other modules.
Question
What is a module registry?
Click to reveal answer
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