Skip to main content

iOS Data & Networking

Data Layer Architecture

Implement offline-first architecture where the UI reads from local Core Data and background sync keeps it fresh. URLSession with async/await handles API communication with automatic retry logic and error mapping. The repository pattern abstracts data sources - domain layer depends on repository protocols, not concrete implementations. This enables swapping network repositories for mock repositories in tests without changing business logic.

Overview

This guide covers data management and networking best practices for iOS applications. We focus on RESTful API integration with URLSession, local persistence with Core Data, repository pattern implementation, offline-first strategies, and synchronization mechanisms.

What This Guide Covers

  • Networking with URLSession: Async/await, request/response handling, error management
  • Core Data Persistence: Stack setup, entities, DAOs, migrations
  • Repository Pattern: Abstracting data sources for testability
  • Offline-First Architecture: Local-first design, sync strategies
  • Data Mapping: DTOs to domain models, type-safe transformations

Networking Architecture

Networking Layer Diagram


URLSession Networking

Network Manager

// Core/Network/NetworkManager.swift
import Foundation

protocol NetworkManagerProtocol {
func request<T: Decodable>(_ endpoint: APIEndpoint) async throws -> T
func request(_ endpoint: APIEndpoint) async throws
}

final class NetworkManager: NetworkManagerProtocol {
// MARK: - Properties

private let session: URLSession
private let decoder: JSONDecoder
private let encoder: JSONEncoder

// MARK: - Initialization

init(
session: URLSession = .shared,
decoder: JSONDecoder = .standardDecoder,
encoder: JSONEncoder = .standardEncoder
) {
self.session = session
self.decoder = decoder
self.encoder = encoder
}

// MARK: - Public Methods

func request<T: Decodable>(_ endpoint: APIEndpoint) async throws -> T {
let request = try buildRequest(for: endpoint)

do {
let (data, response) = try await session.data(for: request)
try validateResponse(response)
return try decoder.decode(T.self, from: data)
} catch let error as NetworkError {
throw error
} catch {
throw NetworkError.unknown(error)
}
}

func request(_ endpoint: APIEndpoint) async throws {
let request = try buildRequest(for: endpoint)
let (_, response) = try await session.data(for: request)
try validateResponse(response)
}

// MARK: - Private Methods

private func buildRequest(for endpoint: APIEndpoint) throws -> URLRequest {
guard let url = endpoint.url else {
throw NetworkError.invalidURL
}

var request = URLRequest(url: url)
request.httpMethod = endpoint.method.rawValue
request.allHTTPHeaderFields = endpoint.headers
request.timeoutInterval = 30

if let body = endpoint.body {
request.httpBody = try encoder.encode(body)
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
}

return request
}

private func validateResponse(_ response: URLResponse) throws {
guard let httpResponse = response as? HTTPURLResponse else {
throw NetworkError.invalidResponse
}

switch httpResponse.statusCode {
case 200...299:
return
case 401:
throw NetworkError.unauthorized
case 403:
throw NetworkError.forbidden
case 404:
throw NetworkError.notFound
case 500...599:
throw NetworkError.serverError(statusCode: httpResponse.statusCode)
default:
throw NetworkError.httpError(statusCode: httpResponse.statusCode)
}
}
}

// MARK: - Network Error

enum NetworkError: LocalizedError {
case invalidURL
case invalidResponse
case httpError(statusCode: Int)
case unauthorized
case forbidden
case notFound
case serverError(statusCode: Int)
case decodingError(Error)
case encodingError(Error)
case unknown(Error)

var errorDescription: String? {
switch self {
case .invalidURL:
return "Invalid URL"
case .invalidResponse:
return "Invalid response from server"
case .httpError(let statusCode):
return "HTTP error: \(statusCode)"
case .unauthorized:
return "Unauthorized. Please log in again."
case .forbidden:
return "Access forbidden"
case .notFound:
return "Resource not found"
case .serverError(let statusCode):
return "Server error: \(statusCode)"
case .decodingError:
return "Failed to decode response"
case .encodingError:
return "Failed to encode request"
case .unknown(let error):
return error.localizedDescription
}
}
}

API Endpoint Protocol

// Core/Network/APIEndpoint.swift
import Foundation

