Swift Framework Design
Introduction
Frameworks are a fundamental building block in modern application development that enable code reuse, modular architecture, and clean separation of concerns. In Swift, designing effective frameworks requires thoughtful consideration of access control to create APIs that are both usable for consumers and maintainable for developers.
In this guide, we'll explore how Swift's access control features enable the creation of well-designed frameworks with clear public interfaces and protected internal implementations.
What is a Framework?
A framework is a reusable package of code that provides specific functionality with a well-defined interface. Unlike individual classes or functions, frameworks typically contain multiple related components designed to work together as a cohesive unit.
In the Apple ecosystem, frameworks are the primary way to share code across:
- Different parts of your application
- Multiple applications
- The wider developer community
Access Control in Framework Design
When designing frameworks, access control becomes crucial for:
- Defining clear API boundaries: What consumers can and cannot access
- Protecting internal implementation details: Preventing misuse and enabling future changes
- Enforcing proper usage patterns: Guiding users to the correct API usage
The Public Interface
Let's start by examining how to define a framework's public interface:
// Public API - Available to anyone who imports your framework
public class NetworkManager {
    // Public initializer allows consumers to create instances
    public init(configuration: NetworkConfiguration) {
        self.configuration = configuration
    }
    
    // Public method - part of your framework's API
    public func fetchData(from url: URL) async throws -> Data {
        // Implementation details
        return try await performNetworkRequest(url)
    }
    
    // Private properties - inaccessible outside this file
    private let configuration: NetworkConfiguration
    
    // Private methods - implementation details
    private func performNetworkRequest(_ url: URL) async throws -> Data {
        // Implementation hidden from framework users
        // ...
        return Data()
    }
}
// Also part of the public API
public struct NetworkConfiguration {
    public let timeoutInterval: TimeInterval
    public let cachePolicy: URLRequest.CachePolicy
    
    public init(timeoutInterval: TimeInterval = 30.0,
                cachePolicy: URLRequest.CachePolicy = .useProtocolCachePolicy) {
        self.timeoutInterval = timeoutInterval
        self.cachePolicy = cachePolicy
    }
}
In this example, NetworkManager and NetworkConfiguration are part of your framework's public API. The public keyword ensures that any code that imports your framework can access these types.
Internal Implementation Details
For components that need to be shared between files within your framework but should not be exposed to framework consumers, use the internal access level:
// Public API
public class ImageLoader {
    public init() {}
    
    public func loadImage(from url: URL) async throws -> UIImage {
        let data = try await networkService.fetchData(from: url)
        return try imageProcessor.processImageData(data)
    }
    
    // Internal components - accessible within the framework but not to consumers
    internal let networkService = InternalNetworkService()
    internal let imageProcessor = InternalImageProcessor()
}
// Internal implementation - not accessible outside the framework
internal class InternalNetworkService {
    func fetchData(from url: URL) async throws -> Data {
        // Implementation details
        return Data()
    }
}
// Another internal implementation
internal class InternalImageProcessor {
    func processImageData(_ data: Data) throws -> UIImage {
        // Implementation details
        guard let image = UIImage(data: data) else {
            throw ImageProcessingError.invalidData
        }
        return image
    }
}
// Internal error type
internal enum ImageProcessingError: Error {
    case invalidData
}
In this example, InternalNetworkService and InternalImageProcessor are only accessible within your framework, keeping these implementation details hidden from consumers.
Designing for Extension
A well-designed framework should be extensible. Swift provides the open access level specifically for this purpose:
// Open class allows subclassing from outside the framework
open class Theme {
    open var primaryColor: UIColor
    open var secondaryColor: UIColor
    open var fontFamily: String
    
    public init(primaryColor: UIColor, secondaryColor: UIColor, fontFamily: String) {
        self.primaryColor = primaryColor
        self.secondaryColor = secondaryColor
        self.fontFamily = fontFamily
    }
    
