Kevin Dallian's Portfolio Website

My Experience Integrating SDKs the Hard Way (and What I’d Do Differently)

iOS|SDK Integration|Clean Code|Protocol-Oriented Programming

21 Jan 2026

Background

Throughout my career as an iOS engineer, I worked on a mobile banking application where I was directly responsible for building the onboarding flow for debit and credit card registration. This onboarding process relies heavily on multiple third-party SDKs especially for user identification, such as OCR Scan, Liveness Detection, and Video Banking.

As I worked with each SDK, I quickly realized that every one of them came with its own lifecycle, state management, callbacks and version updates. My initial approach, that directly coupled business logic to SDK APIs became one, quickly became hard to fix, hard to test and fragile to change. This experience pushed me to rethink how SDK integrations should be structured.

In this blog, I share my hands-on experience integrating multiple third-party SDKs and explain how a protocol-oriented architecture can align SDK integrations with user flows rather than vendor APIs.

Throughout the steps below, I will also give Swift code snippets as a hands-on example.


Step 1: Sketch the User flow

Before touching any SDK documentation, I started by defining the flow from a user's perspective. Let's say I want to integrate AI chatbot SDK into my app, I will firstly define the user's flow:

  1. User starts chatbot
  2. User types prompt
  3. Generate Chat with SDK
    • If succeed, display Text views using generated chat
    • If failed, display error with errors status

At this stage, the focus was purely on what the user experiences, not how an SDK implements it. This abstraction turned out to be crucial for long-term maintainability.


Step 2: Define Protocols Based on User Flow

For the next step, I translated the user flow into Swift protocols.

import Combine
 
protocol ChatBotSDK {
    /// Performs required SDK setup before starting a chat session.
    ///
    /// Typical responsibilities include:
    /// - Setting API or license keys
    /// - Configuring network proxies or environments
    /// - Initializing mandatory SDK objects
    ///
    /// This method should be called once during app startup or
    /// before any chat interaction begins.
    func setupSDK() 
 
    /// Starts a chat request using the provided user prompt.
    ///
    /// Implementations should:
    /// - Forward the prompt to the underlying SDK
    /// - Trigger appropriate status updates via `statusPublisher`
    /// - Handle SDK callbacks and map them into domain-level states
    ///
    /// - Parameter prompt: The user input to be sent to the chatbot model.
    func startChat(prompt: String)
 
    /// - Important: SDK-specific states or callbacks should be mapped into
    /// `ChatBotStatus` values inside the concrete implementation.
    /// - Returns: A Combine publisher emitting `ChatBotStatus` updates
    var statusPublisher: AnyPublisher<ChatBotStatus, Never> { get }
}
 
enum ChatBotStatus {
    case idle
    case initialized
    case loading
    case success(message: String)
    case failed(reason: ChatBotError)
}
 
enum ChatBotError {
    case licenseError
    case responseError(Error)
    case unknown
}

Key Ideas:

  • The protocol reflects on what the app needs, not what the SDK provides.
  • Model states (loading, streaming, success, failure) are expressed as domain-level enums
  • Business logic depends only on protocols, never on concrete SDKs

Protocols also make mocking straightforward, which is especially useful for unit and integration testing.


Step 3: Wrap SDKs Using Protocol

SDK is integrated through a dedicated wrapper class or struct that conforms to the previously defined protocol. For this example I am using DeepSwiftSeek for integrating DeepSeek chat to Swift.

import Combine
import DeepSwiftSeek
 
final class DeepSeekChatSDK: ChatBotSDK {
    private var client: DeepSeekClient?
    private let statusSubject = PassthroughSubject<ChatBotStatus, Never>()
 
    var statusPublisher: AnyPublisher<ChatBotStatus, Never> {
        statusSubject.eraseToAnyPublisher()
    }
 
    func setupSDK() {
        let config = Configuration(apiKey: "apiKey")
        self.client = DeepSeekClient(configuration: config)
        statusSubject.send(.initialized)
    }
 
    func startChat(prompt: String) {
        Task {
            statusSubject.send(.loading)
            do {
                let response = try await client.chatCompletions(
                    messages: {
                        ChatMessageRequest(role: .user, content: prompt, name: "User")
                    },
                    model: .deepSeekChat,
                    parameters: .creative
                )
                let text = response.choices.first?.message.content ?? ""
                statusSubject.send(.success(message: text))
            } catch {
                // Wrap DeepSwiftSeek errors in your domain error
                statusSubject.send(.failed(reason: .responseError(error)))
            }
        }
    }
}

Responsibilities of a wrapper:

  • Listen to all callbacks or events coming from the SDK
  • Convert those callbacks into states that fit the app’s own flow and architecture
  • Keep SDK-specific details hidden from the rest of the app

By isolating SDK logic inside wrappers, the rest of the codebase stays clean and predictable—even when SDK APIs are verbose or unstable.


Step 4: Unit test

Instead of testing a real SDK directly, we can test the app using a mock wrapper that conforms to the same ChatBotSDK protocol.

This mock behaves like a real SDK wrapper from the app’s point of view, but internally it simulates callbacks and responses. This allows us to focus on testing user flows, state handling, and edge cases without relying on network calls or third-party binaries.

final class MockChatBotSDKWrapper: ChatBotSDK {
 
    private let statusSubject = PassthroughSubject<ChatBotStatus, Never>()
 
    var statusPublisher: AnyPublisher<ChatBotStatus, Never> {
        statusSubject.eraseToAnyPublisher()
    }
 
    func setupSDK() {
        // Simulate SDK setup success
        statusSubject.send(.initialized)
    }
 
    func startChat(prompt: String) {
        // Simulate SDK starting a request
        statusSubject.send(.loading)
 
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
            if prompt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
                self.statusSubject.send(.failed(reason: .unknown))
            } else {
                self.statusSubject.send(
                    .success(message: "Mock reply for: \(prompt)")
                )
            }
        }
    }
}

This mock implementation allows tests to:

  • Simulate success and failure scenarios
  • Verify UI and business logic reactions without real SDK dependencies

In unit tests, this mock can be injected wherever a ChatBotSDK is required, ensuring fast, stable, and isolated test runs.


Trade-offs and Potential Cons

While this approach has worked well for me, I want to list any downsides or drawbacks that may occur:

Higher upfront time for designing protocols

Designing user flows, protocols, and custom enums requires more effort at the beginning compared to integrating the SDK directly.

Additional abstraction layers

Protocols and wrappers may fail to capture important SDK-specific capabilities.

Steep Learning cure for new team members

Developers may be unfamiliar with protocol-oriented design and may need time to understand why SDK APIs are not used directly.

Despite these trade-offs, I’ve found that in SDK-heavy and high-risk domains like mobile banking, the long-term benefits often outweigh the initial complexity.


Conclusion

This approach has a pretty straightforward objective: Design the user flow first and let SDKs adapt to it, not the way around. By starting on what user experiences and flow through protocols, we prevent vendor APIs from leaking into business logic.

Class wrappers become the translation layer where SDK callbacks, states and errors are reshaped to match the app's architecture. This keeps the codebase stable even when SDKs change, allowing vendors to be swapped or upgraded with minimal impact.

Many of the ideas in this article are influenced by John Ousterhout’s A Philosophy of Software Design, a book that shape how I think about complexity, and long-term software design.