From Core to Reality: Infrastructure, URLSession & Real-World API Challenges
Building the networking layer with TDD: When perfect domain models meet messy real-world APIs. A journey through JSON parsing, error mapping, and Swift Concurrency patterns.
Introduction
In the previous article we built a solid Core with use cases, entities, and protocols using TDD. We had PriceLoader contracts and domain logic, but everything was pure abstractions.
Now comes the reality check: connecting our beautiful Core to actual APIs.
The challenge: Binance returns "price": "68901.23000000" (String), while CryptoCompare returns "PRICE": 68910.12 (Number). Different formats, different error scenarios, and we need to maintain our Clean Architecture principles.
The goal: Build a production-ready networking layer that adapts real APIs to our domain contracts.
By the end of this article, you’ll see how real-world infrastructure challenges drive better architectural decisions, and how TDD catches bugs that could affect production.
Step 1: Module Separation and Dependencies
First decision: separate module for networking concerns.
btc-price/├── BTCPriceCore/ # Domain layer (done)└── BTCPriceNetworking/ # Infrastructure layer (new)Why separate modules?
- Clear boundaries: Infrastructure can’t pollute domain
- Independent testing: Network tests don’t need domain complexity
- Swappable: Could replace with different networking approach later
Package.swift Dependencies
let package = Package( name: "BTCPriceNetworking", dependencies: [ .package(path: "../BTCPriceCore"), // ← Domain contracts ], targets: [ .target(name: "BTCPriceNetworking", dependencies: ["BTCPriceCore"]) ])Key insight: Infrastructure depends on domain, never the other way around.
Step 2: URLSession Abstraction with TDD
🔴 RED - The failing test drives the design
@Suite("BinancePriceLoaderTests")struct BinancePriceLoaderTests { @Test func loadLatest_withValidBinanceResponse_deliversPriceQuote() async throws { let jsonData = """ { "symbol": "BTCUSDT", "price": "68901.23000000" } """.data(using: .utf8)!
let session = URLSessionStub(data: jsonData, response: httpResponse(200)) let sut = BinancePriceLoader(session: session)
let quote = try await sut.loadLatest()
#expect(quote.value == Decimal(string: "68901.23")!) #expect(quote.currency == "USD") }}Compilation errors: BinancePriceLoader doesn’t exist, URLSessionStub doesn’t exist.
TDD benefit: The test tells us exactly what we need to build.
🟢 GREEN - URLSession protocol abstraction
public protocol URLSessionProtocol: Sendable { func data(for request: URLRequest) async throws -> (Data, URLResponse)}
extension URLSession: URLSessionProtocol {}Why the protocol?
- Testability: Can inject stubs for testing
- Clean Architecture: Abstract away framework details
- Sendable compliance: Safe for concurrent access
Step 3: First API Adapter - Binance Implementation
🟢 GREEN - Minimal BinancePriceLoader
public struct BinancePriceLoader: PriceLoader { private let session: URLSessionProtocol
public func loadLatest() async throws -> PriceQuote { let url = URL(string: "https://api.binance.com/api/v3/ticker/price?symbol=BTCUSDT")! let request = URLRequest(url: url)
let (data, response) = try await session.data(for: request)
// HTTP status validation guard let httpResponse = response as? HTTPURLResponse, 200...299 ~= httpResponse.statusCode else { throw PriceLoadingError.networkFailure }
do { let response = try JSONDecoder().decode(BinanceResponse.self, from: data)
guard let price = Decimal(string: response.price) else { throw PriceLoadingError.invalidPrice }
return PriceQuote(value: price, currency: "USD", timestamp: Date())
} catch is DecodingError { throw PriceLoadingError.invalidData } }}
private struct BinanceResponse: Codable { let symbol: String let price: String // ← Note: String, not Number}Key patterns:
- Domain error mapping:
DecodingError→PriceLoadingError.invalidData - HTTP status validation: Don’t trust 200 is the only success
- Decimal parsing: Financial precision over Double
🔄 REFACTOR - Comprehensive error testing
@Test func loadLatest_withInvalidJSON_throwsInvalidDataError() async throws { let invalidJSON = "{ invalid json }".data(using: .utf8)! let session = URLSessionStub(data: invalidJSON, response: httpResponse(200)) let sut = BinancePriceLoader(session: session)
await #expect(throws: PriceLoadingError.invalidData) { _ = try await sut.loadLatest() }}
@Test func loadLatest_withHTTPError_throwsNetworkFailure() async throws { let session = URLSessionStub(data: Data(), response: httpResponse(500)) let sut = BinancePriceLoader(session: session)
await #expect(throws: PriceLoadingError.networkFailure) { _ = try await sut.loadLatest() }}Result: 5 tests covering happy path, different data, JSON errors, HTTP errors.
Step 4: Different API, Different Challenges - CryptoCompare
Same TDD approach, but different JSON structure reveals new challenges:
🔴 RED - Different JSON format
@Test func loadLatest_withValidCryptoCompareResponse_deliversPriceQuote() async throws { let jsonData = """ { "RAW": { "PRICE": 68910.12, // ← Number, not String! "FROMSYMBOL": "BTC", "TOSYMBOL": "USD" } } """.data(using: .utf8)!
// ... rest of test}Challenge 1: Complex JSON Structure
private struct CryptoCompareResponse: Codable { let raw: RAWData
struct RAWData: Codable { let price: Double // ← Double from API let fromSymbol: String let toSymbol: String
enum CodingKeys: String, CodingKey { case price = "PRICE" // ← Uppercase in API case fromSymbol = "FROMSYMBOL" case toSymbol = "TOSYMBOL" } }
enum CodingKeys: String, CodingKey { case raw = "RAW" }}Challenge 2: Floating-Point Precision Bug Discovered
Test failure revealed a real bug:
- Expected:
75500.99 - Actual:
75500.990000000001024
Root cause: Decimal(double: 75500.99) introduces floating-point errors.
🟢 GREEN - Solution: String conversion for precision
let price = Decimal(string: String(response.raw.price)) ?? Decimal(response.raw.price)TDD benefit: Caught a financial precision bug that could affect production!
Step 5: Integration Testing with URLProtocolStub
Unit tests were great, but we needed to test with real URLSession without hitting real networks.
The Challenge: URLProtocolStub with Swift Concurrency
final class URLProtocolStub: URLProtocol, @unchecked Sendable { static let stubStore = StubStore()
actor StubStore { // ← Actor for thread safety private var stubs: [URL: Stub] = [:]
func setStub(url: URL, data: Data?, response: URLResponse?, error: Error?) { stubs[url] = Stub(data: data, response: response, error: error) }
func getStub(for url: URL) -> Stub? { stubs[url] } }
// Public API static func stub(url: URL, data: Data?, response: URLResponse?, error: Error?) async { await stubStore.setStub(url: url, data: data, response: response, error: error) }}Modern Swift patterns:
- Actor: Thread-safe shared state
- @unchecked Sendable: URLProtocol isn’t Sendable by default
- Static methods: Avoid capture issues in async contexts
URLProtocol Implementation Challenge
Problem: startLoading() is synchronous but we need async access to actor
Solution: Proper task management
override func startLoading() { let request = self.request let client = self.client
Task { @Sendable in await URLProtocolStub.handleRequestAsync( urlProtocol: self, request: request, client: client ) }}private static func handleRequestAsync( urlProtocol: URLProtocolStub, request: URLRequest, client: URLProtocolClient?) async { guard let url = request.url, let stub = await URLProtocolStub.stubStore.getStub(for: url) else { client?.urlProtocol(urlProtocol, didFailWithError: URLError(.badURL)) return }
// Deliver stubbed response...}Integration Test Success
@Test func binanceLoader_withURLProtocolStub_deliversResponse() async throws { let binanceURL = URL(string: "https://api.binance.com/api/v3/ticker/price?symbol=BTCUSDT")! let jsonData = """ { "symbol": "BTCUSDT", "price": "68901.23000000" } """.data(using: .utf8)!
await URLProtocolStub.stub(url: binanceURL, data: jsonData, response: httpResponse(200), error: nil)
// Real URLSession with custom configuration let config = URLSessionConfiguration.ephemeral config.protocolClasses = [URLProtocolStub.self] let session = URLSession(configuration: config)
let sut = BinancePriceLoader(session: session) let quote = try await sut.loadLatest()
#expect(quote.value == Decimal(string: "68901.23")!)}Key insight: URLProtocolStub intercepts network calls, letting us test with real URLSession but controlled responses.
Real Development Challenges We Solved
Challenge 1: “JSON APIs Don’t Follow Standards”
Problem: Binance uses strings for numbers, CryptoCompare uses numbers for numbers.
Solution: Separate response models per API, map to common domain model.
Lesson: Infrastructure adapts to domain, not the other way around.
Challenge 2: “Floating-Point Numbers Are Evil in Finance”
Problem: Decimal(75500.99) became 75500.990000000001024
Solution: Always convert through String for financial precision.
TDD benefit: Test with different values caught this immediately.
Challenge 3: “Swift Concurrency in Legacy Protocols”
Problem: URLProtocol is pre-async/await, but our loaders are async.
Solution: Actor-based state management with proper Task handling.
Pattern: Static async functions avoid capture complexity.
Challenge 4: “HTTP 200 Doesn’t Mean Success”
Problem: APIs can return 200 with error JSON.
Solution: Always validate HTTP status codes explicitly.
Best practice: Assume nothing about HTTP behavior.
Architecture Insights
Domain Error Mapping
// Infrastructure errors → Domain errorscatch let error as DecodingError { throw PriceLoadingError.invalidData} catch { throw PriceLoadingError.networkFailure}Benefit: Core layer never knows about JSON, HTTP, or specific APIs.
Protocol-Based Testing
public protocol URLSessionProtocol: Sendable { func data(for request: URLRequest) async throws -> (Data, URLResponse)}Benefit:
- Unit tests use simple stubs
- Integration tests use URLProtocolStub
- Production uses real URLSession
- All implement same contract
Modern Swift Patterns
- Actor: Thread-safe shared state (StubStore)
- @Sendable closures: Concurrency-safe callbacks
- Structured concurrency: Proper Task lifecycle management
- Protocol extensions:
URLSession: URLSessionProtocol
The Numbers: What We Built
Complete networking infrastructure:
- 2 API adapters: Binance + CryptoCompare
- 13 tests total: Unit tests + Integration tests
- 3 test suites: BinancePriceLoaderTests, CryptoComparePriceLoaderTests, IntegrationTests
- 5 error scenarios per loader: HTTP errors, JSON errors, validation errors
- 0 dependencies on UI frameworks: Pure networking layer
Test execution metrics: ✔ Test run with 13 tests in 3 suites passed after 0.008 seconds
Why this matters: 8ms test suite enables instant feedback during development.
Key Design Decisions We Made
1. Why Separate Modules?
Alternative: Add networking to BTCPriceCore Choice: Separate BTCPriceNetworking module Reason: Clear architectural boundaries, independent testing
2. Why Protocol Abstraction for URLSession?
Alternative: Use URLSession directly in loaders Choice: URLSessionProtocol abstraction Reason: Testability without complex mocking frameworks
3. Why Actor for URLProtocolStub State?
Alternative: @MainActor or Synchronization framework Choice: Custom actor StubStore Reason: Proper isolation without main thread dependency
4. Why Domain Error Mapping?
Alternative: Let infrastructure errors bubble up Choice: Map all errors to PriceLoadingError Reason: Clean Architecture - domain doesn’t know about JSON/HTTP
Production-Ready Results
Our networking layer can now:
- ✅ Load quotes from Binance with string-to-decimal conversion
- ✅ Load quotes from CryptoCompare with precision-safe number handling
- ✅ Handle all HTTP error scenarios (404, 500, timeouts)
- ✅ Parse different JSON formats with proper error mapping
- ✅ Integrate with any URLSession (real or stubbed)
- ✅ Maintain Clean Architecture (domain independence)
All modular. All tested. All Swift Concurrency compliant.
What We Learned
1. TDD Drives Better API Design
Every public interface was shaped by test-first thinking:
// Test demanded this simple, focused APIlet quote = try await loader.loadLatest()
// Not this complex, configuration-heavy onelet loader = NetworkLoader(config: config, retries: 3, timeout: 30)loader.setBaseURL(url)let quote = try await loader.fetchPriceQuote()2. Real APIs Are Messier Than Specs
- Binance:
"price": "68901.23000000"(string) - CryptoCompare:
"PRICE": 68910.12(number) - HTTP 200 doesn’t guarantee success
- Floating-point precision matters in finance
Infrastructure exists to hide this complexity from domain logic.
3. Swift Concurrency Requires Discipline
- Use actor for shared mutable state
- Prefer static methods to avoid capture complexity
- Always think about Sendable compliance
- URLProtocol pre-dates async/await - adapt carefully
4. Integration Tests Catch Different Bugs
- Unit tests: Logic correctness
- Integration tests: Protocol compliance, real URLSession behavior
- Both needed for confidence
Conclusion
We went from abstract domain contracts → real API integration using pure TDD. The process revealed several insights:
- APIs lie about their contracts - always validate and test with real data
- Floating-point precision matters - financial apps need Decimal precision
- Swift Concurrency is powerful - but requires careful actor design
- Clean Architecture pays off - domain stays pure despite infrastructure complexity
Our BTC/USD app networking layer can now:
- Load quotes from multiple APIs with different formats
- Handle all error scenarios gracefully
- Integrate with any URLSession implementation
- Maintain Clean Architecture boundaries
All modular. All tested. All production-ready.
The development wasn’t always smooth - we hit floating-point bugs, concurrency challenges, and JSON format surprises. But each challenge taught us something valuable about building robust infrastructure.
What’s Next
In the next article we’ll tackle the persistence layer:
- BTCPricePersistence → comparing UserDefaults vs FileManager vs SwiftData
- Cache strategies → when to persist, how to handle corruption
- Performance analysis → measuring read/write speeds across approaches
- Migration patterns → swapping persistence implementations easily
Only then will we connect everything with ViewModels and build the actual iOS/CLI apps that users see.
The networking foundation is solid. Time to make data stick around 💾.
Related
-
- swift
- ios
- performance
Mastering Instruments (Part 4): Flame Graphs, Swift Concurrency Under the Microscope, and Processor Trace in Action
Learn to read Flame Graphs, audit async tasks with Swift Tasks, and push Processor Trace to its limits with a real CLI project that uses Swift Concurrency intensively.
-
- swift
- swift-zero-expert
- swift-fundamentals
Swift from Zero to Expert #5: Functions — first-class citizens
Parameters, labels, inout, function types and functions as values. The gateway to closures and functional programming.
-
- swift
- ios
- performance
Mastering Instruments (Part 3): Scientific Method, Advanced Time Profiler, and Profiling at Scale
Learn to diagnose performance issues as a scientific process. Master Weight vs Self-Weight, Charge/Prune/Flatten, and scale profiling with xctrace.