Skip to content
Commitwise
Menu
Design Patterns

Repository

Hide where data lives behind a small, collection-like interface that speaks your domain's language, keep queries in the repository instead of filtering in view models, combine network and local storage into one source of truth, and test with fakes that behave like the real thing, in Swift, Kotlin, Java and TypeScript.

beginner

The core idea with one simple example.Step by step, with more examples and exercises.A cheat sheet: key APIs, gotchas and trade-offs.Internals, trade-offs and production pitfalls.

The order history screen builds a SQL query in its view model. The order detail screen calls the REST API directly and parses the JSON itself. The checkout reads the last address from local storage with a hard-coded key. When the team adds offline support, they have to find every place that touches orders, and there are fourteen.

A Repository gives each kind of data one home: an OrderRepository with methods like order(id), recentOrders(for:) and save(order). Screens ask the repository; only the repository knows whether the answer comes from a database, a server, a file or a cache.

New to some of these? Open what you need, or skip ahead.

Basics:What is an interface (or protocol)?

An interface (Kotlin, Java, TypeScript) or protocol (Swift) is a list of things a type promises it can do, without saying how. Code that only knows the interface can use any class that implements it: the real repository in the app, and an in-memory fake in tests.

Basics:What is a domain model?

A domain model is a type that describes something your app is about, in your own words: Order, Customer, Money. It’s different from the shape data has in storage (a database row, a JSON response), which often has other names, extra fields or missing ones. Keeping the two apart means the database or the API can change without every screen changing with it.

Part Not started

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:), not select(query:). A reader knows what it does without knowing SQL.
  • Domain models out, storage formats in: the database row or JSON is converted to Order inside the repository. Screens never see OrderDTO or OrderEntity, 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.
Spot the bugA slow order history

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);
  }
}

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?

Exerciseeasy

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

  1. What should a repository return to a view model?

  2. A screen needs a customer's 20 latest orders. Where should the filtering happen?

  3. What's wrong with a repository method find(where: (Order) -> Boolean)?

A repository is an interface shaped like a collection of domain objects: get by id, find by meaningful criteria, save, delete. It returns domain models, never database rows or JSON DTOs, and its query methods are named after what the app needs, so filtering happens in storage, not in view models.

Part Not started

Combining network and local storage

A mobile app usually has data in two places: on the server and in a local database or cache. Deciding which one to use (show what’s cached, then refresh from the network; save locally, then upload) is exactly the kind of decision that shouldn’t be repeated in every screen. The repository makes it once.

The repository coordinates data sources (remote API, local database, in-memory cache), each a thin class with no policy of its own. Policies (cache-then-network, network-first with fallback, local-first with background sync) live in the repository, which exposes streams (Flow, AsyncSequence, observables) from the local source as the single source of truth and refreshes it from the remote. Writes go local first with sync state, then upstream through a queue. See Room database and Mobile: offline-first and sync for the full design.

final class ArticlesRepository {
    private let remote: ArticlesAPI
    private let local: ArticlesStore               // SwiftData, SQLite, files…

    init(remote: ArticlesAPI, local: ArticlesStore) { self.remote = remote; self.local = local }

    /// Cached articles immediately, fresh ones after the refresh.
    func articles() -> AsyncStream<[Article]> { local.observeAll() }

    func refresh() async throws {
        let fresh = try await remote.latest().map { $0.toDomain() }
        try await local.replaceAll(with: fresh)    // observers get the new list
    }
}
class ArticlesRepository(
    private val remote: ArticlesApi,
    private val local: ArticleDao,
) {
    /** Cached articles immediately, fresh ones after the refresh. */
    fun articles(): Flow<List<Article>> = local.observeAll().map { rows -> rows.map { it.toDomain() } }

    suspend fun refresh() {
        val fresh = remote.latest().map { it.toEntity() }
        local.replaceAll(fresh)                    // in one transaction; observers get the new list
    }
}
public final class ArticlesRepository {
    private final ArticlesApi remote;
    private final ArticleStore local;

    public ArticlesRepository(ArticlesApi remote, ArticleStore local) { this.remote = remote; this.local = local; }

    /** Cached articles immediately, fresh ones after the refresh. */
    public Flow.Publisher<List<Article>> articles() { return local.observeAll(); }

    public void refresh() {
        List<Article> fresh = remote.latest().stream().map(ArticleDto::toDomain).toList();
        local.replaceAll(fresh);                   // subscribers get the new list
    }
}
class ArticlesRepository {
  constructor(private readonly remote: ArticlesApi, private readonly local: ArticleStore) {}

