| name | repository |
| description | Creates Repositories that abstract data access. Use when creating repositories, transforming DTOs to domain models, or implementing local-first caching. Supports remote-only, local-only, and cached (remote + local) repositories with CachePolicy. |
Skill: Repository
Guide for creating Repositories that abstract data access following Clean Architecture.
References
Each reference contains full templates: contract, implementation, mock, and tests.
Scope & Boundaries
Important: The /repository skill is responsible only for Domain and Data layer files (models, errors, contracts, mappers, repositories, mocks, stubs, tests). It does NOT modify Feature entry points ({Feature}Feature.swift), Containers ({Feature}Container.swift), AppContainer, or Tuist modules. Any changes to these files must be delegated to the /feature skill, which owns the wiring of dependencies into the feature.
Workflow
Step 1 — Identify Existing DataSources
Before creating a Repository, scan the feature's Sources/Data/DataSources/ directory to discover what already exists.
- DataSources found? → Go to Step 2
- No DataSources found? → Ask the user which DataSource to create first, then invoke the
/datasource skill. Return here after completion.
Step 2 — Select Target DataSource
Present the discovered DataSources to the user and ask:
"Which DataSource(s) should this Repository use?"
Possible combinations:
| Scenario | DataSources | Next step |
|---|
| Remote only | RemoteDataSource only | → Step 3a |
| Local only (memory) | MemoryDataSource only | → Step 3b |
| Local only (persistent) | LocalDataSource (UserDefaults) only | → Step 3b |
| Both (cached) | RemoteDataSource + MemoryDataSource | → Step 3c |
If the user wants a cached repository but no local DataSource exists:
"There is no local DataSource for caching. Do you want to create one using the /datasource skill?"
If yes → invoke /datasource to create the Memory DataSource, then return to Step 3c.
Step 3a — Remote Only Repository
Implement using references/remote-only.md.
Checklist:
Step 3b — Local Only Repository
Implement using references/local-only.md.
Checklist:
Step 3c — Cached Repository (Remote + Local)
Ask the user which cache policy to apply:
"Which cache policy should this Repository use?"
- localFirst — Cache first, remote if cache miss. Saves remote result to cache.
- remoteFirst — Remote first, cache as fallback on error. Saves remote result to cache.
- noCache — Remote only, no cache interaction.
- All (configurable) — Accept
CachePolicy parameter, implement all three strategies.
For most repositories, "All (configurable)" is recommended — callers decide the policy per request.
Checklist (for All configurable — adapt for single-policy variants):
File Structure
Features/{Feature}/
├── Sources/
│ ├── Domain/
│ │ ├── Errors/
│ │ │ └── {Feature}Error.swift # Domain error (typed throws)
│ │ ├── Models/
│ │ │ └── {Name}.swift # Domain model
│ │ └── Repositories/
│ │ └── {Name}RepositoryContract.swift # Contract (protocol)
│ └── Data/
│ ├── DataSources/ # See /datasource skill
│ ├── DTOs/ # See /datasource skill
│ ├── Mappers/
│ │ ├── {Name}Mapper.swift # DTO → Domain mapping
│ │ └── {Name}ErrorMapper.swift # APIError → Domain error
│ └── Repositories/
│ └── {Name}Repository.swift # Implementation
└── Tests/
├── Unit/
│ ├── Data/
│ │ ├── Repositories/
│ │ │ └── {Name}RepositoryTests.swift
│ │ └── Mappers/
│ │ └── {Name}ErrorMapperTests.swift
│ └── Domain/
│ └── Errors/
│ └── {Feature}ErrorTests.swift
└── Shared/
└── Mocks/
└── {Name}RepositoryMock.swift
Patterns
Domain Model (Rich, not Anemic)
Domain models should have behavior intrinsic to the concept they represent (unlike DTOs, which are intentionally anemic):
nonisolated struct {Name}: Equatable {
let id: Int
let name: String
let items: [Item]
static func empty() -> {Name} {
{Name}(id: 0, name: "", items: [])
}
var totalValue: Decimal {
items.reduce(0) { $0 + $1.price }
}
}
Rules: Domain/Models/, internal visibility, nonisolated, Equatable, let properties, may include factory methods / computed properties / business rules. No persistence, presentation, or serialization logic. Public types crossing module boundaries need explicit Sendable.
Contract (Protocol)
import ChallengeCore
nonisolated protocol {Name}RepositoryContract: Sendable {
@concurrent func get{Name}(identifier: Int, cachePolicy: CachePolicy) async throws({Feature}Error) -> {Name}
}
Rules: Domain/Repositories/, nonisolated protocol, Contract suffix, internal, Sendable, @concurrent on methods, return Domain models (not DTOs), typed throws. Include cachePolicy: CachePolicy for cached repos.
Naming: get{Name} (singular), get{Name}sPage (paginated list), search{Name}sPage (search). Separate contracts per ISP when concerns differ.
DTO → Domain Mapping
import ChallengeCore
nonisolated struct {Name}Mapper: MapperContract {
func map(_ input: {Name}DTO) -> {Name} {
{Name}(id: input.id, name: input.name)
}
}
Pure stateless nonisolated structs, MapperContract from ChallengeCore. Used as concrete types in repositories (not injected). Composable: mappers can delegate to other mappers.
Error Mapping
import ChallengeCore
import ChallengeNetworking
nonisolated struct {Name}ErrorMapperInput {
let error: any Error
let identifier: Int
}
nonisolated struct {Name}ErrorMapper: MapperContract {
func map(_ input: {Name}ErrorMapperInput) -> {Feature}Error {
guard let apiError = input.error as? APIError else {
return .loadFailed(description: String(describing: input.error))
}
return switch apiError {
case .notFound:
.notFound(identifier: input.identifier)
case .invalidRequest, .invalidResponse, .serverError, .decodingFailed:
.loadFailed(description: String(describing: apiError))
}
}
}
Domain Error
nonisolated public enum {Feature}Error: Error, Equatable, LocalizedError {
case loadFailed(description: String = "")
case notFound(identifier: Int)
public static func == (lhs: {Feature}Error, rhs: {Feature}Error) -> Bool {
switch (lhs, rhs) {
case (.loadFailed, .loadFailed): true
case let (.notFound(lhsId), .notFound(rhsId)): lhsId == rhsId
default: false
}
}
public var errorDescription: String? {
switch self {
case .loadFailed:
"{feature}Error.loadFailed".localized()
case .notFound(let identifier):
"{feature}Error.notFound %lld".localized(identifier)
}
}
}
nonisolated extension {Feature}Error: CustomDebugStringConvertible {
public var debugDescription: String {
switch self {
case .loadFailed(let description): description
case .notFound(let identifier): "notFound(identifier: \(identifier))"
}
}
}
Rules: Public, Error + Equatable + LocalizedError, custom == ignores description, CustomDebugStringConvertible for tracker, localized strings from Resources.
CachePolicy (Strategy Pattern)
Defined in ChallengeCore, shared across features. The enum carries its own fetch behavior — no separate executor type:
nonisolated public enum CachePolicy {
case localFirst
case remoteFirst
case noCache
public func fetch<Value>(
fromRemote: sending () async throws -> Value,
fromCache: sending () async -> Value?,
saveToCache: sending (Value) async -> Void
) async throws -> Value
}
fetch returns the raw Value and propagates the original transport error untyped. Repositories wrap the call in a do-catch and handle mapping (DTO → Domain) and error mapping themselves:
@concurrent func get{Name}(identifier: Int, cachePolicy: CachePolicy) async throws({Feature}Error) -> {Name} {
do {
let dto = try await cachePolicy.fetch(
fromRemote: { try await remoteDataSource.fetch{Name}(...) },
fromCache: { await memoryDataSource.get{Name}(...) },
saveToCache: { await memoryDataSource.save{Name}($0) }
)
return mapper.map(dto)
} catch {
throw errorMapper.map({Name}ErrorMapperInput(error: error, ...))
}
}
This separation respects SRP: CachePolicy only coordinates cache, mapping is the Repository's responsibility.
Note: Private helpers in the CachePolicy extension are static (not instance methods) — passing self along with sending closures triggers a region-based isolation error. The extension must be nonisolated (does NOT propagate from the type).
Cache strategy logic is tested in CachePolicyTests calling CachePolicy.localFirst.fetch(...) directly. Repository tests only verify wiring, cache wiring, and error mapping.
Visibility Summary
| Component | Visibility | Location |
|---|
| Domain Model | internal | Sources/Domain/Models/ |
| Domain Error | public | Sources/Domain/Errors/ |
| Contract | internal | Sources/Domain/Repositories/ |
| Implementation | internal | Sources/Data/Repositories/ |
| Mapper | internal | Sources/Data/Mappers/ |
| Error Mapper | internal | Sources/Data/Mappers/ |
| Mock | internal | Tests/Shared/Mocks/ |