Wrapping Third-Party Dependencies in Swift
Every iOS app accumulates third-party dependencies. Analytics, logging, crash reporting, feature flags. The SDKs start small - a single import FirebaseAnalytics in your app delegate. Then you add screen tracking to a few views. Then event logging for user actions. Before long, import FirebaseAnalytics appears in 40 files across your project.
Then the business decides to switch to Segment.
Now you're touching every file that ever called Analytics.logEvent. Your tests were importing the real SDK. Your previews need a configured Firebase project to even compile. A vendor decision has become a codebase-wide refactor.
The fix is straightforward: never let your app talk to the SDK directly. Wrap it in a service you own, inject it through the environment, and swap implementations when vendors change. If you've read Dependency Injection in SwiftUI Without the Ceremony, this pattern will feel familiar. We're applying the same closure-based approach, but this time the thing we're wrapping isn't our own backend - it's someone else's SDK.
Defining the interface
The wrapper is a struct with closure properties. No protocols, no abstract classes. Just a plain value type that describes what your app needs from a logger:
public enum LogLevel: String, Sendable {
case debug, info, warning, error
}
public struct Logger: Sendable {
public var log: @Sendable (String, LogLevel) -> Void
public var screen: @Sendable (String, [String: String]) -> Void
public var event: @Sendable (String, [String: String]) -> Void
public init(
log: @escaping @Sendable (String, LogLevel) -> Void,
screen: @escaping @Sendable (String, [String: String]) -> Void,
event: @escaping @Sendable (String, [String: String]) -> Void
) {
self.log = log
self.screen = screen
self.event = event
}
}
Three operations cover most logging and analytics needs:
log- structured console messages with a severity levelscreen- screen view tracking with optional parametersevent- custom analytics events with a name and key-value parameters
This interface is yours. It doesn't mention Firebase, Segment, Datadog, or any other vendor. Your entire app codes against these three closures.
The Firebase implementation
Now we connect the interface to a real SDK. The .live implementation wraps Firebase Analytics and also logs to os.Logger so you get console output during development:
import FirebaseAnalytics
import OSLog
private let osLog = os.Logger(
subsystem: Bundle.main.bundleIdentifier ?? "app",
category: "Analytics"
)
extension Logger {
public static func live() -> Logger {
Logger(
log: { message, level in
switch level {
case .debug:
osLog.debug("\(message)")
case .info:
osLog.info("\(message)")
case .warning:
osLog.warning("\(message)")
case .error:
osLog.error("\(message)")
}
},
screen: { name, parameters in
osLog.info("Screen: \(name)")
Analytics.logEvent(
AnalyticsEventScreenView,
parameters: [AnalyticsParameterScreenName: name]
.merging(parameters) { _, new in new }
)
},
event: { name, parameters in
osLog.info("Event: \(name) \(parameters)")
Analytics.logEvent(name, parameters: parameters)
}
)
}
}
Notice where import FirebaseAnalytics lives. It's in this one file. Not in your views. Not in your tests. Not in your previews. Just here, in the .live implementation. Every closure logs to the console via os.Logger so you always have local visibility, and then forwards to Firebase for production analytics.
Mock and unimplemented
The .mock implementation logs to the console only. No SDK, no configuration, no network calls:
extension Logger {
public static let mock = Logger(
log: { message, level in
print("[\(level.rawValue.uppercased())] \(message)")
},
screen: { name, _ in
print("[SCREEN] \(name)")
},
event: { name, parameters in
print("[EVENT] \(name) \(parameters)")
}
)
}
And the .unimplemented version crashes immediately if called, which is exactly what you want as a default - it surfaces missing injection during development rather than silently swallowing events:
extension Logger {
public static let unimplemented = Logger(
log: { _, _ in fatalError("Logger.log unimplemented") },
screen: { _, _ in fatalError("Logger.screen unimplemented") },
event: { _, _ in fatalError("Logger.event unimplemented") }
)
}
Your test target and preview code never need import FirebaseAnalytics. They use .mock or a custom instance that captures calls for assertions. The SDK is completely invisible outside of the one file that defines .live.
Environment integration
Register the logger with SwiftUI's environment using @Entry:
import SwiftUI
extension EnvironmentValues {
@Entry var logger: Logger = .unimplemented
}
The default is .unimplemented, so any view that uses the logger without proper injection will crash immediately with a clear message. No silent failures.
Inject the real implementation at your app's root:
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.environment(\.logger, .live())
}
}
}
Using it in views
Views access the logger through the environment. No SDK imports, no direct Firebase calls:
import SwiftUI
struct LandmarkDetailView: View {
@Environment(\.logger) private var logger
let landmark: Landmark
var body: some View {
ScrollView {
LandmarkHeader(landmark: landmark)
LandmarkDescription(landmark: landmark)
Button("Add to Favorites") {
logger.event("landmark_favorited", [
"landmark_id": "\(landmark.id)",
"landmark_name": landmark.name
])
}
}
.task {
logger.screen("LandmarkDetail", [
"landmark_id": "\(landmark.id)"
])
}
}
}
#Preview {
LandmarkDetailView(landmark: .mock)
.environment(\.logger, .mock)
}
The view doesn't know or care whether events go to Firebase, Segment, the console, or nowhere. It calls logger.event and logger.screen and moves on. The preview uses .mock, which just prints to the console. No Firebase project configuration needed, no network calls, instant previews.
The payoff: swapping to Segment
Six months later, the analytics team decides to migrate from Firebase to Segment. In a codebase where Firebase is imported everywhere, this is a multi-week project. With our wrapper, it's a new .live implementation:
import Segment
extension Logger {
public static func live(
analytics: Segment.Analytics
) -> Logger {
Logger(
log: { message, level in
switch level {
case .debug:
osLog.debug("\(message)")
case .info:
osLog.info("\(message)")
case .warning:
osLog.warning("\(message)")
case .error:
osLog.error("\(message)")
}
},
screen: { name, parameters in
osLog.info("Screen: \(name)")
analytics.screen(title: name, properties: parameters)
},
event: { name, parameters in
osLog.info("Event: \(name) \(parameters)")
analytics.track(name: name, properties: parameters)
}
)
}
}
And update the injection point:
@main
struct MyApp: App {
let analytics = Segment.Analytics(configuration: Configuration(
writeKey: "YOUR_WRITE_KEY"
))
var body: some Scene {
WindowGroup {
ContentView()
.environment(\.logger, .live(analytics: analytics))
}
}
}
That's it. Every view, every test, every preview continues to work unchanged. The LandmarkDetailView above doesn't need a single edit. It still calls logger.event and logger.screen the same way it always did. The only files that change are the .live implementation and the app entry point.
The Firebase SDK can be removed from your Package.swift entirely. import FirebaseAnalytics appeared in one file, and now it's gone.
What you get
Wrapping third-party dependencies behind closure-based services gives you four things:
Testable. Write a test logger that captures events into an array, then assert on what was logged. No analytics SDK running during tests, no network calls to intercept.
Previewable. SwiftUI previews use .mock and render instantly. No SDK initialization, no API keys, no "Firebase not configured" crashes.
Swappable. Switching vendors is a one-file diff. The interface stays the same, views stay the same, tests stay the same. Only the closure bodies change.
Compiler-enforced isolation. import FirebaseAnalytics (or import Segment) appears in exactly one file. If someone on your team tries to import it elsewhere, code review catches a single unexpected import instead of chasing scattered SDK calls.
If you're working in a modular codebase, this wrapper belongs in your Common layer. Every feature module can depend on it without pulling in the analytics SDK as a transitive dependency. The SDK only gets linked into the app target where .live is configured.
This pattern works for any third-party dependency: analytics, crash reporting, feature flags, A/B testing, push notifications. If your app calls it, wrap it. Your future self, the one doing the inevitable vendor migration, will thank you.