One home for each kind of data
A library’s front desk is a repository. You ask for “the latest books by Ursula K. Le Guin”, and the librarian brings them; you don’t know whether they came from the shelf, the archive in the basement, or another branch. If the library reorganizes its basement, your request stays the same.
A repository is that desk for your app’s data. It’s an interface with a few methods that speak the app’s language (order(id:), recentOrders(for:), save(_:)) and return the app’s own types. Behind the interface, one class talks to the database or the server. Screens and view models only ever see the interface.
A Repository (Fowler, DDD) mediates between the domain and data mapping layers with a collection-like interface for aggregates: lookups by identity, intention-revealing finders, add/save and remove. It returns domain objects and accepts domain criteria, so storage concerns (SQL, ORM entities, HTTP DTOs, pagination tokens, caching) stay behind it. In mobile architectures (Android’s guide, most iOS apps), the repository is the data layer’s public API and the single source of truth coordinator between remote and local data sources. Its interface belongs to the domain/application side; implementations live in the data layer, which is what makes fakes and storage changes cheap.
struct Order: Identifiable, Equatable {
let id: String
let customerId: String
let total: Money
let placedAt: Date
var status: OrderStatus
}
protocol OrderRepository {
func order(id: String) async throws -> Order?
func recentOrders(for customerId: String, limit: Int) async throws -> [Order]
func save(_ order: Order) async throws
}
final class RemoteOrderRepository: OrderRepository {
private let api: OrdersAPI
init(api: OrdersAPI) { self.api = api }
func order(id: String) async throws -> Order? {
try await api.order(id: id).map { $0.toDomain() } // DTO → domain model
}
func recentOrders(for customerId: String, limit: Int) async throws -> [Order] {
try await api.orders(customerId: customerId, limit: limit, sort: "-placed_at")
.map { $0.toDomain() } // the server filters and sorts
}
func save(_ order: Order) async throws {
try await api.put(OrderDTO(order))
}
}data class Order(
val id: String,
val customerId: String,
val total: Money,
val placedAt: Instant,
val status: OrderStatus,
)
interface OrderRepository {
suspend fun order(id: String): Order?
suspend fun recentOrders(customerId: String, limit: Int): List<Order>
suspend fun save(order: Order)
}
class RoomOrderRepository(private val dao: OrderDao) : OrderRepository {
override suspend fun order(id: String): Order? = dao.byId(id)?.toDomain() // entity → domain model
override suspend fun recentOrders(customerId: String, limit: Int): List<Order> =
dao.recent(customerId, limit).map { it.toDomain() } // SQL filters, sorts and limits
override suspend fun save(order: Order) = dao.upsert(order.toEntity())
}public record Order(String id, String customerId, Money total, Instant placedAt, OrderStatus status) {}
public interface OrderRepository {
Optional<Order> order(String id);
List<Order> recentOrders(String customerId, int limit);
void save(Order order);
}
public final class JdbcOrderRepository implements OrderRepository {
private final JdbcClient jdbc;
public JdbcOrderRepository(JdbcClient jdbc) { this.jdbc = jdbc; }
public Optional<Order> order(String id) {
return jdbc.sql("SELECT * FROM orders WHERE id = :id").param("id", id)
.query(OrderRow.class).optional().map(OrderRow::toDomain); // row → domain model
}
public List<Order> recentOrders(String customerId, int limit) {
return jdbc.sql("""
SELECT * FROM orders WHERE customer_id = :c ORDER BY placed_at DESC LIMIT :n""")
.param("c", customerId).param("n", limit)
.query(OrderRow.class).list().stream().map(OrderRow::toDomain).toList();
}
public void save(Order order) { /* INSERT … ON CONFLICT (id) DO UPDATE … */ }
}interface Order {
id: string;
customerId: string;
total: Money;
placedAt: Date;
status: OrderStatus;
}
interface OrderRepository {
order(id: string): Promise<Order | undefined>;
recentOrders(customerId: string, limit: number): Promise<Order[]>;
save(order: Order): Promise<void>;
}
class HttpOrderRepository implements OrderRepository {
constructor(private readonly http: HttpClient) {}
async order(id: string): Promise<Order | undefined> {
const dto = await this.http.getOrNull<OrderDto>(`/orders/${id}`);
return dto && toDomain(dto); // DTO → domain model
}
async recentOrders(customerId: string, limit: number): Promise<Order[]> {
const dtos = await this.http.get<OrderDto[]>(`/customers/${customerId}/orders?limit=${limit}&sort=-placedAt`);
return dtos.map(toDomain); // the server filters and sorts
}
async save(order: Order): Promise<void> {
await this.http.put(`/orders/${order.id}`, toDto(order));
}
}- The interface speaks the app’s language:
recentOrders(for:limit:), notselect(query:). A reader knows what it does without knowing SQL. - Domain models out, storage formats in: the database row or JSON is converted to
Orderinside the repository. Screens never seeOrderDTOorOrderEntity, so renaming a column or a JSON field changes one mapper. - Filtering happens where the data lives: the database or server returns the 20 recent orders, not all 5,000.
- Each platform example uses a different storage (server, Room, JDBC, HTTP): the interface is the same idea in all of them.
Tap the line with the bug.
@MainActor @Observable
final class OrderHistoryModel {
private(set) var orders: [Order] = []
private let repository: OrderRepository
init(repository: OrderRepository) { self.repository = repository }
func load(customerId: String) async throws {
orders = try await repository.allOrders()
.filter { $0.customerId == customerId }
.sorted { $0.placedAt > $1.placedAt }
.prefix(20).map { $0 }
}
}class OrderHistoryViewModel(private val repository: OrderRepository) : ViewModel() {
val orders = MutableStateFlow<List<Order>>(emptyList())
fun load(customerId: String) = viewModelScope.launch {
orders.value = repository.allOrders()
.filter { it.customerId == customerId }
.sortedByDescending { it.placedAt }
.take(20)
}
}public final class OrderHistoryService {
private final OrderRepository repository;
public OrderHistoryService(OrderRepository repository) { this.repository = repository; }
public List<Order> recent(String customerId) {
return repository.findAll().stream()
.filter(o -> o.customerId().equals(customerId))
.sorted(Comparator.comparing(Order::placedAt).reversed())
.limit(20)
.toList();
}
}class OrderHistoryModel {
orders: Order[] = [];
constructor(private readonly repository: OrderRepository) {}
async load(customerId: string): Promise<void> {
const all = await this.repository.allOrders();
this.orders = all
.filter((o) => o.customerId === customerId)
.sort((a, b) => b.placedAt.getTime() - a.placedAt.getTime())
.slice(0, 20);
}
}Hint: The orders table has 2 million rows from every customer. What does allOrders() load to show 20 of them?
The repository has only a generic allOrders(), so the view model loads every order of every customer, converts them all to domain objects, and throws away all but 20. With a few test orders it’s instant; in production it’s seconds of network or disk, a spike in memory, and on a server, one request that can bring the service down. It also means the caller now knows about storage-sized data, which a repository was supposed to hide.
Add a query method named after the need, and let the storage filter, sort and limit:
orders = try await repository.recentOrders(for: customerId, limit: 20)orders.value = repository.recentOrders(customerId, limit = 20)return repository.recentOrders(customerId, 20);this.orders = await this.repository.recentOrders(customerId, 20);A few well-named methods per repository are better than a generic findAll plus filtering everywhere, or a “query anything” method that leaks the storage’s query language into the app.
Predict the output
A view model test passes an InMemoryOrderRepository (a fake holding orders in a dictionary) instead of the real one. The test loads recent orders three times. How many network requests does it make?
None. The view model only knows the OrderRepository interface; the fake answers from memory. That's why the test runs in milliseconds, works on a plane, and never fails because a server is down. The real repository's own behavior (SQL, mapping, HTTP) is tested separately, against a real database or a recorded server response.
Design a repository
A recipes app shows: a list of recipes in a category, sorted by rating; a recipe’s detail; the user’s favorites; and lets the user favorite or unfavorite a recipe. Write the RecipeRepository interface. What do the methods return?
›
One method per need, named after it, returning domain models. Favoriting changes data, so it’s a method too. Think about which calls might need paging.
SolutionTry it yourself first
protocol RecipeRepository {
func recipes(in category: Category, page: Int) async throws -> [RecipeSummary]
func recipe(id: Recipe.ID) async throws -> Recipe?
func favorites() async throws -> [RecipeSummary]
func setFavorite(_ isFavorite: Bool, recipeId: Recipe.ID) async throws
}interface RecipeRepository {
suspend fun recipes(category: Category, page: Int): List<RecipeSummary>
suspend fun recipe(id: RecipeId): Recipe?
fun favorites(): Flow<List<RecipeSummary>> // observed: updates when favorites change
suspend fun setFavorite(recipeId: RecipeId, isFavorite: Boolean)
}public interface RecipeRepository {
List<RecipeSummary> recipes(Category category, int page);
Optional<Recipe> recipe(RecipeId id);
List<RecipeSummary> favorites();
void setFavorite(RecipeId recipeId, boolean isFavorite);
}interface RecipeRepository {
recipes(category: Category, page: number): Promise<RecipeSummary[]>;
recipe(id: string): Promise<Recipe | undefined>;
favorites(): Promise<RecipeSummary[]>;
setFavorite(recipeId: string, isFavorite: boolean): Promise<void>;
}Notes: lists return a lighter RecipeSummary (title, image, rating) while the detail returns the full Recipe, so the list doesn’t download every ingredient. Sorting by rating is part of what recipes(in:page:) means, so it isn’t a parameter. And nothing in the interface says “SQL”, “HTTP” or “cache”.
Generic repositories and query objects
A generic Repository<T, ID> with findAll, findById, save is fine as a base (Spring Data’s CrudRepository is one), but it’s not the interface the domain needs; add intention-revealing finders on top. Exposing the storage’s query language (IQueryable, JPA Specifications built in controllers, raw predicates) makes callers depend on storage again, and in-memory predicates (find(where: (Order) -> Boolean)) silently load everything. When the number of finders explodes, consider a small query object or specification type owned by the domain, translated by the repository, or split read models (CQRS) from the aggregate repositories.
- Collection-like interface per aggregate: identity lookup, intention-revealing finders, save/remove; returns domain models.
- Interface owned by domain/application layer; implementations in the data layer (SQL, ORM, HTTP, cache).
- Push filtering, sorting and paging into storage; no
findAll+ in-memory filtering, no leaked query languages. - Generic CRUD bases are fine underneath; add domain finders, query objects or read models as needs grow.
Check this part
What should a repository return to a view model?
A screen needs a customer's 20 latest orders. Where should the filtering happen?
What's wrong with a repository method find(where: (Order) -> Boolean)?