Repository Pattern in API Design: Practical Guide with Patterns, Pitfalls, and Code

Apply the Repository pattern in API design with practical code, specs, UoW, and testing tips—plus performance, security, and pitfalls to avoid.

ASOasis
8 min read
Repository Pattern in API Design: Practical Guide with Patterns, Pitfalls, and Code

Image used for representation purposes only.

Overview

The Repository pattern is a timeless abstraction that separates your API’s domain and application logic from data-access concerns. By hiding the mechanics of persistence (SQL, NoSQL, HTTP calls, or files) behind well-defined interfaces, repositories help you ship APIs that are easier to test, evolve, and scale. This article explains the pattern in the context of modern API design, shows practical code examples, and highlights performance, security, and testing considerations.

What the Repository Pattern Does (and Doesn’t)

At its core, a repository:

  • Encapsulates data access for a specific aggregate or entity.
  • Exposes intent-revealing methods (e.g., GetById, List, Add) rather than persistence details.
  • Returns domain objects or DTOs relevant to the application layer, not ORM-specific types.

It is not:

  • A universal replacement for your ORM or data client.
  • A dumping ground for every query in the system.
  • A license to create a single, generic mega-repository that knows everything about everything.

Why Use It in API Design?

  • Clear separation of concerns: Controllers/handlers focus on HTTP semantics; domain/application services focus on business rules; repositories focus on persistence.
  • Easier testing: Swap real repositories with in-memory fakes or mocks for fast, deterministic tests.
  • Portability: Switch databases or move from monolith to microservice with minimal ripple effects.
  • Security: Centralize data-access rules (row filters, tenant scopes) and auditing.

Architectural Context

  • Layered architecture: Controllers → Application Services → Repositories → Data Store. Repositories prevent controllers from “knowing” the data layer.
  • Hexagonal/Clean architecture: Repositories are ports; database adapters are driven by interfaces defined in the core. This keeps your domain independent of frameworks.
  • DDD alignment: A repository typically operates on an Aggregate Root (e.g., Order) and preserves invariants.

Designing Repository Interfaces

Start with intent-first, minimal interfaces. Keep them narrow; expand only when your use cases demand it.

Example in C# (Domain-Driven, async-safe):

public interface IRepository<T> where T : class
{
    Task<T?> GetByIdAsync(Guid id, CancellationToken ct = default);
    Task<IReadOnlyList<T>> ListAsync(ISpecification<T> spec, CancellationToken ct = default);
    Task AddAsync(T entity, CancellationToken ct = default);
    Task UpdateAsync(T entity, CancellationToken ct = default);
    Task DeleteAsync(T entity, CancellationToken ct = default);
}

public interface ICustomerRepository : IRepository<Customer>
{
    Task<Customer?> GetByEmailAsync(string email, CancellationToken ct = default);
}

TypeScript (Node.js) with a focused interface:

export interface CustomerRepository {
  getById(id: string, signal?: AbortSignal): Promise<Customer | null>;
  list(spec?: Spec<Customer>, signal?: AbortSignal): Promise<Customer[]>;
  add(entity: Customer, signal?: AbortSignal): Promise<void>;
  update(entity: Customer, signal?: AbortSignal): Promise<void>;
  delete(entity: Customer, signal?: AbortSignal): Promise<void>;
}

Tips:

  • Avoid leaking ORM types (DbContext, PrismaClient, Session) into the interface.
  • Use CancellationToken/AbortSignal to support timeouts and graceful shutdowns.
  • Keep return types domain-centric. Map persistence models to domain entities/DTOs.

Implementing the Repository

C# with EF Core (simplified):

public sealed class EfCustomerRepository : ICustomerRepository
{
    private readonly AppDbContext _db;
    public EfCustomerRepository(AppDbContext db) => _db = db;

    public async Task<Customer?> GetByIdAsync(Guid id, CancellationToken ct = default) =>
        await _db.Customers.AsNoTracking().FirstOrDefaultAsync(c => c.Id == id, ct);