  /** Cached articles immediately, fresh ones after the refresh. */
  articles(): Observable<Article[]> { return this.local.observeAll(); }

  async refresh(): Promise<void> {
    const fresh = (await this.remote.latest()).map(toDomain);
    await this.local.replaceAll(fresh);          // subscribers get the new list
  }
}
  • Screens observe articles() and see the cached list instantly, even offline.
  • refresh() fetches from the server and writes into local storage; the screen updates because it observes local storage, not because it was told to.
  • If the refresh fails, the cached list stays on screen. The screen can show “Couldn’t update” without losing anything.
  • Repository = policy over thin data sources (remote, local, memory).
  • Expose observable local data as the single source of truth; refresh writes into it; writes go local first and sync via a queue.
  • Screens never coordinate sources or caching themselves.

Check this part

  1. In an offline-first repository, where does the screen's data come from?

  2. Where does a cache-then-network policy belong?

On mobile, a repository combines a remote and a local data source and decides which one answers: cached data first, network refresh in the background, writes to the local store and synced later. Screens observe the repository and never coordinate sources themselves.

Part Not started

Fakes that tell the truth

An in-memory fake repository (a dictionary behind the same interface) lets you test view models and use cases instantly, with exactly the data each test needs. But a fake that behaves differently from the real thing gives you tests that pass while the app fails. The fix is to write the important rules down as tests and run them against both.

In-memory fakes are preferable to mocks for repositories: they hold state, so tests assert outcomes rather than interactions. Their risk is drift from the real implementation’s semantics: uniqueness constraints, ordering, case sensitivity, null handling, transaction boundaries. Contract tests (one abstract test suite, instantiated once for the fake and once for the real implementation against a real database, e.g. Testcontainers or an in-memory SQLite/Room) keep them honest.

Production puzzles

Spot the bugGreen tests, duplicate accounts

Tap the line with the bug.

final class InMemoryUserRepository: UserRepository {
    private var users: [String: User] = [:]
    func save(_ user: User) async throws { users[user.id] = user }
    func user(email: String) async throws -> User? {
        users.values.first { $0.email == email }
    }
}
class InMemoryUserRepository : UserRepository {
    private val users = mutableMapOf<String, User>()
    override suspend fun save(user: User) { users[user.id] = user }
    override suspend fun user(email: String): User? =
        users.values.firstOrNull { it.email == email }
}
public final class InMemoryUserRepository implements UserRepository {
    private final Map<String, User> users = new HashMap<>();
    public void save(User user) { users.put(user.id(), user); }
    public Optional<User> user(String email) {
        return users.values().stream().filter(u -> u.email().equals(email)).findFirst();
    }
}
class InMemoryUserRepository implements UserRepository {
  private users = new Map<string, User>();
  async save(user: User): Promise<void> { this.users.set(user.id, user); }
  async user(email: string): Promise<User | undefined> {
    return [...this.users.values()].find((u) => u.email === email);
  }
}

Practice: what to test where

  1. View models and use cases: with an in-memory fake repository. Fast, many tests, every branch.
  2. The real repository: against real storage (an in-memory Room or SQLite database, a Testcontainers database, or recorded HTTP responses). Fewer tests, focused on queries and mapping.
  3. The rules both must follow (unique emails, sort order, what “not found” returns): one shared contract suite, run against both.
  • In-memory fakes over mocks for repositories: state-based assertions.
  • Contract test suites run against the fake and the real implementation (Testcontainers, in-memory Room/SQLite) to prevent semantic drift: uniqueness, ordering, collation, null handling.
  • Real repositories get focused integration tests for queries and mapping.

Check this part

  1. Why test view models with an in-memory fake repository?

  2. How do you keep a fake repository honest?

Repositories make the rest of the app easy to test with in-memory fakes. A fake is only useful if it behaves like the real implementation, so run one set of contract tests against both, and test the real repository against real storage.

Key takeaways

  • A repository is one home for one kind of data, behind an interface that speaks the app’s language.
  • It returns domain models; database rows and JSON stay inside.
  • Name query methods after what screens need, and let storage do the filtering.
  • Test the app with in-memory fakes, and keep fakes honest with shared contract tests.
  • Collection-like interfaces per aggregate, owned by the domain side, implemented in the data layer.
  • No leaked query languages or in-memory predicates; finders, query objects or read models instead.
  • On mobile, the repository holds the source-of-truth policy over remote and local sources.
  • Contract tests against fake and real implementations prevent drift.

Finished this page?Nice work, this page is complete!

Mark it as done to track your progress. It's saved in this browser, no account needed.

Its cards are now in your Bites review ⚡
Esc

Type to search the whole site.