Handling API Dependencies with Chain of Responsibility and Modern Concurrency in Swift
High-Level Design - Chain of Responsibility Pattern for API Handling

Handling API Dependencies with Chain of Responsibility and Modern Concurrency in Swift

Introduction

When building iOS applications, we often encounter scenarios requiring multiple sequential API calls, where each subsequent call depends on the previous one’s result. A common example might look like this:

  1. Fetch user details
  2. Use those details to retrieve account information
  3. Use the account information to fetch transaction history

Without proper organization, dependencies can quickly become unmanageable. In this context, we’ll explore how this happens and then discuss how to break down complex operations into reusable steps. This brings us to today’s topic: the Chain of Responsibility pattern, combined with Swift’s modern concurrency features, to create a clean and maintainable solution.

Problems with the Traditional Approach

Consider this typical scenario in a banking app:

// Problematic approach
func fetchUserData() async {
    do {
        let userDetails = try await fetchUserDetails()
        let accountInfo = try await fetchAccountInfo(userId: userDetails.userId)
        let transactions = try await fetchTransactions(accountId: accountInfo.accountId)
        // Handle the results
    } catch {
        // Handle errors
    }
}        

Tight Coupling

  • Data from one step is hardwired to the next
  • Code must be used as a single unit

Hard to Modify

  • Adding new API calls requires changing existing code
  • It is difficult to add optional steps

Complex Error Handling

  • Nested error-handling blocks become messy
  • Different error types at each level

Testing Challenges

  • Must mock the entire chain for any test
  • It is hard to test specific error scenarios

The Solution: Chain of Responsibility

The Chain of Responsibility pattern allows us to break this complex operation into discrete, reusable steps. Combined with Swift’s async/await, it creates a clean and maintainable solution.

High-Level Design - Chain of Responsibility Pattern for API Handling

Step 1: Define the Protocol

First, we create a protocol that each handler will implement:

protocol AsyncAPIHandler {
    func handle(input: Any?) async throws -> Any
}        

Step 2: Create Model Types

We’ll use Codable for type-safe data handling:

struct UserDetails: Codable {
    let userId: String
    let username: String
}

struct AccountInfo: Codable {
    let accountId: String
    let balance: Double
}

struct Transaction: Codable {
    let id: String
    let amount: Double
}        

Step 3: Implement the Handlers

Each handler focuses on one specific API call:

class UserDetailsHandler: AsyncAPIHandler {
    func handle(input: Any?) async throws -> Any {
        let url = URL(string: "https://api.example.com/user")!
        let (data, _) = try await URLSession.shared.data(from: url)
        return try JSONDecoder().decode(UserDetails.self, from: data)
    }
}

class AccountInfoHandler: AsyncAPIHandler {
    func handle(input: Any?) async throws -> Any {
        guard let userDetails = input as? UserDetails else {
            throw HandlerError.invalidInput
        }
         
        let url = URL(string: "https://api.example.com/account/\(userDetails.userId)")!
        let (data, _) = try await URLSession.shared.data(from: url)
        return try JSONDecoder().decode(AccountInfo.self, from: data)
    }
}

class TransactionHandler: AsyncAPIHandler {
    func handle(input: Any?) async throws -> Any {
        guard let accountInfo = input as? AccountInfo else {
            throw HandlerError.invalidInput
        }
        
        let url = URL(string: "https://api.example.com/transactions/\(accountInfo.accountId)")!
        let (data, _) = try await URLSession.shared.data(from: url)
        
        return try JSONDecoder().decode([Transaction].self, from: data)
    }
}        

Step 4: Create the Chain

The chain class orchestrates the execution:

class AsyncAPIChain {
    private var handlers: [AsyncAPIHandler] = []
    
    func addHandler(_ handler: AsyncAPIHandler) {
        handlers.append(handler)
    }
    
    func execute() async throws -> Any {
        var result: Any? = nil
        
        for handler in handlers {
            result = try await handler.handle(input: result)
        }
         
        return result ?? "No Result"
    }
}        

Step 5: Integration with SwiftUI

In the first part, we’ll focus on how to set up the AsyncAPIChain with the required handlers and inject it into the ViewModel in the App structure.

import SwiftUI