    public async Task<IReadOnlyList<Customer>> ListAsync(ISpecification<Customer> spec, CancellationToken ct = default)
    {
        IQueryable<Customer> query = _db.Customers.AsQueryable();
        query = spec.Apply(query);
        return await query.AsNoTracking().ToListAsync(ct);
    }

    public async Task AddAsync(Customer entity, CancellationToken ct = default)
    {
        _db.Customers.Add(entity);
        await _db.SaveChangesAsync(ct);
    }

    public async Task UpdateAsync(Customer entity, CancellationToken ct = default)
    {
        _db.Customers.Update(entity);
        await _db.SaveChangesAsync(ct);
    }

    public async Task DeleteAsync(Customer entity, CancellationToken ct = default)
    {
        _db.Customers.Remove(entity);
        await _db.SaveChangesAsync(ct);
    }

    public Task<Customer?> GetByEmailAsync(string email, CancellationToken ct = default) =>
        _db.Customers.AsNoTracking().FirstOrDefaultAsync(c => c.Email == email, ct);
}

TypeScript with Prisma (sketch):

export class PrismaCustomerRepository implements CustomerRepository {
  constructor(private readonly prisma: PrismaClient) {}

  getById(id: string) {
    return this.prisma.customer.findUnique({ where: { id } });
  }

  list(spec?: Spec<Customer>) {
    const query = spec ? spec.toPrismaQuery() : {};
    return this.prisma.customer.findMany(query);
  }

  async add(entity: Customer) {
    await this.prisma.customer.create({ data: entity });
  }

  async update(entity: Customer) {
    await this.prisma.customer.update({ where: { id: entity.id }, data: entity });
  }

  async delete(entity: Customer) {
    await this.prisma.customer.delete({ where: { id: entity.id } });
  }
}

Specifications: Queries Without Leaking Details

The Specification pattern helps express filtering, sorting, and pagination without exposing ORM specifics.

  • A specification encapsulates the query intent (e.g., “active customers created after X, ordered by last activity”).
  • The repository applies the specification to its queryable source.
  • This keeps controllers clean and repositories focused.

Minimal C# spec:

public interface ISpecification<T>
{
    IQueryable<T> Apply(IQueryable<T> query);
}

Unit of Work and Transactions

  • Unit of Work coordinates changes across multiple repositories so your API executes a set of writes atomically.
  • In EF Core, the DbContext is effectively a Unit of Work. Elsewhere, expose an IUnitOfWork with CommitAsync/RollbackAsync.
  • For distributed systems, consider the Outbox pattern to achieve reliable event publishing after commit.

Pagination, Sorting, and Projections

  • Implement cursor- or offset-based pagination in the spec layer. Cursor-based is more stable for frequently changing datasets.
  • Expose lightweight read models (DTOs) instead of entire aggregates when the API only needs a subset of fields.
  • Consider projections at the repository level to avoid loading heavy graphs (e.g., Select into DTOs).

Caching and Performance

  • Put read-through caching in a query-side repository or a decorator (e.g., ICustomerRepository -> CachingCustomerRepository).
  • Scope caches to idempotent reads; avoid caching writes.
  • Use ETags/If-None-Match at the API layer for HTTP caching; repositories can expose version tokens (rowversion/timestamp) to enable this.
  • Beware N+1 queries; use includes or batch queries prudently.

Error Handling and Idempotency

  • Convert low-level exceptions (unique constraint, deadlocks, timeouts) into domain-meaningful errors.
  • Idempotent create/update endpoints can leverage repository methods that upsert by unique key.
  • For eventual consistency, document read-your-writes behavior and consider read models updated via events.

Security and Multi-Tenancy

  • Apply tenant scoping, row-level filters, and soft-delete predicates in a base specification or repository decorator.
  • Enforce least-privilege at the data client: separate readers/writers, rotate credentials, and encrypt secrets.
  • Validate inputs at the API edge; repositories should never trust UI parameters for raw SQL.

REST, GraphQL, and gRPC

  • REST: Repositories back application services; controllers translate HTTP to use cases.
  • GraphQL: Resolvers should orchestrate use cases; apply batching (DataLoader) to avoid N+1 across repositories.
  • gRPC: Coarse-grained methods map well to application services that call repositories inside transactions.