enum HTTPMethod: String {
case get = "GET"
case post = "POST"
case put = "PUT"
case delete = "DELETE"
case patch = "PATCH"
}

protocol APIEndpoint {
var baseURL: String { get }
var path: String { get }
var method: HTTPMethod { get }
var headers: [String: String] { get }
var queryItems: [URLQueryItem]? { get }
var body: Encodable? { get }
}

extension APIEndpoint {
var url: URL? {
var components = URLComponents(string: baseURL + path)
components?.queryItems = queryItems
return components?.url
}

var headers: [String: String] {
var headers = [String: String]()
headers["Accept"] = "application/json"
headers["Content-Type"] = "application/json"

// Add auth token if available
if let token = KeychainManager.shared.getAuthToken() {
headers["Authorization"] = "Bearer \(token)"
}

return headers
}
}

Payment Endpoints

// Data/Remote/Services/PaymentEndpoint.swift
import Foundation

enum PaymentEndpoint: APIEndpoint {
case getPayments(limit: Int)
case getPayment(id: String)
case createPayment(CreatePaymentRequest)
case cancelPayment(id: String)

var baseURL: String {
Environment.current.baseURL
}

var path: String {
switch self {
case .getPayments:
return "/v1/payments"
case .getPayment(let id):
return "/v1/payments/\(id)"
case .createPayment:
return "/v1/payments"
case .cancelPayment(let id):
return "/v1/payments/\(id)/cancel"
}
}

var method: HTTPMethod {
switch self {
case .getPayments, .getPayment:
return .get
case .createPayment, .cancelPayment:
return .post
}
}

var queryItems: [URLQueryItem]? {
switch self {
case .getPayments(let limit):
return [URLQueryItem(name: "limit", value: "\(limit)")]
default:
return nil
}
}

var body: Encodable? {
switch self {
case .createPayment(let request):
return request
default:
return nil
}
}
}

Request/Response Flow


Data Transfer Objects (DTOs)

Payment DTO

// Data/Remote/DTOs/PaymentDTO.swift
import Foundation

struct PaymentDTO: Codable {
let id: String
let accountId: String
let recipientName: String
let recipientAccountNumber: String
let amount: String // Decimal as String from API
let currency: String
let status: String
let description: String?
let reference: String
let createdAt: String // ISO8601 string
let completedAt: String?

// MARK: - Convert to Domain Model

func toDomain() throws -> Payment {
guard let amount = Decimal(string: amount) else {
throw MappingError.invalidAmount
}

guard let createdAt = ISO8601DateFormatter().date(from: createdAt) else {
throw MappingError.invalidDate
}

let completedAt = completedAt.flatMap { ISO8601DateFormatter().date(from: $0) }

guard let status = PaymentStatus(rawValue: status) else {
throw MappingError.invalidStatus
}

return Payment(
id: id,
accountId: accountId,
recipientName: recipientName,
recipientAccountNumber: recipientAccountNumber,
amount: amount,
currency: currency,
status: status,
description: description,
reference: reference,
createdAt: createdAt,
completedAt: completedAt
)
}
}

struct CreatePaymentRequest: Encodable {
let accountId: String
let recipientName: String
let recipientAccountNumber: String
let amount: String
let currency: String
let description: String?
let idempotencyKey: String // Prevent duplicate payments

init(from payment: Payment, idempotencyKey: String) {
self.accountId = payment.accountId
self.recipientName = payment.recipientName
self.recipientAccountNumber = payment.recipientAccountNumber
self.amount = payment.amount.description
self.currency = payment.currency
self.description = payment.description
self.idempotencyKey = idempotencyKey
}
}

enum MappingError: Error {
case invalidAmount
case invalidDate
case invalidStatus
}

// MARK: - JSON Decoder/Encoder

extension JSONDecoder {
static var standardDecoder: JSONDecoder {
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
decoder.dateDecodingStrategy = .iso8601
return decoder
}
}

extension JSONEncoder {
static var standardEncoder: JSONEncoder {
let encoder = JSONEncoder()
encoder.keyEncodingStrategy = .convertToSnakeCase
encoder.dateEncodingStrategy = .iso8601
return encoder
}
}

Core Data Persistence

Core Data Stack

Core Data Stack Implementation

// Core/Storage/CoreDataStack.swift
import CoreData

