What is the repository pattern, and why map storage models to domain models?
Predict first
A storage record with an optional name is mapped to a domain value. What does the app see for a missing name?
struct UserRecord { var id: Int; var name: String?; var age: Int? }
struct User { let id: Int; let name: String; let age: Int }
extension User {
init(record: UserRecord) {
self.id = record.id
self.name = record.name ?? "Unknown"
self.age = max(0, record.age ?? 0)
}
}
let u = User(record: UserRecord(id: 1, name: nil, age: nil))
print(u.name, u.age)
- nil nil
- Unknown 0
- It does not compile
Mental model. A repository is a librarian: you ask for a book by title, and you do not care whether it is on the shelf, in the archive or borrowed from another branch. The mapping is the catalogue card, which describes the book in the way readers need, not how the warehouse labels the boxes.
A repository is an object (usually behind a protocol) that offers the rest of the app a collection-like interface to a kind of data: func user(id:) async throws -> User?, func save(_ user: User) async throws, func users(matching:) async throws -> [User]. The caller does not know whether the data comes from SQLite, Core Data, SwiftData, a file, the network or a memory cache. Its job is to hide the storage mechanism and its quirks (contexts and threads, faults, SQL, pagination, merging a remote response into the local store) and to be the single place that decides where to read from and where to write to (cache first, then network; write through; offline queue). The payoff is testability (view models are tested with an in-memory fake repository), a smaller migration blast radius (replace Core Data with SwiftData behind the protocol), and one place for caching and consistency rules.
Mapping between layers is the other half. A storage model (a Core Data NSManagedObject, a SwiftData @Model, a database record, a network DTO) mirrors the store’s shape: optional columns, raw types, relationships as faults, identity tied to a context. The domain model is a plain Swift value type shaped by what the app needs: non-optional where the app requires a value, strong types (Currency, not a string), computed properties, and no framework dependency. Converting at the boundary (init(record:) and func toRecord()) lets you apply defaults and validation once (a record with a missing name becomes “Unknown”), keeps managed objects from leaking across threads (value types are Sendable by default), and means a schema change touches the mapping, not every screen. The cost is extra types and copies, so apply it where the layers genuinely differ; for a tiny app a SwiftData model used directly is fine. Do not build a generic repository over everything (one Repository<T> that exposes the ORM’s API); make repositories as specific as the use cases, and keep them thin: business rules belong in services or the domain.
The mapping applies the defaults once, so the domain type never sees an optional.
Common traps
- Letting managed objects or
@Modelinstances flow through the whole app. They are tied to a context and thread; map to value types at the boundary. - A generic
Repository<T>that re-exposes the ORM. It leaks the storage API and removes the benefit.
They might follow up with
- Where do defaults and validation go? In the mapping from a record to the domain model, once.
- When is the extra layer not worth it? A small app with one screen and one store, where the framework model is already the domain model.
Related questions
- 13.5.2 How do you design a cache: memory and disk layers, TTL, eviction, and what to key it by?
- 13.1.1 UserDefaults, Keychain, files, SQLite, Core Data or SwiftData: how do you choose, and what survives deletion…
- 13.3.2 How do you use Core Data across threads: contexts, perform, merging changes and merge policies?
How do you design a cache: memory and disk layers, TTL, eviction, and… →