In Apple's MVC the controller mediates between model and view, but UIViewController also owns the view's lifecycle, so view code, state, networking and navigation all settle in one class that cannot be tested without a view.
Model, View, Controller: the model holds data and business rules, UIView subclasses draw and forward touches, and the controller receives events, updates the model and pushes new values back into the view.
Why it bloats: the controller is already the delegate and data source of every table, the target of every button and the place navigation starts, so each new feature lands there by default.
The way out: move presentation logic into a view model or presenter, navigation into a coordinator, data sources and networking into their own types; the controller keeps only the wiring.
iOSMiddle
iOS Architecture
MVC, MVVM, VIPER, Coordinators, TCA, Clean Swift
Questions closed
0 / 24
- 0Review
- 9Asked often
Questions
Dependency injection means an object receives its collaborators from outside instead of creating or looking them up itself. Pass them through init as protocols, and a test can hand in a stub where the app hands in the real client.
Constructor injection: the default: dependencies are visible in the signature, and the compiler refuses to build the object without them.
Method injection: pass the dependency to the one call that needs it.
Composition root: one place at the app entry (App, SceneDelegate, a root coordinator) creates the real implementations and wires the graph.
The smell it removes: NetworkManager.shared called inside a method: the test cannot replace it, so the test hits the network.
MVVM moves presentation logic and screen state out of the view into a view model that exposes ready-to-show values; the view only binds to them and forwards user events, so the logic can be unit-tested without any UI.
Roles: Model: domain data and services. ViewModel: screen state, formatting, validation, calls into services. View: renders state and sends actions.
Binding is the key part: in SwiftUI it comes for free: an @Observable view model held in @State, and Observation re-renders only the views that read a changed property. In UIKit you bind yourself, with a closure or a Combine publisher (a stored binding closure must capture self weakly, or the controller and view model keep each other alive), or, since iOS 26, rely on automatic observation tracking of @Observable properties read inside updateProperties() (or viewWillLayoutSubviews() / layoutSubviews()). Before iOS 26 use withObservationTracking or a closure.
No UIKit in the view model: expose an enum or a string, not a UIColor or a UILabel; then the type compiles in a package, on watchOS and in tests with no view hierarchy.
What it does not solve: navigation. MVVM says nothing about who opens the next screen, which is why it is paired with coordinators (MVVM-C) or a router.
Optional in SwiftUI: a screen with two @State values and a .task reads better without a view model; add one when the screen owns a real workflow (validation, paging, retries) worth testing.
Every keystroke starts its own Task and nothing cancels the old ones, so whichever response arrives last wins: a slow reply for "iph" lands after the fast one for "iphone" and overwrites results with stale data.
Why it happens: the server does not answer in request order: a short query matches more rows and is often slower, and retries or cache hits reorder replies further. Writing straight into results assumes an order nobody guarantees.
How the fix works: keep the task in searchTask, cancel it before starting the next one, and check Task.isCancelled before writing. The check matters: cancellation is cooperative, and an API that ignores it, or try? turning CancellationError into [], still returns, so without the guard a cancelled search would overwrite or clear the list.
Bonus: cancelling also stops URLSession requests nobody needs; a 300 ms debounce (try await Task.sleep(for: .milliseconds(300)) at the top of the task) turns six requests into one. SwiftUI's .task(id: query) and Combine's switchToLatest do the same cancel-previous job.
How to catch it: a unit test with a stub API whose delay depends on the query ("i" slower than "iph"): send three queries and assert the last one is shown. On fast office Wi-Fi this bug almost never appears by hand.
Choose by the shape of the link: several messages to one known counterpart is a delegate, a single result is an async call, and many unknown listeners means a notification or an observable value.
Delegate: a protocol plus a weak var delegate, for a long-lived one-to-one relationship with several messages, especially when the caller needs a value back. UIKit is built on it.
Closure: one callback defined at the call site: a cell's button action, a coordinator's onFinish. A stored closure retains what it captures, so capture lists matter.
async/await: replaces most completion closures: a result you asked for is returned by the call.
NotificationCenter: one-to-many broadcast between objects that must not know each other, such as the app entering the background. Untyped and easy to abuse, so keep it for system-wide events.
Observation, Combine, AsyncSequence: @Observable for state the UI reads; a publisher or an AsyncStream for a stream of values over time (search text, socket messages) consumed with sink or for await.
KVO: observe(\.keyPath) on an @objc dynamic property of an NSObject; today mostly for Apple's objects such as WKWebView.estimatedProgress or AVPlayer.status. Keep the returned NSKeyValueObservation, or observation stops.
Two smells give away a wrong choice: a notification used to reach exactly one known object, and a delegate protocol with a single method that would read better as a closure.
UIKit and Foundation are a catalogue of classic patterns: delegate and target-action for callbacks, observer through NotificationCenter and KVO, singletons for app-wide services, composite for the view tree and chain of responsibility for the responder chain.
Delegate: UITableViewDelegate, UITextFieldDelegate: one object customises another's behaviour through a protocol.
Target-action and command: addTarget(_:action:for:) on UIControl; UIAction wraps the handler as an object, and UndoManager records commands to reverse.
Observer: NotificationCenter, KVO, and now Observation.
Singleton: UIApplication.shared, FileManager.default, URLSession.shared, UserDefaults.standard.
Composite: UIView and its subviews: container and leaf share one interface, so layout and drawing recurse through the tree.
Chain of responsibility: the responder chain: an unhandled touch or a nil-target action travels from the first responder up through superviews and controllers to UIApplication.
Template method: viewDidLoad, layoutSubviews, draw(_:): the framework fixes the algorithm, you override the steps.
Factory and class cluster: UIFont.preferredFont(forTextStyle:), UIButton(type:); NSNumber and NSString return private subclasses.
Adapter (SwiftUI interop): UIViewRepresentable adapts a UIKit view to SwiftUI; UIHostingController goes the other way.
SOLID is five rules for keeping types small and replaceable: one reason to change, extend without editing, subtypes keep the contract, small protocols, depend on abstractions. On iOS they are mostly the argument against the massive view controller.
S (Single responsibility): a view controller that lays out views, calls the API, parses JSON and decides navigation has four reasons to change; split it into a view model, a service and a coordinator.
O (Open/closed): a switch over payment types repeated in five places must be edited for every new method; a PaymentMethod protocol with one type per method lets you add a method by adding a type.
L (Liskov substitution): a subtype must keep the parent's promises: a UITableViewCell subclass that skips super.prepareForReuse(), or a ReadOnlyCache that throws from save, breaks code written against the base type.
I (Interface segregation): UIKit splits UITableViewDataSource, UITableViewDelegate and UITableViewDataSourcePrefetching so you adopt only what you need; do the same instead of one ServiceProtocol with twenty methods.
D (Dependency inversion): a view model depends on protocol UserRepository, not on URLSession or a concrete client; the real type is plugged in at the composition root, a stub in tests.
Applied mechanically, SOLID produces a protocol per class and five files per feature; a good answer names the principle and also says where you stop.
A coordinator is an object that owns navigation for a flow: screens report events such as "login succeeded", and the coordinator decides what to show next, so view controllers never push or present each other.
Reusable screens: the same view controller works in onboarding and in settings, because it does not know what comes after it.
Flows have an owner: login, onboarding and checkout each get a coordinator; a parent starts its children and keeps them in a children array.
Deep links: one object can rebuild the whole stack for a route.
Testing: view controllers no longer need a real UINavigationController to be tested.
The usual bug: a child never removed from children when its flow ends leaks its screens.
When not: a two-screen app only pays for the ceremony. In SwiftUI the same idea is a router object that owns NavigationStack(path:); MVVM with coordinators is called MVVM-C.
The child is appended to children but never removed, so the parent keeps every finished checkout alive, together with its view controllers and draft. The closure also captures self strongly, adding a cycle through children.
Remove the child on finish: checkout.onFinish = { [weak self, weak checkout] in self?.nav.popToRootViewController(animated: true); self?.children.removeAll { $0 === checkout } }.
The cycle: AppCoordinator → children → checkout → onFinish → AppCoordinator. Harmless for an app-lifetime root, a real leak for any coordinator below it.
The path everyone forgets: the user leaves with the back button or an edge swipe, so onFinish never fires. Become the navigation controller's delegate and, in navigationController(_:didShow:animated:), check whether the from controller of transitionCoordinator is still in viewControllers; if not, the flow was popped and the child must go. A sheet swiped down needs presentationControllerDidDismiss(_:) for the same reason.
Verify: run the flow twice and check deinit logs or the Memory Graph Debugger.
In one observable router object above the navigation container, holding a typed path array that the URL handler, the notification handler and the UI all mutate.
Model routes as a Hashable enum per tab or flow and drive NavigationStack(path:) with an array of them. Appending pushes one screen; replacing the whole array opens a nested screen directly.
Deep links work reliably only when navigation is state you own, not a chain of pushes scattered across views; with one router, a cold-start link, a tapped notification and an in-app tap all land in the same path.
Parse the incoming link or notification payload into a route in exactly one place. onOpenURL and the notification delegate then only hand the route to the router.
Every screen must be reachable from a cold start, not only when it happens to already be on screen. A route the router cannot rebuild from an id is a route no deep link can reach.
In UIKit the coordinator does the same job: it owns the navigation controller, so it can pop to root and reconstruct the stack in one step.
Three things that break deep links in practice: rebuilding the stack with a sequence of timed pushes, putting a whole model object in the route so a link cannot recreate it, and having no place to park a route while the user signs in.
A repository is a protocol per domain concept that hides where data comes from (network, database, cache) and returns domain types. DTOs stay behind it, so a renamed JSON field becomes a one-file change instead of a change in three views.
Repository: the view model depends on protocol PostRepository, not on a network client; the live implementation chooses between network and cache, so a test passes a stub with no network at all.
DTO: must tolerate whatever the server does: optional fields that are "usually" present, snake_case, dates as strings, ids that change type between releases. Keep that shape out of call sites, where it spreads as if let and ??.
Unknown enum values: synthesised Decodable on a RawRepresentable enum throws on a raw value it does not know, so one new server status fails the whole response. Keep status a String in the DTO and map it with PostStatus(rawValue:) ?? .unknown.
Domain type: the shape the app wants: non-optional fields, Date, URL, enums with a default. A toDomain() on the DTO, covered by a test with a captured JSON fixture, fails loudly once at the boundary instead of scattering defensive code through views.
View state: a third per-screen type is usually unnecessary; add it when a screen composes two sources.
One model is fine when you own the backend and ship it with the app; keep CodingKeys explicit anyway.
Rule: a layer earns its cost when it absorbs change. If the DTO and the domain type stay identical forever, it is rent for nothing.
Rarely: constructor injection from a composition root is compile-checked and enough for most apps. A runtime registry such as Swinject pays off only with dozens of services and scoped lifetimes, and its price is a missing registration that fails at launch, not at build time.
A service locator such as container.resolve(AuthService.self)! turns a missing dependency into a runtime lookup. Nothing fails at build time when a feature moves to a target whose assembly was never updated, or when a resolve runs before its registration.
Constructor injection puts the same mistake in front of the compiler: a type that needs AuthService cannot be initialised without one. The single place that builds everything is the composition root (the App struct, or an AppDependencies object it owns).
SwiftUI's built-in answer is checked too: an EnvironmentKey must supply a defaultValue (the @Entry macro writes the key), so there is no unregistered case.
Factory and swift-dependencies keep the check through a declaration-time default: Factory needs a factory closure, swift-dependencies needs liveValue on every DependencyKey, and withDependencies overrides one scope in a test.
Rule: if the app can crash because a dependency is missing, it is being looked up, not injected.
Both tests use the default UserDefaults.standard, which is persisted on disk. testFinishHidesOnboarding writes onboardingDone = true, and every later run of the other test (in this run or the next) reads that leftover value instead of a fresh install.
Why it happens: the test bundle is hosted by the app, so .standard is the app's real defaults domain on that simulator and survives between tests and runs. XCTest runs a class's tests alphabetically, so testFinish… runs before testFresh….
How the fix works: the view model already takes UserDefaults through init; the tests never used that seam. Each test gets its own suite, UserDefaults(suiteName: name), and wipes it with removePersistentDomain(forName:), so it always starts from a fresh install.
The default argument is the trap: = .standard keeps call sites short but lets a test silently use the real dependency. The same goes for .shared singletons, the Keychain and files on disk: anything that outlives a test must be injected and replaced.
How to catch it: enable "Randomize execution order" in the test plan and run the suite repeatedly (xcodebuild -test-iterations 5); Swift Testing runs tests in parallel by default, which exposes shared state even faster.
Both split a screen into single-purpose objects behind protocols (VIPER into View, Interactor, Presenter, Entity and Router, Clean Swift into a one-way ViewController → Interactor → Presenter loop), buying testable seams and a uniform shape for about five files per screen.
VIPER: the Presenter sits in the middle: it takes events from the View, asks the Interactor for data (business logic over Entities), formats the result and tells the View what to show, holding it weak; the Router assembles the module and navigates.
The Clean Swift cycle: the view controller sends a Request to the Interactor; the interactor does the work and hands a Response to the Presenter; the presenter formats a ViewModel and calls a display… method on the view controller. Nobody calls backwards and each side holds the next through a protocol, so any hop can be replaced by a spy. The interactor doubles as the DataStore the next scene reads, instead of the view controller passing a payload.
The win: all formatting lives in the presenter, so numbers, strings and "3 hours ago" are testable without a view.
The cost: ceremony: a one-field screen still needs Models, Interactor, Presenter, Router, ViewController and protocols, and a new displayed field touches five files. Teams generate it from a template and stop questioning it.
Against MVVM: MVVM binds state and the view re-reads what changed; VIP pushes explicit display calls. That fits UIKit well and SwiftUI poorly, where the view is already a function of state and the push has nowhere to land.
Where it pays: large UIKit codebases with many contributors, where an identical shape per screen is worth more than brevity.
Mental model: VIP is MVP with business logic split into an Interactor and every arrow forced one way.
A feature flag is a runtime switch that decides whether code already in the binary is active, so you can merge unfinished work, roll a feature out to a share of users and turn it off remotely without waiting for App Review.
Kinds: release toggles that hide unfinished work on main, experiments (A/B tests), operational kill switches, and flags for a user cohort. They live for different times; a release toggle should die within weeks.
Source: a remote config service (Firebase Remote Config, LaunchDarkly or your own endpoint), cached on disk, with a default compiled into the app for the first offline launch.
Read once per session: evaluate at a stable point and keep the value, so a screen does not change under the user when a fetch lands mid-session.
Behind a protocol: a FeatureFlags dependency injected like any other; tests and previews pass fixed values, and a debug menu overrides flags on device.
With App Store phased release: it spreads an automatic update over 7 days (1% up to 100%) and can be paused, but it cannot switch code off; a flag can.
Never a security boundary: anything that must stay locked is checked on the server.
Debt: every flag doubles the paths to test; delete it once the feature is at 100%.
They differ in where non-view logic lives and how strictly data flows: MVC keeps it in the controller, MVP and MVVM move it into a testable object, VIPER splits it further, TCA routes every change through a reducer. Pick by team, UI framework and what must be tested.
Side by side
MVC: fastest to write; logic ends up in UIViewController and is tested only through the UI.
MVP: a UIKit-free Presenter drives a passive view through a protocol (showLoading(), show(items:)); very testable, but every UI change becomes a protocol method.
MVVM: the view binds to observable state; natural in SwiftUI with @Observable, needs a binding mechanism in UIKit, and is silent on navigation, hence MVVM-C.
VIPER / Clean Swift: one object per responsibility plus a router; a uniform shape for large UIKit teams at five or more files per screen.
TCA: unidirectional: state, actions, reducer, effects; exhaustive tests and composition, at the price of a third-party dependency and a learning curve.
How to choose
UI framework: SwiftUI favours light MVVM or TCA; VIPER's push-to-view style fights a view that is already a function of state.
Team size: a rigid template pays off with thirty engineers on one app and is ceremony for three.
Testing needs: complex flows justify a reducer or a presenter; CRUD screens rarely do.
Cost of leaving: TCA and generated VIPER modules are hard to remove; MVVM over plain Swift types is easy to migrate.
Mixing per feature is fine in a modular app, as long as navigation and dependency rules are shared; consistency matters at module boundaries, not inside every screen.
A strong answer names the trade-off for this team, not a favourite pattern.
Unidirectional data flow means state changes travel one way: the view sends an action, a reducer produces the new state, the view renders it. TCA, Point-Free's Redux-style library, builds every feature this way from State, Action, Reducer, Effect and Store.
The pieces
State: a single Equatable struct describing everything the screen shows.
Action: an enum of every event, from user taps to responses coming back.
Reducer: a function with no side effects that mutates inout state and returns an Effect; no networking happens inside it.
Effect: async work, wrapped so it can be cancelled and, in tests, replaced.
Store: holds the state, receives actions, runs the effects.
What it buys you
One source of truth per feature, and features compose into larger features through reducer combinators.
TestStore replays every action and fails the test if any state change or in-flight effect is unaccounted for: the strictest testing story of any iOS architecture.
Dependencies are declared with @Dependency and overridden per test, so there is no ambient singleton to reset between cases.
The cost is real: a sizeable library to learn and keep updated, more indirection than MVVM for a simple screen, and boilerplate that the @Reducer and @ObservableState macros reduce without removing.
Clean Architecture arranges code in rings (domain at the centre, then data and presentation) with every dependency pointing inward, so business rules know nothing about URLSession, SwiftData or SwiftUI and can be tested alone.
Domain: entities, repository protocols and use cases in plain Swift with no framework imports; everything else depends on it.
Data: repository implementations, DTOs, the API client and the store; it implements the domain's protocols, which is dependency inversion in practice.
Presentation: view models or presenters and views; they reach the domain only through protocols.
Use case (interactor): a single-purpose type such as PublishDraft that coordinates several repositories and applies the rules. It earns its place when a workflow spans repositories or is shared by two screens.
The common failure: a use case that forwards one call to one repository is noise; delete it. Five-layer diagrams for a list screen are exactly what interviewers push back on.
Let modules enforce it: put Domain in its own Swift package with no dependencies; an import SwiftUI in a use case then fails the build instead of waiting for review.
Add a layer when it removes a dependency or makes something testable that was not, never because the diagram had five boxes.
Split by feature first and by layer inside each feature: a local Swift package per feature on top of shared Core and DesignSystem modules, with a thin app target that only wires them together.
By layer or by feature: top-level Models/, Views/, ViewModels/ folders are fine for a small app but scatter one feature across the tree; feature folders keep a change in one place and become modules later.
Local SPM packages: a Package.swift per feature, referenced by path. Each module builds independently, so incremental builds parallelise and public becomes a real API boundary. This is the default today.
Xcode frameworks: the pre-SPM way. Still everywhere in older projects; slower to build and clumsier to depend on.
XCFrameworks: prebuilt binaries, for distributing an SDK or vending code you cannot ship as source.
A layout that scales
App/: entry point and composition root, and nothing else.
Features/Feed, Features/Profile, Features/Search: one module per feature, each with its own tests.
Core/Networking, Core/Persistence, Core/Logging: cross-cutting infrastructure.
DesignSystem: shared components, colours, typography.
Two rules keep it healthy: features depend on Core and DesignSystem but never on each other, and anything two features need moves down into Core rather than sideways. Cross-feature imports are how a modular project quietly turns back into one module.
The payoff is build time and blast radius: a change inside a feature recompiles that feature, and a module you can build alone is a module you can test alone.
In an object owned by the flow itself, created when the flow starts and released when it ends. A stale address means the draft outlives the flow, which is exactly what a singleton or an app-root environment object guarantees.
Per-screen state is too small: three screens must agree on one draft. App-scoped state is too large: nothing ever clears it, and "clear it on finish" is a line somebody will forget on the cancel path. The correct lifetime is the flow.
In SwiftUI the container owns it. @State private var draft = CheckoutDraft() in the view that presents step one, plus a NavigationStack(path:), and each step receives it by initialiser or through .environment(draft) applied to that subtree rather than the app root. Leaving the flow tears down the container, and the draft goes with it.
In UIKit the coordinator holds the draft, passes it to each step's view model, and is itself released by its parent on finish or cancel. CheckoutDraft.shared is the bug in one line.
Steps mutate the draft; only the last step commits it through the repository. Writing to the server at each step is how half-finished orders end up in the backend and how a resumed flow gets two of them.
Deallocation is not persistence. If losing a draft on a crash is unacceptable, save it explicitly under a flow id and delete it on both commit and cancel: resumability is a decision with a deletion path, not a side effect of a long-lived object.
The test that catches this class of bug: run the flow, cancel it, start it again, and assert the second draft is empty. It fails instantly against a singleton.
Rule: give state the shortest lifetime that still covers every reader; the owner of the state should be the object whose life matches the feature's life.
Adopt SwiftUI one whole screen at a time behind a UIHostingController, so the seam falls on a navigation edge instead of inside a view.
UIHostingController wraps a SwiftUI view as a view controller you can push, present or add as a child. A screen is the right unit because navigation, lifecycle and the coordinator all stay in UIKit.
UIViewRepresentable and UIViewControllerRepresentable go the other way, for the parts SwiftUI has no answer for: a camera preview, a mature charting view, a text view with a custom input accessory.
Share state as one object both sides observe. An @Observable store, or a store vending publishers if the codebase is Combine-based, keeps the UIKit screen and the SwiftUI screen reading one truth instead of syncing two copies.
Keep navigation in one owner. While UIKit owns the stack, a hosted SwiftUI screen should report events through a closure or the coordinator rather than opening a NavigationStack of its own.
Start with a leaf screen that has few dependencies, such as settings or a detail view, not the hub that everything else pushes onto.
Known friction: hosting controllers inside cells need self-sizing care, and UIHostingConfiguration is the supported route for list cells. Watch for double navigation bars and safe-area insets being applied twice when a hosted screen also declares its own chrome.
Refactor along the seams you are already touching: new work is written in the target architecture and old screens change only when a ticket lands in them.
Stop the bleeding: agree that new features ship as modules and that new view controllers do not gain business logic, then enforce it in review or with a lint rule.
Break the singletons. Nothing is testable while a shared instance is called inline, so introduce a protocol, keep the singleton as a default argument, and migrate call sites gradually without a big diff.
Extract infrastructure into local Swift packages before features. Networking, persistence and the design system are what every feature drags in, so they must be importable on their own first.
Strangle screen by screen: when you edit a screen, pull its logic into an injected collaborator, cover it with tests, and leave the controller as a shell.
Track a number (files remaining in the app target, incremental build time, coverage inside the new modules) so the migration cannot quietly stall halfway.
Characterisation tests come before the move, not after: snapshot tests are cheap cover for screens that have no tests at all, and they fail loudly if the extraction changes behaviour.
Layer it: a memory cache of decoded, downsampled images, a disk cache of the original bytes, and a loader that coalesces duplicate requests, decodes off the main thread and cancels work for cells that scrolled away.
Layers
Memory cache: NSCache keyed by URL plus target size, with totalCostLimit in bytes; it evicts on its own under memory pressure. Store decoded images, or every reuse pays the decode again.
Disk cache: files in Caches/ with a size cap and LRU cleanup, or a dedicated URLCache that honours Cache-Control and ETag. The system may purge Caches/, which is acceptable for re-downloadable data.
Network and prefetch: URLSession with a cap on concurrent loads; start early from UICollectionViewDataSourcePrefetching or the rows about to appear.
Details that decide performance
Downsample: a 4000×3000 photo decodes to 48 MB (width × height × 4 bytes). Use ImageIO's CGImageSourceCreateThumbnailAtIndex with kCGImageSourceThumbnailMaxPixelSize set to view size × screen scale, or preparingThumbnail(of:).
Decode off the main thread: byPreparingForDisplay() or ImageIO in a background task; a decode on the main thread is the classic scroll hitch.
Coalesce: an actor keeps a dictionary of in-flight Tasks per key, so ten cells asking for one avatar make one request.
Cancel correctly: a reused cell cancels its own wait, but the shared download stops only when nobody else waits; check the URL before assigning the result, or a reused cell shows the wrong image.
Trade-offs
Cache keys must include size and transformations, or a thumbnail and the full image overwrite each other.
Nuke, Kingfisher and SDWebImage already do all of this; the interview point is knowing why each layer exists.
Make the local database the source of truth: the UI reads and writes only locally, every change is also queued in an outbox, and a sync engine pushes the outbox and pulls server changes when it can, resolving conflicts by a rule chosen up front.
Data flow
Reads: screens observe the local store (@Query, NSFetchedResultsController, GRDB observation), so they update the same way for a local edit and for a pulled change.
Writes: save locally and append a mutation to an outbox table in the same transaction; the user never waits for the network.
Client-generated ids: a UUID per record and per mutation makes retries idempotent; the server ignores a mutation id it has already applied.
Pull: fetch changes since a server cursor or change token; deletions arrive as tombstones, not as missing rows.
Conflicts
Last writer wins by server timestamp: simple, silently loses one edit; fine for settings.
Version check: each record carries a version; the server rejects a stale write with 409, and the client rebases or asks the user.
Field-level merge: changes to different fields merge; only the same field needs a decision.
CRDTs: for collaborative text where both edits must survive, at the cost of storage and complexity.
When it runs
On launch, on foreground, after a local write, and when NWPathMonitor reports connectivity (as a hint, not a gate); simply try the request.
BGAppRefreshTask for opportunistic pulls; a background URLSession for large uploads that must finish after suspension.
For data that lives only in iCloud, CKSyncEngine or SwiftData with CloudKit implements most of this for you.