Skip to content

The Repository Pattern and Services

Aman Kumar Singh7 min read
Part 16 of 40From the NestJS Production Guide series
The Repository Pattern and Services — article by Aman Kumar Singh

The previous article covered how to evolve a database schema safely once real data is sitting in it. That article assumed your application code already knows how to talk to the database. This one is about the layer that sits between your business logic and TypeORM: whether you need a repository abstraction at all, and if you do, where it belongs.

This is part of the NestJS Production Guide, and it's a topic where I've seen more wasted effort than almost anywhere else in a NestJS codebase. Engineers coming from a strict layered-architecture background often reach for a full repository interface and dependency inversion setup on day one, before there's a second data source or a single test that needs it. Others swing the other way and let TypeOrmRepository calls scatter across every service, controller, and one-off script in the app. Both extremes cause real pain, just on different timelines.

The right answer depends on what problem you're actually solving. A repository pattern earns its keep when you need to swap persistence implementations, isolate business logic from an ORM's query API for testing, or centralize query logic that would otherwise be duplicated across services. It doesn't earn its keep as a default architectural layer applied uniformly because a textbook says so.

What the repository pattern is actually for

The repository pattern's job is to hide how data is fetched and persisted behind an interface that expresses domain intent. Instead of a service calling this.orderRepo.find({ where: { status: 'pending', tenantId } }) directly, it calls this.orderRepository.findPendingForTenant(tenantId). The service doesn't know or care whether that query hits PostgreSQL through TypeORM, a different ORM, or an HTTP call to another service.

That indirection buys you three things, in roughly this order of how often they matter in practice:

  1. Query logic gets a single home. If three services all need "pending orders for a tenant older than 30 days," that filter logic lives in one method instead of being copy-pasted with a subtly different WHERE clause each time.
  2. Testing services doesn't require a real database. A service that depends on a repository interface can be tested against an in-memory fake or a mock, rather than needing Testcontainers or a shared test database for every unit test.
  3. Swapping the underlying data store becomes a contained change. This one gets cited constantly and matters far less often than people think. Most SaaS backends never actually swap PostgreSQL for something else.

The mistake is treating reason 3 as the primary justification. It's the least common reason a repository layer earns its cost. Reasons 1 and 2 are the ones that show up in a real, ongoing codebase.

TypeORM's own repository, and why it's not enough on its own

TypeORM already gives you a repository: @InjectRepository(Order) hands you a Repository<Order> with find, findOne, save, and a query builder. For a lot of NestJS services, that's genuinely sufficient, and wrapping it in another layer just to satisfy "the pattern" adds a file and an indirection without adding a capability.

// src/modules/orders/orders.service.ts
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Order, OrderStatus } from './order.entity';

@Injectable()
export class OrdersService {
  constructor(
    @InjectRepository(Order)
    private readonly orderRepo: Repository<Order>,
  ) {}

  findById(id: string) {
    return this.orderRepo.findOneBy({ id });
  }

  createDraft(customerId: string) {
    const order = this.orderRepo.create({
      customerId,
      status: OrderStatus.Draft,
    });
    return this.orderRepo.save(order);
  }
}

This is a fine starting point for a module with a handful of straightforward queries. Nothing here is wrong, and I'd rather see a service like this shipped than a premature abstraction sitting on top of it. The problem shows up later, once the same Repository<Order> is injected into three or four services, each building a similar-but-not-identical query with the query builder, and none of them share the logic.

Introducing a custom repository once queries get real

A dedicated repository class earns its place once a module's queries are complex enough, or reused enough, that leaving them inline in services causes duplication or makes the service hard to read. A module simply existing isn't the trigger. TypeORM supports this directly through custom repositories built on Repository<T>.

// src/modules/orders/orders.repository.ts
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Order, OrderStatus } from './order.entity';

@Injectable()
export class OrdersRepository {
  constructor(
    @InjectRepository(Order)
    private readonly repo: Repository<Order>,
  ) {}

  findPendingOlderThan(tenantId: string, days: number) {
    const cutoff = new Date();
    cutoff.setDate(cutoff.getDate() - days);

    return this.repo
      .createQueryBuilder('order')
      .where('order.tenantId = :tenantId', { tenantId })
      .andWhere('order.status = :status', { status: OrderStatus.Pending })
      .andWhere('order.createdAt < :cutoff', { cutoff })
      .orderBy('order.createdAt', 'ASC')
      .getMany();
  }

  async transitionStatus(orderId: string, next: OrderStatus) {
    const result = await this.repo.update({ id: orderId }, { status: next });
    if (result.affected === 0) {
      throw new Error(`Order ${orderId} not found`);
    }
  }
}
// src/modules/orders/orders.service.ts
import { Injectable } from '@nestjs/common';
import { OrdersRepository } from './orders.repository';
import { OrderStatus } from './order.entity';

@Injectable()
export class OrdersService {
  constructor(private readonly orders: OrdersRepository) {}

  getStaleOrders(tenantId: string) {
    return this.orders.findPendingOlderThan(tenantId, 30);
  }

  approve(orderId: string) {
    return this.orders.transitionStatus(orderId, OrderStatus.Approved);
  }
}

The service now reads as business logic: get stale orders, approve an order. The query builder, the specific column names, and the exact filtering logic all live in one place. If the definition of "stale" changes from 30 days to something configurable per tenant, there's one method to update, and every caller gets the fix automatically.