CQRS: Split Reads and Writes When It Pays Off

  • For complex domains, use separate read and write repositories. Reads can be denormalized and optimized for queries; writes preserve invariants on aggregates.
  • Keep it simple until you need it. CQRS introduces operational overhead (sync, backfills, eventual consistency).

Testing Strategies

  • Unit test application services with repository fakes or mocks. Verify behaviors, not EF/Prisma.
  • Contract tests for real repository implementations: seed known data, assert queries/transactions behave as expected.
  • Integration tests for edge cases (deadlocks, constraint violations) and migrations.

A simple in-memory fake for C#:

public sealed class InMemoryCustomerRepository : ICustomerRepository
{
    private readonly Dictionary<Guid, Customer> _store = new();

    public Task<Customer?> GetByIdAsync(Guid id, CancellationToken ct = default)
        => Task.FromResult(_store.TryGetValue(id, out var c) ? c : null);

    public Task<IReadOnlyList<Customer>> ListAsync(ISpecification<Customer> spec, CancellationToken ct = default)
        => Task.FromResult<IReadOnlyList<Customer>>(spec.Apply(_store.Values.AsQueryable()).ToList());

    public Task AddAsync(Customer entity, CancellationToken ct = default) { _store[entity.Id] = entity; return Task.CompletedTask; }
    public Task UpdateAsync(Customer entity, CancellationToken ct = default) { _store[entity.Id] = entity; return Task.CompletedTask; }
    public Task DeleteAsync(Customer entity, CancellationToken ct = default) { _store.Remove(entity.Id); return Task.CompletedTask; }
    public Task<Customer?> GetByEmailAsync(string email, CancellationToken ct = default)
        => Task.FromResult(_store.Values.FirstOrDefault(c => c.Email == email));
}

Common Pitfalls and How to Avoid Them

  • Generic Repository Overreach: A single IRepository with dozens of methods becomes leaky and complex. Keep it small; add domain-specific repositories as needed.
  • ORM Leakage: Returning IQueryable or exposing DbContext/PrismaClient ties upper layers to the data store. Return collections/DTOs instead.
  • Chatty APIs: Performing many small repository calls in a loop creates N+1 behavior. Prefer set-based operations or batch methods.
  • Anemic Repositories: If every controller crafts its own queries, your repositories are bypassed. Centralize complex querying behind specs or query objects.
  • Hidden Transactions: Implicit commits inside repositories can surprise callers. Document transaction boundaries and provide UoW when necessary.
  • Over-Abstraction: If your app only uses a single data store and has trivial queries, a thin data service might suffice. Don’t add layers you don’t need.

Migration and Versioning Considerations

  • Schema changes: Keep repositories resilient via projections and backward-compatible DTOs. Roll out with online migrations and feature flags.
  • Backfills: For CQRS read models, implement replay or backfill jobs. Expose admin endpoints to monitor progress.
  • Versioned APIs: Repositories remain stable while controllers evolve; deprecate endpoints without rewriting data access.

Observability and Operations

  • Emit metrics: count, latency, and error-rate per repository method.
  • Add structured logs for correlation IDs and tenant IDs.
  • Trace database calls with OpenTelemetry; attach spans from the API layer through the repository to the driver.

A Practical Checklist

  • Define narrow, intent-based repository interfaces per aggregate.
  • Keep domain purity: no ORM types leaking outward.
  • Use Specifications for complex queries; keep repositories cohesive.
  • Introduce Unit of Work for multi-aggregate transactions.
  • Support cancellation and timeouts.
  • Add caching via decorators for hot, read-heavy paths.
  • Test with in-memory fakes and integration tests.
  • Monitor with metrics, logs, and tracing.

Conclusion

The Repository pattern remains a strong foundation for API design when applied thoughtfully. It focuses your controllers and services on business intent while containing persistence complexity behind clean interfaces. Start small, prefer explicitness over clever abstraction, and evolve toward specifications, UoW, and CQRS only as real needs emerge. The result is an API that’s easier to reason about, safer to change, and friendlier to test at every layer.

Related Posts