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.