final class CoreDataStack {
static let shared = CoreDataStack()

private init() {}

// MARK: - Core Data Stack

lazy var persistentContainer: NSPersistentContainer = {
let container = NSPersistentContainer(name: "PaymentApp")

container.loadPersistentStores { description, error in
if let error = error {
fatalError("Unable to load persistent stores: \(error)")
}
}

// Main context for UI reads
container.viewContext.automaticallyMergesChangesFromParent = true
container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy

return container
}()

var viewContext: NSManagedObjectContext {
persistentContainer.viewContext
}

// MARK: - Core Data Saving

func saveContext() {
let context = persistentContainer.viewContext

if context.hasChanges {
do {
try context.save()
} catch {
let nsError = error as NSError
print("Unresolved error \(nsError), \(nsError.userInfo)")
}
}
}

// Create background context for heavy operations
func newBackgroundContext() -> NSManagedObjectContext {
let context = persistentContainer.newBackgroundContext()
context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
return context
}
}

Core Data Entity

// Data/Local/CoreData/PaymentEntity+CoreDataClass.swift
import Foundation
import CoreData

@objc(PaymentEntity)
public class PaymentEntity: NSManagedObject {
@NSManaged public var id: String
@NSManaged public var accountId: String
@NSManaged public var recipientName: String
@NSManaged public var recipientAccountNumber: String
@NSManaged public var amount: NSDecimalNumber
@NSManaged public var currency: String
@NSManaged public var status: String
@NSManaged public var paymentDescription: String?
@NSManaged public var reference: String
@NSManaged public var createdAt: Date
@NSManaged public var completedAt: Date?
@NSManaged public var syncedAt: Date? // Track last sync

// MARK: - Domain Model Conversion

func toDomain() -> Payment {
Payment(
id: id,
accountId: accountId,
recipientName: recipientName,
recipientAccountNumber: recipientAccountNumber,
amount: amount as Decimal,
currency: currency,
status: PaymentStatus(rawValue: status) ?? .pending,
description: paymentDescription,
reference: reference,
createdAt: createdAt,
completedAt: completedAt
)
}

func update(from payment: Payment) {
id = payment.id
accountId = payment.accountId
recipientName = payment.recipientName
recipientAccountNumber = payment.recipientAccountNumber
amount = payment.amount as NSDecimalNumber
currency = payment.currency
status = payment.status.rawValue
paymentDescription = payment.description
reference = payment.reference
createdAt = payment.createdAt
completedAt = payment.completedAt
syncedAt = Date() // Update sync timestamp
}
}

Data Access Object (DAO)

// Data/Local/DAO/PaymentDAO.swift
import Foundation
import CoreData

protocol PaymentDAOProtocol {
func getAll() async throws -> [Payment]
func getById(_ id: String) async throws -> Payment?
func save(_ payment: Payment) async throws
func saveAll(_ payments: [Payment]) async throws
func delete(_ id: String) async throws
func deleteAll() async throws
func getUnsyncedPayments() async throws -> [Payment]
}