@main
struct BankingApp: App {
    var body: some Scene {
        WindowGroup {
            // Create the handlers for the chain
            let userDetailsHandler = UserDetailsHandler()
            let accountInfoHandler = AccountInfoHandler()
            let transactionHandler = TransactionHandler()

            // Create the AsyncAPIChain with the handlers
            let apiChain = AsyncAPIChain()
            apiChain.addHandler(userDetailsHandler)
            apiChain.addHandler(accountInfoHandler)
            apiChain.addHandler(transactionHandler)

            // Inject the apiChain into the ViewModel
            ContentView(viewModel: ViewModel(apiChain: apiChain))
        }
    }
}

struct ContentView: View {
    @StateObject private var viewModel: ViewModel
    
    init(viewModel: ViewModel) {
        _viewModel = StateObject(wrappedValue: viewModel)
    }
    
    var body: some View {
        VStack {
            if let latestTransaction = viewModel.latestTransaction {
                Text("Latest Transaction: \(latestTransaction.transactionId), Amount: \(latestTransaction.amount))")
            }
            Text("Total Transactions: \(viewModel.transactionCount)")
        }
        .padding()
        .task {
            await viewModel.fetchData()
        }
    }
}        

Key Highlights:

  • AsyncAPIChain Setup: Here we create and configure the AsyncAPIChain by adding the relevant handlers (UserDetailsHandler, AccountInfoHandler, and TransactionHandler).
  • Injecting into ViewModel: The chain is then injected into the ViewModel using dependency injection, making the ViewModel ready to handle the chained API requests.

Now, let’s look at how the ViewModel interacts with the AsyncAPIChain to fetch data and update the view with the results.

import SwiftUI

@MainActor
class ViewModel: ObservableObject {
    @Published var transactionCount: Int = 0
    @Published var latestTransaction: Transaction? = nil

    private let apiChain: AsyncAPIChain

    init(apiChain: AsyncAPIChain) {
        self.apiChain = apiChain
    }  
  
    func fetchData() async {
        do {
            let finalResult = try await apiChain.execute()
            if let transactions = finalResult as? [Transaction] {
                transactionCount = transactions.count
                latestTransaction = transactions.first  // Assuming the first is the latest
            }
        } catch {
            result = "Error: \(error.localizedDescription)"
        }
    }
}        

The @MainActor attribute is used to ensure that the ViewModel's properties and methods are accessed and updated on the main thread.

Key Highlights:

  • ViewModel Properties: transactionCount and latestTransaction hold the results fetched by the API chain.
  • Fetching Data: The fetchData method asynchronously fetches data from the AsyncAPIChain. The results are then mapped to update the transactionCount and latestTransaction properties.

Mocking Handlers for Unit Testing

As we showed above, how we design and build the AsyncAPIChain to manage the API dependencies and provide the final result for the ViewModel easily as it is injected from outside, let's now explore how we can mock the handlers and test our ViewModel.

By injecting dependencies such as the AsyncAPIChain into our ViewModel, we decouple the business logic from external systems, allowing us to write clean and maintainable unit tests. This approach helps us simulate different API responses and ensures we can test the ViewModel in isolation, without making real network requests.

Create Mock Handlers

Mock handlers simulate the behavior of the actual handlers used in the AsyncAPIChain. These handlers provide predefined outputs, enabling us to simulate various test scenarios without the need for real network calls.

Example Mock Handler for UserDetailsHandler:

class MockUserDetailsHandler: AsyncAPIHandler {
    func handle(input: Any?) async throws -> Any {
        return UserDetails(userId: "mockUserId", username: "mockUser")
    }
}        

Example Mock Handler for AccountInfoHandler:

class MockAccountInfoHandler: AsyncAPIHandler {
    func handle(input: Any?) async throws -> Any {
        guard let userDetails = input as? UserDetails else {
            throw HandlerError.invalidInput
        }
        return AccountInfo(accountId: "mockAccountId", balance: 100)
    }
}        

Example Mock Handler for TransactionHandler:

class MockTransactionHandler: AsyncAPIHandler {
    func handle(input: Any?) async throws -> Any {
        return [
            Transaction(id: "mockTx1", amount: 100),
            Transaction(id: "mockTx2", amount: 200)
        ]
    }
}        

Testable ViewModel

