SwiftUI Architecture & iOS Data Layer Design
Architecture patterns from production iOS apps: clean layer separation in SwiftUI, local-first data design, Core Data vs SwiftData trade-offs, offline-first architecture, and CloudKit sync that works when Apple's documentation doesn't cover the edge cases.
By Ehsan Azish · 3NSOFTS · March 2026
Architecture decisions in iOS apps compound. A view that owns persistence logic is unmaintainable at 10,000 lines. A data layer designed for always-online fails silently in a subway tunnel. These patterns come from building and maintaining production apps — not from university coursework or toy examples.
SwiftUI layer separation
IntermediateThe single most important structural decision in a SwiftUI app: separating the view layer from the domain layer from the data layer. Breaking this produces apps that are impossible to test and brittle to change.
// ❌ What tutorials show — everything in the view
struct BadView: View {
@Environment(\.modelContext) private var context // data access in view
var body: some View {
Button("Save") {
let item = Item(name: "Example") // business logic in view
context.insert(item) // persistence in view
}
}
}
// ✅ What production apps look like — three clean layers
// Layer 1: Domain model — pure Swift, no UI, no persistence imports
struct Item: Identifiable, Equatable {
let id: UUID
var name: String
var createdAt: Date
}
// Layer 2: Repository — all persistence knowledge lives here
actor ItemRepository {
private let container: ModelContainer
func save(_ item: Item) async throws {
let context = ModelContext(container)
let entity = ItemEntity(item: item)
context.insert(entity)
try context.save()
}
func all() async throws -> [Item] {
let context = ModelContext(container)
return try context.fetch(FetchDescriptor<ItemEntity>())
.map { $0.domainModel }
}
}
// Layer 3: ViewModel — translates between domain and UI
@MainActor @Observable
class ItemListViewModel {
private(set) var items: [Item] = []
private let repo: ItemRepository
func load() async { items = (try? await repo.all()) ?? [] }
func create(name: String) async { try? await repo.save(Item(id: .init(), name: name, createdAt: .now)) }
}- ▸The view should only call ViewModel methods — never touch the data layer directly.
- ▸The repository knows everything about persistence; domain models know nothing about it.
- ▸This structure lets you test the ViewModel with a mock repository that never touches disk.
Local-first data design
IntermediateLocal-first means all reads and writes go to a local store first, with sync to a server/cloud as a background concern. The app works identically whether the device is online or offline. This requires designing the data model before designing the sync strategy.
// Local-first with SwiftData + NSPersistentCloudKitContainer sync
// The app always reads from the local store — never waits for network
@Model
final class Entry {
var id: UUID = UUID()
var content: String = ""
var modifiedAt: Date = Date.now
var syncState: SyncState = .pending // track sync separately from data
enum SyncState: Int, Codable {
case synced, pending, conflict
}
}
// NSPersistentCloudKitContainer syncs in the background
// Your app code never awaits a network call for a read or write
let container = try ModelContainer(
for: Entry.self,
configurations: ModelConfiguration(cloudKitDatabase: .private("iCloud.com.yourapp.db"))
)
// Writes are always local-first — CloudKit sync happens asynchronously
@MainActor
func create(content: String, context: ModelContext) {
let entry = Entry(content: content)
context.insert(entry)
try? context.save()
// CloudKit sync happens automatically in the background
// No awaiting, no suspense, no spinners on create
}- ▸Design for last-write-wins conflict resolution unless your data model requires something more complex.
- ▸NSPersistentCloudKitContainer uses CKModifyRecordsOperation internally with .changedKeys merge policy.
- ▸Test offline behavior by disabling Wi-Fi and cellular in Settings, not just in the simulator.
Core Data vs SwiftData: When to Use Each
IntermediateSwiftData is not a replacement for Core Data in all cases. The two frameworks have meaningfully different capabilities as of iOS 17–18. The decision depends on your iOS version floor, migration complexity, and whether you need Core Data's more mature NSFetchedResultsController.
// SwiftData (iOS 17+) — preferred for new apps
@Model
final class Note {
var title: String
var body: String
var tags: [String]
init(title: String, body: String) {
self.title = title
self.body = body
self.tags = []
}
}
// @Query in SwiftUI — automatic live updates
struct NoteListView: View {
@Query(sort: \.title) private var notes: [Note]
@Environment(\.modelContext) private var context
var body: some View {
List(notes) { note in
Text(note.title)
}
}
}
// Core Data (iOS 15+) — when you need:
// - Existing migration history to preserve
// - NSFetchedResultsController for section control
// - Finer-grained conflict policies
// - iOS 16 or lower deployment target
//
// NSFetchRequest<Note> + NSFetchedResultsController
// gives you section-by-section batch updates that @Query
// does not expose in iOS 17/18- ▸SwiftData in iOS 17 has rough edges: composite predicates, section queries, and some migration paths have bugs.
- ▸Core Data is stable and production-proven. Prefer it for existing apps and complex migration scenarios.
- ▸SwiftData iOS 18 is significantly more stable — acceptable for new apps targeting iOS 18+.
- ▸See /insights/swiftdata-vs-coredata-2026 for a full decision matrix.
NSPersistentCloudKitContainer: The Non-Obvious Parts
AdvancedNSPersistentCloudKitContainer abstracts CloudKit sync but exposes several behaviors that Apple's documentation understates. These are the production patterns for handling sync state, conflict detection, and debugging.
// Listen for remote change notifications
NotificationCenter.default.addObserver(
forName: NSPersistentCloudKitContainer.eventChangedNotification,
object: container,
queue: .main
) { notification in
guard let event = notification.userInfo?[
NSPersistentCloudKitContainer.eventNotificationUserInfoKey
] as? NSPersistentCloudKitContainer.Event else { return }
switch event.type {
case .setup:
print("CloudKit account setup: \(event.succeeded ? "OK" : "FAILED")")
case .import:
// Remote changes landed — fetch updated objects
updateFetchedObjects()
case .export:
// Local writes pushed to CloudKit
break
@unknown default:
break
}
}
// Check CloudKit account status before assuming sync will work
CKContainer.default().accountStatus { status, error in
switch status {
case .available:
// Sync will work
case .noAccount, .restricted, .couldNotDetermine:
// Show "sync unavailable" UI — app still works locally
case .temporarilyUnavailable:
// Retry after a delay
}
}- ▸NSPersistentCloudKitContainer does not work in the simulator without a real iCloud account configured.
- ▸CloudKit private database requires the user to be signed into iCloud on the device.
- ▸NSMergeByPropertyObjectTrumpMergePolicy is the correct merge policy for most CloudKit scenarios.
- ▸See /insights/nspersistentcloudkitcontainer-production-guide for the full guide including debugging workflow.