final class PaymentDAO: PaymentDAOProtocol {
private let coreDataStack: CoreDataStack

init(coreDataStack: CoreDataStack = .shared) {
self.coreDataStack = coreDataStack
}

func getAll() async throws -> [Payment] {
let context = coreDataStack.viewContext

return try await context.perform {
let request = PaymentEntity.fetchRequest()
request.sortDescriptors = [
NSSortDescriptor(keyPath: \PaymentEntity.createdAt, ascending: false)
]

let entities = try context.fetch(request)
return entities.map { $0.toDomain() }
}
}

func getById(_ id: String) async throws -> Payment? {
let context = coreDataStack.viewContext

return try await context.perform {
let request = PaymentEntity.fetchRequest()
request.predicate = NSPredicate(format: "id == %@", id)
request.fetchLimit = 1

let entities = try context.fetch(request)
return entities.first?.toDomain()
}
}

func save(_ payment: Payment) async throws {
let context = coreDataStack.newBackgroundContext()

try await context.perform {
let request = PaymentEntity.fetchRequest()
request.predicate = NSPredicate(format: "id == %@", payment.id)

let existing = try context.fetch(request).first
let entity = existing ?? PaymentEntity(context: context)
entity.update(from: payment)

try context.save()
}
}

func saveAll(_ payments: [Payment]) async throws {
let context = coreDataStack.newBackgroundContext()

try await context.perform {
for payment in payments {
let request = PaymentEntity.fetchRequest()
request.predicate = NSPredicate(format: "id == %@", payment.id)

let existing = try context.fetch(request).first
let entity = existing ?? PaymentEntity(context: context)
entity.update(from: payment)
}

try context.save()
}
}

func delete(_ id: String) async throws {
let context = coreDataStack.newBackgroundContext()

try await context.perform {
let request = PaymentEntity.fetchRequest()
request.predicate = NSPredicate(format: "id == %@", id)

if let entity = try context.fetch(request).first {
context.delete(entity)
try context.save()
}
}
}

func deleteAll() async throws {
let context = coreDataStack.newBackgroundContext()

try await context.perform {
let request = NSFetchRequest<NSFetchRequestResult>(entityName: "PaymentEntity")
let deleteRequest = NSBatchDeleteRequest(fetchRequest: request)

try context.execute(deleteRequest)
try context.save()
}
}

func getUnsyncedPayments() async throws -> [Payment] {
let context = coreDataStack.viewContext

return try await context.perform {
let request = PaymentEntity.fetchRequest()
// Payments not synced in last hour
let oneHourAgo = Date().addingTimeInterval(-3600)
request.predicate = NSPredicate(
format: "syncedAt == nil OR syncedAt < %@",
oneHourAgo as NSDate
)

let entities = try context.fetch(request)
return entities.map { $0.toDomain() }
}
}
}

Repository Pattern

Repository Protocol

// Domain/Repositories/PaymentRepository.swift
import Foundation

protocol PaymentRepository {
func getPayments() async throws -> [Payment]
func getPayment(id: String) async throws -> Payment
func createPayment(_ payment: Payment) async throws -> Payment
func cancelPayment(id: String) async throws
func syncPayments() async throws
}

Repository Implementation

// Data/Repositories/PaymentRepositoryImpl.swift
import Foundation

final class PaymentRepositoryImpl: PaymentRepository {
private let networkManager: NetworkManagerProtocol
private let paymentDAO: PaymentDAOProtocol

init(
networkManager: NetworkManagerProtocol,
paymentDAO: PaymentDAOProtocol
) {
self.networkManager = networkManager
self.paymentDAO = paymentDAO
}

// MARK: - Offline-First: Read from local, sync in background

func getPayments() async throws -> [Payment] {
// 1. Return cached data immediately
let cachedPayments = try await paymentDAO.getAll()

// 2. Sync in background
Task {
try? await syncPayments()
}

return cachedPayments
}

func getPayment(id: String) async throws -> Payment {
// Try local first
if let cachedPayment = try await paymentDAO.getById(id) {
return cachedPayment
}

// Fallback to network
let dto: PaymentDTO = try await networkManager.request(
PaymentEndpoint.getPayment(id: id)
)
let payment = try dto.toDomain()

// Cache for offline access
try await paymentDAO.save(payment)

return payment
}

// MARK: - Writes go to server first, then cache

func createPayment(_ payment: Payment) async throws -> Payment {
let idempotencyKey = UUID().uuidString
let request = CreatePaymentRequest(from: payment, idempotencyKey: idempotencyKey)

let dto: PaymentDTO = try await networkManager.request(
PaymentEndpoint.createPayment(request)
)

let createdPayment = try dto.toDomain()

// Cache for offline viewing
try await paymentDAO.save(createdPayment)

return createdPayment
}

func cancelPayment(id: String) async throws {
try await networkManager.request(
PaymentEndpoint.cancelPayment(id: id)
)

// Update local cache
if var payment = try await paymentDAO.getById(id) {
payment = Payment(
id: payment.id,
accountId: payment.accountId,
recipientName: payment.recipientName,
recipientAccountNumber: payment.recipientAccountNumber,
amount: payment.amount,
currency: payment.currency,
status: .cancelled,
description: payment.description,
reference: payment.reference,
createdAt: payment.createdAt,
completedAt: Date()
)
try await paymentDAO.save(payment)
}
}

// MARK: - Background Sync

func syncPayments() async throws {
let response: [PaymentDTO] = try await networkManager.request(
PaymentEndpoint.getPayments(limit: 100)
)

let payments = try response.map { try $0.toDomain() }

// Batch save to local database
try await paymentDAO.saveAll(payments)
}
}