    // Open method can be overridden by subclasses
    open func applyTheme(to view: UIView) {
        view.backgroundColor = primaryColor
    }
    
    // Public but not open - can't be overridden
    public func generateColorVariants() -> [UIColor] {
        // Generate and return color variants
        return [primaryColor.withAlphaComponent(0.5), secondaryColor.withAlphaComponent(0.5)]
    }
}
When a framework consumer imports your framework, they can extend the Theme class:
// In the consumer's code
import YourFramework
class CustomTheme: Theme {
    // Additional properties
    var accentColor: UIColor
    
    init(accentColor: UIColor) {
        self.accentColor = accentColor
        super.init(primaryColor: .white, secondaryColor: .blue, fontFamily: "Helvetica")
    }
    
    // Override the open method
    override func applyTheme(to view: UIView) {
        super.applyTheme(to: view)
        
        // Add custom theming logic
        if let button = view as? UIButton {
            button.tintColor = accentColor
        }
    }
    
    // Note: Cannot override generateColorVariants() because it's only public, not open
}
Module-Level Access Control
When working with frameworks, it's important to understand that Swift treats each framework as a separate module. This affects how access control works:
- publicand- open: Accessible across module boundaries
- internal: Accessible only within the same module (default)
- fileprivate: Accessible only within the defining file
- private: Accessible only within the defining declaration
This module-level separation makes frameworks a natural boundary for encapsulating code.
Protocol-Based Design
Protocols are particularly valuable in framework design for defining clear interfaces and enabling dependency injection:
// Public protocol defining capabilities
public protocol NetworkService {
    func fetch(from url: URL) async throws -> Data
}
// Public concrete implementation
public class DefaultNetworkService: NetworkService {
    public init() {}
    
    public func fetch(from url: URL) async throws -> Data {
        // Implementation
        let (data, _) = try await URLSession.shared.data(from: url)
        return data
    }
}
// Framework component that depends on the protocol, not the concrete implementation
public class DataRepository {
    private let networkService: NetworkService
    
    // Inject any NetworkService implementation
    public init(networkService: NetworkService = DefaultNetworkService()) {
        self.networkService = networkService
    }
    
    public func fetchUser(id: String) async throws -> User {
        let url = URL(string: "https://api.example.com/users/\(id)")!
        let data = try await networkService.fetch(from: url)
        return try JSONDecoder().decode(User.self, from: data)
    }
}
public struct User: Decodable {
    public let id: String
    public let name: String
    public let email: String
    
    // Internal initializer - framework can create Users but consumers can't
    internal init(id: String, name: String, email: String) {
        self.id = id
        self.name = name
        self.email = email
    }
}
This design allows framework consumers to provide their own implementations of NetworkService, making the framework more flexible and testable.
Real-World Example: Analytics Framework
Let's design a simple analytics framework to demonstrate these concepts:
// MARK: - Public API
public protocol AnalyticsEvent {
    var name: String { get }
    var parameters: [String: Any] { get }
}
public struct BasicEvent: AnalyticsEvent {
    public let name: String
    public let parameters: [String: Any]
    
    public init(name: String, parameters: [String: Any] = [:]) {
        self.name = name
        self.parameters = parameters
    }
}
public protocol AnalyticsProvider {
    func track(event: AnalyticsEvent)
}
open class AnalyticsManager {
    // Using private(set) allows reading but not modifying the providers array from outside
    public private(set) var providers: [AnalyticsProvider]
    
    public init(providers: [AnalyticsProvider] = []) {
        self.providers = providers
    }
    
    open func track(event: AnalyticsEvent) {
        // Hook for subclasses to override
        prepareEvent(event)
        
        // Send to all providers
        providers.forEach { provider in
            provider.track(event: event)
        }
        
        // Call internal method
        logEventInternally(event)
    }
    
    // Public method to add providers
    public func register(provider: AnalyticsProvider) {
        providers.append(provider)
    }
    