Now that we have mock handlers in place, we can inject them into the ViewModel to simulate API calls during testing. This allows us to test the business logic in isolation without relying on real network data.

Example of a ViewModel Test:

import XCTest
import SwiftUI

class ViewModelTests: XCTestCase {
    func testFetchData_Success() async {
        // Arrange: Create mock handlers
        let mockUserDetailsHandler = MockUserDetailsHandler()
        let mockAccountInfoHandler = MockAccountInfoHandler()
        let mockTransactionHandler = MockTransactionHandler()

        // Create the AsyncAPIChain with the mock handlers
        let apiChain = AsyncAPIChain()
        apiChain.addHandler(mockUserDetailsHandler)
        apiChain.addHandler(mockAccountInfoHandler)
        apiChain.addHandler(mockTransactionHandler)

        // Inject the chain into the ViewModel
        let viewModel = ViewModel(apiChain: apiChain)

        // Act: Fetch data
        await viewModel.fetchData()

        // Assert: Verify the results
        XCTAssertEqual(viewModel.transactionCount, 2, "Transaction count should be 2")
        XCTAssertEqual(viewModel.latestTransaction?.id, "mockTx1", "The latest transaction ID should be mockTx1")
        XCTAssertEqual(viewModel.latestTransaction?.amount, 100, "The latest transaction amount should be 100")
    }

    func testFetchData_Error() async {
        // Arrange: Create a mock handler that throws an error
        class MockErrorHandler: AsyncAPIHandler {
            func handle(input: Any?) async throws -> Any {
                throw NSError(domain: "MockError", code: 123, userInfo: nil)
            }
        }

        // Create the AsyncAPIChain with the mock handlers
        let apiChain = AsyncAPIChain()
        let mockErrorHandler = MockErrorHandler()
        apiChain.addHandler(mockErrorHandler)

        // Inject the chain into the ViewModel
        let viewModel = ViewModel(apiChain: apiChain)

        // Act: Fetch data
        await viewModel.fetchData()

        // Assert: Verify the error result
        XCTAssertEqual(viewModel.result, "Error: The operation couldn’t be completed. (MockError error 123.)", "Error message should be properly set")
    }
}        

This section shows how we can mock handlers to create testable, isolated code. By doing so, we can efficiently test our ViewModel without relying on external systems.

Benefits

This approach offers several advantages:

  1. Modularity: Each handler is independent and can be tested in isolation
  2. Flexibility: Easy to add, remove, or reorder API calls
  3. Reusability: Handlers can be reused in different chains
  4. Type Safety: Codable ensures type-safe data handling
  5. Error Handling: Centralized error handling in the chain
  6. Testability: Easy to mock handlers for testing

Conclusion

The Chain of Responsibility pattern, combined with Swift’s modern concurrency features, provides a clean solution for handling complex API dependencies. This approach scales well as your application grows and makes future modifications easier.

Remember that while this pattern adds some complexity, the benefits of maintainability and flexibility often outweigh the initial setup cost for complex API workflows.

Feel free to adapt this pattern to your specific needs and simplify it for simpler use cases.

Acknowledgments

I want to extend my thanks to the reviewers for their valuable feedback and insights, which helped shape this guide. Special thanks to Kareem Abd Elsattar and Mohamed Goda for their time and expertise in reviewing the content.

Happy coding!?

Mohamed Ramadan

Senior Software Engineer (iOS) @ Warba Bank

2 个月

???? ???? ???? ???? ?? Essam ????

Ahmed Heikal

Software Engineer

2 个月

?????? ?? ??? fresh ??? ?????? ?

回复
Muhammad Mamdouh

Backend PHP Developer

2 个月

?? ???? ?? ???????? ???? .. ??? ???? ?? ???? ????? ?? ?? logs? ?? ?? ????? ??? ???? ???? ??? ???? ????? ?? ??????? ??? ?? ???? ?????? ???? ???? ???? ???? ???? ???? ?? ?????? ?? ???? ?? ??? .. ???? ???? ????? ????? ?????? ?????? ?? ???????

Ibrahim Ramadan

Senior Mobile Developer || Flutter || IOS

2 个月

approach ???? ?????? ???? ??? ?? ???????? ??

要查看或添加评论,请登录

Essam Fahmy的更多文章

社区洞察

其他会员也浏览了