Repository Data Flow


Offline-First Architecture

Offline-first architecture prioritizes local data storage and synchronizes with the server in the background. Read operations (like fetching payments) return cached data immediately from Core Data, then trigger a background network request to refresh the cache. This means the UI displays data in <50ms instead of waiting 500-2000ms for a network round-trip. Write operations (like creating a payment) send the request to the server first to ensure it succeeds, then cache the response locally. If the network request fails, the write is not cached - this prevents showing phantom payments that never reached the server.

Sync Strategy

Sync Manager

// Core/Sync/SyncManager.swift
import Foundation
import Network

final class SyncManager {
static let shared = SyncManager()

private let monitor = NWPathMonitor()
private let queue = DispatchQueue(label: "com.bankapp.sync")

@Published private(set) var isOnline = false

private init() {
startMonitoring()
}

private func startMonitoring() {
monitor.pathUpdateHandler = { [weak self] path in
DispatchQueue.main.async {
self?.isOnline = path.status == .satisfied

if self?.isOnline == true {
Task {
await self?.syncPendingChanges()
}
}
}
}

monitor.start(queue: queue)
}

func syncPendingChanges() async {
guard isOnline else { return }

// Sync all repositories
do {
let paymentRepo = DIContainer.shared.paymentRepository
try await paymentRepo.syncPayments()
} catch {
print("Sync failed: \(error)")
}
}
}

Common Mistakes

Don't: Use Float/Double for Currency

//  BAD: Floating point precision errors
let amount: Double = 0.1 + 0.2 // 0.30000000000000004

// GOOD: Use Decimal for currency
let amount: Decimal = 0.1 + 0.2 // 0.3 (exact)

Don't: Block Main Thread with Core Data

//  BAD: Core Data fetch on main thread
let payments = try context.fetch(request) // BLOCKS UI!

// GOOD: Use async perform
let payments = try await context.perform {
try context.fetch(request)
}

Don't: Ignore Idempotency for Payments

//  BAD: No idempotency key (duplicate payments!)
func createPayment(_ payment: Payment) async throws {
let request = CreatePaymentRequest(from: payment)
try await networkManager.request(endpoint)
}

// GOOD: Include idempotency key
func createPayment(_ payment: Payment) async throws {
let idempotencyKey = UUID().uuidString
let request = CreatePaymentRequest(
from: payment,
idempotencyKey: idempotencyKey
)
try await networkManager.request(endpoint)
}

Code Review Checklist

Security (Block PR)

  • No API keys in code - Use environment configuration
  • Certificate pinning enabled - For production environments
  • Auth tokens in Keychain - Never in UserDefaults or Core Data
  • No sensitive data logged - Account numbers, amounts, tokens

Watch For (Request Changes)

  • Decimal for currency - Never Float/Double
  • Async/await for network - No blocking main thread
  • Idempotency keys for writes - Prevent duplicate payments
  • Error handling present - All network calls have try/catch
  • Background context for writes - Don't write on main context
  • DTOs map to domain models - Proper separation of concerns
  • Offline support - Cache data locally

Best Practices

  • Repository pattern - Abstract data sources
  • Offline-first reads - Return cached data immediately
  • Server-first writes - Write to server, then cache
  • Background sync - Sync in background, don't block UI
  • Type-safe endpoints - Enum-based endpoint definitions
  • Proper error types - Structured error handling

Further Reading

iOS Framework Guidelines

Data and API Guidelines

Language Guidelines

External Resources


Summary

Key Takeaways

  1. URLSession with async/await - Clean asynchronous networking
  2. Repository pattern - Abstract data sources for testability
  3. Offline-first - Cache first, sync in background
  4. Core Data for persistence - Background contexts for writes
  5. DTOs separate from domain - Clean data mapping layer
  6. Decimal for currency - Exact precision for financial data
  7. Idempotency keys - Prevent duplicate payment submissions
  8. Error handling - Structured, user-friendly error types
  9. Certificate pinning - Secure API communication
  10. Sync strategies - Network monitoring and background sync

Next Steps: Explore iOS Security for Keychain integration and certificate pinning implementation.