    // Internal method accessible within the framework
    internal func logEventInternally(_ event: AnalyticsEvent) {
        #if DEBUG
        print("[Analytics] \(event.name) tracked with parameters: \(event.parameters)")
        #endif
    }
    
    // This method can be overridden by subclasses
    open func prepareEvent(_ event: AnalyticsEvent) {
        // Default implementation does nothing
    }
    
    // Private helper method
    private func validateEvent(_ event: AnalyticsEvent) -> Bool {
        return !event.name.isEmpty
    }
}
// MARK: - Built-in Provider
public class ConsoleAnalyticsProvider: AnalyticsProvider {
    public init() {}
    
    public func track(event: AnalyticsEvent) {
        print("Event: \(event.name)")
        print("Parameters: \(event.parameters)")
    }
}
How Consumers Would Use This Framework
import AnalyticsFramework
// Create and configure the analytics manager
let analytics = AnalyticsManager()
analytics.register(provider: ConsoleAnalyticsProvider())
// Track a basic event
let loginEvent = BasicEvent(
    name: "user_login",
    parameters: ["method": "email", "success": true]
)
analytics.track(event: loginEvent)
// Output:
// [Analytics] user_login tracked with parameters: ["method": "email", "success": true]
// Event: user_login
// Parameters: ["method": "email", "success": true]
// Create a custom provider
class FirebaseAnalyticsProvider: AnalyticsProvider {
    func track(event: AnalyticsEvent) {
        // In a real app, this would integrate with Firebase
        print("Sending to Firebase: \(event.name)")
    }
}
// Register the custom provider
analytics.register(provider: FirebaseAnalyticsProvider())
// Create a specialized analytics manager
class EnhancedAnalytics: AnalyticsManager {
    override func prepareEvent(_ event: AnalyticsEvent) {
        // Add common parameters or modify events before tracking
        print("Enhanced analytics preparing event: \(event.name)")
    }
}
// Use the enhanced version
let enhancedAnalytics = EnhancedAnalytics()
enhancedAnalytics.register(provider: ConsoleAnalyticsProvider())
enhancedAnalytics.track(event: loginEvent)
// Output:
// Enhanced analytics preparing event: user_login
// [Analytics] user_login tracked with parameters: ["method": "email", "success": true]
// Event: user_login
// Parameters: ["method": "email", "success": true]
Best Practices for Framework Design
- Minimize the public surface area: Only expose what consumers genuinely need
- Use protocols for abstractions: Define capabilities rather than concrete implementations
- Be intentional with access levels: Choose the appropriate access level for each component
- Document your public APIs: Include comments explaining how to use your framework
- Version carefully: Changes to public APIs can break consumer code
- Design for testability: Make your framework easy to mock and test
- Consider backward compatibility: Use techniques like protocol extensions to add functionality without breaking changes
Common Pitfalls
- Over-exposing implementation details: Marking too many things public
- Under-exposing needed functionality: Being too restrictive with access control
- Rigid designs: Not using protocols to enable consumer customization
- Unnecessary subclassing: Using openwhen composition would be better
- Tight coupling: Not separating concerns appropriately
Summary
Designing effective frameworks in Swift requires careful consideration of access control:
- Use openfor classes and methods you want consumers to extend
- Use publicfor APIs you want consumers to use but not necessarily override
- Use internalfor components shared within your framework
- Use fileprivateandprivatefor implementation details
By leveraging Swift's powerful access control system, you can create frameworks that are both flexible for consumers and maintainable for you as the developer.
Additional Resources
Exercises
- Design a simple logging framework with different log levels and extensible log handlers
- Create a UI component framework with a theming system that supports customization
- Implement a networking framework with pluggable authentication mechanisms
- Design a data persistence framework that abstracts away the storage mechanism
- Create a localization framework that supports multiple languages and formatting options
💡 Found a typo or mistake? Click "Edit this page" to suggest a correction. Your feedback is greatly appreciated!