B Blueprint

Architecture

Repositories & Services

How I hid Geoapify and SwiftData behind Repositories and Services.

How I talked to APIs and device frameworks from the Data layer.

The Data layer talks to Geoapify, CoreLocation, disk cache, SwiftData. I used two shapes: Repositories (give me domain objects) and Services (wrap a capability).

Repository

A Repository answers: give me POI (or similar), I do not care if it came from HTTP or disk.

Repository Source
POIRepository Geoapify + file cache
PlaceDetailsRepository Geoapify details endpoint
GeocodingRepository Geoapify geocoding
FavoritesRepository SwiftData

Each one has a protocol (what UseCases see) and a class (what Data implements).

protocol POIRepositoryProtocol {
  func fetchNearby(lat: Double, lon: Double, limit: Int, offset: Int) async throws -> [POI]
}

final class POIRepository: POIRepositoryProtocol {
  func fetchNearby(...) async throws -> [POI] {
    let data = try await client.data(for: request)
    let dto = try JSONDecoder().decode(GeoapifyResponseDTO.self, from: data)
    return dto.features.compactMap(GeoapifyMapper.map)
  }
}

FetchNearbyPOIsUseCase holds POIRepositoryProtocol. DI passes POIRepository() at runtime; the UseCase never imports that class.

Inside POIRepository: build URL, call NetworkClient, decode DTO, map to POI, maybe cache. UseCases do not see those steps.

What I did

One repository per data source / API surface. Protocol at the boundary, DTO + mapper inside the class.

Why (then)

I had duplicated URL-building instincts in ViewModels on past projects. Moving Geoapify shape into Repositories meant one place to fix when the API changed.

What I’d reconsider

Three repositories repeat similar URL/request code. I would extract a GeoapifyRequestBuilder or endpoint enum before adding a fourth endpoint. See Networking.

Service

A Service wraps infrastructure that is not “fetch this entity from source X.”

Service Role
LocationService CoreLocation permissions + coordinates
POICacheService Disk cache actor (used inside POIRepository)
FeatureFlagService Toggle map, favorites, filters

HomeViewModel uses LocationServiceProtocol directly because current location is device infrastructure, not a Geoapify fetch.

What I did

Services for cross-cutting capabilities. Cache stays internal to POIRepository instead of exposing a CacheRepository.

Why (then)

Location did not fit “return [POI] from named source.” Feature flags are toggles, not entities. Keeping cache inside the POI repository avoided an extra protocol for one consumer.

What I’d reconsider

The line between Repository and Service is fuzzy. FavoritesRepository is CRUD-like; LocationService is not. On another app I might wrap location in a UseCase for symmetry. Here I was pragmatic.

How I tell them apart (in this repo)

Question I used a…
UseCase returns domain entities from a named source (API, DB)? Repository
Shared Apple framework behavior, no single entity? Service
Helper only one repository needs? Private to that repository (cache)

Not a universal rule. A naming guide for Discover.

DTOs stay in Data

Geoapify returns nested JSON (GeoapifyResponseDTO). Repositories decode and map to POI. Domain never imports properties.name.

Could the ViewModel call Geoapify directly?

Yes, with URLSession in the View. I did that in early prototypes elsewhere. Here I wanted UseCase tests to mock POIRepositoryProtocol instead of stubbing HTTP in every ViewModel test.

Could POI decode Geoapify JSON directly?

Yes, if POI were Codable against the API shape. I split DTO and entity to practice changing APIs without touching Domain. More files, clearer boundary.

Read next