Notice this custom repository is still a concrete class, wired to @InjectRepository, rather than an interface with a swappable implementation. That's deliberate. Adding an interface here doesn't buy anything unless you actually have or plan a second implementation of it.

When (and when not to) go further with an interface

The full repository pattern from Domain-Driven Design adds one more layer: an interface the service depends on, injected through Nest's DI container, with the TypeORM-backed class as one implementation of it.

// src/modules/orders/orders-repository.interface.ts
export interface IOrdersRepository {
  findPendingOlderThan(tenantId: string, days: number): Promise<Order[]>;
  transitionStatus(orderId: string, next: OrderStatus): Promise<void>;
}

// src/modules/orders/orders.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Order } from './order.entity';
import { OrdersRepository } from './orders.repository';
import { OrdersService } from './orders.service';

export const ORDERS_REPOSITORY = Symbol('ORDERS_REPOSITORY');

@Module({
  imports: [TypeOrmModule.forFeature([Order])],
  providers: [
    OrdersService,
    { provide: ORDERS_REPOSITORY, useClass: OrdersRepository },
  ],
})
export class OrdersModule {}

The service then depends on the ORDERS_REPOSITORY token rather than the concrete class, and a test can provide a stub implementation of IOrdersRepository without touching a database at all.

This has a real cost: an extra file, an extra provider token, and one more level of indirection for anyone tracing through the code. That cost is worth paying in one specific, identifiable situation: a service's unit tests are slow or flaky because they need a real database connection, and swapping in an in-memory fake would meaningfully fix that. Applying it as a blanket rule across every module, on the assumption that "good architecture" means every dependency sits behind an interface, is a different thing entirely and rarely pays for itself. Most NestJS teams get more mileage from integration tests against a real, containerized Postgres instance for the repository layer, plus plain unit tests for services that mock a concrete repository class directly. No interface required.

Keeping business logic out of the repository

The failure mode that's easy to miss going the other direction: once a repository class exists, it becomes a tempting place to put logic that isn't actually about persistence. A method like approveOrderAndNotifyCustomer doesn't belong in OrdersRepository, even though it touches the orders table, because sending a notification is a business concern rather than a data-access one.

The repository's job stops at "fetch this, save that, run this specific query." Composing multiple repository calls, deciding what constitutes a valid state transition, and triggering side effects like emails or events are the service's job. This split matters practically because it keeps the repository testable with nothing but a database, and keeps the service testable with nothing but mocked repository methods. Blur that line and both layers end up needing the same heavyweight test setup, which defeats the point of separating them in the first place.

Key takeaways

  • TypeORM's built-in `Repository<T>` is often enough on its own for a module with straightforward queries. Don't wrap it in another layer until duplication or complexity actually shows up.
  • Introduce a custom repository class when query logic is reused across services or complex enough to clutter a service's business logic. That's the real signal, not the existence of a module.
  • Reserve a full interface-based repository (with a DI token and swappable implementation) for cases where you specifically need to test services without a database, or genuinely expect more than one persistence implementation.
  • Keep business logic, validation, side effects, and orchestration in the service. The repository should only fetch and persist data.
  • Prefer integration tests against a real containerized database for repository logic, and unit tests with mocked repositories for service logic, rather than defaulting every dependency to an interface.

Frequently asked questions

Do I need a repository pattern in every NestJS module?

No. For modules with simple, non-duplicated queries, injecting TypeORM's `Repository<T>` directly into the service is sufficient. Add a dedicated repository class once query logic is complex or reused enough that leaving it in the service causes real duplication or clutter.

What's the difference between a custom repository and the full repository pattern?

A custom repository is a concrete class wrapping `Repository<T>` with domain-specific query methods; it's still injected as a concrete class. The full pattern adds an interface and a DI token so the service depends on an abstraction rather than a concrete implementation, which is useful mainly when you need to swap that implementation in tests or across environments.

Should services call the TypeORM query builder directly?

It's fine for one-off, simple queries early in a module's life. Once the same query builder logic starts appearing in more than one service, or the where-clauses get long enough to hurt readability, move that logic into a repository method instead.

Where should transaction logic live: the repository or the service?

Repositories should expose the primitives; services (or a dedicated unit-of-work helper) should coordinate them within a transaction boundary when a business operation spans multiple repository calls. The next article in this series covers transaction management in NestJS in detail.

Is it worth mocking the database for every unit test?

Not universally. Mocking a repository interface is worth it when tests are slow or flaky because of database setup, and the logic under test is genuinely about orchestration rather than the query itself. For logic that's mostly a specific query's correctness, an integration test against a real database in a container catches more real bugs than a mock ever will.

Can I use the repository pattern with Prisma instead of TypeORM?

Yes, the same reasoning applies. Prisma's generated client plays the same role TypeORM's `Repository<T>` does here: fine to inject directly for simple modules, worth wrapping in a dedicated repository class once query logic needs a shared home.

Related articles

Connecting PostgreSQL with TypeORM — Aman Kumar Singh
NestJS Architecture Overview — Aman Kumar Singh
Choosing Prisma or TypeORM for Your NestJS API — Aman Kumar Singh

Explore more on

Aman Kumar Singh

About the author

I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.