Skip to content

Repository Pattern

When to use: Creating services, data-access layers, or reviewing business logic for direct ORM or database usage.

Instructions

The rule

Services must not import ORM query builders, database clients, or schema/table definitions.

All persistence goes through a repository interface (for example IFooRepository). Services depend on the interface; a concrete class (for example PostgresFooRepository or NeonFooRepository) implements storage details. This keeps business logic storage-agnostic, testable with simple mocks, and prevents cross-aggregate queries from leaking into services.

Directory layout

Use one folder per aggregate (domain):

src/repositories/<aggregate>/
  IFooRepository.ts             # Interface + input/option types
  <Storage>FooRepository.ts     # ORM/driver implementation

src/services/
  fooService.ts                 # Depends on IFooRepository only

Adjust paths to match the project; keep the interface + implementation + service split.

Creating a new service and repository

1. Define the interface

IFooRepository.ts — only methods the service actually needs:

import type { Foo } from '../../types/index.js';

export interface IFooRepository {
  findById(id: string): Promise<Foo | null>;
  insert(values: InsertFooValues): Promise<Foo>;
}

2. Implement persistence

<Storage>FooRepository.ts — ORM/schema imports live here only:

import { eq } from 'drizzle-orm';
import { foos } from '../../db/schema.js';
import type { IFooRepository } from './IFooRepository.js';

export class PostgresFooRepository implements IFooRepository {
  // ...
}

3. Write the service

fooService.ts — constructor takes the interface, not a raw database handle:

import type { IFooRepository } from '../repositories/foo/IFooRepository.js';

export class FooService {
  constructor(private repo: IFooRepository) {}
}

4. Wire at the composition root

Routes, workers, or DI setup construct the concrete repository and pass it in:

const fooService = new FooService(new PostgresFooRepository(db));

Cross-domain data access

When a service needs data owned by another aggregate, inject that aggregate's repository interface. Do not pass a shared database client into the service and query foreign tables.

// GOOD — inject the owning domain's repository
export class DeviceService {
  constructor(
    private repo: IDeviceRepository,
    private subscriptionRepo: ISubscriptionRepository,
  ) {}
}

// BAD — service reaches into another domain's tables
export class DeviceService {
  constructor(private repo: IDeviceRepository, private db: Database) {}
}

Testing services

Mock the repository interface (for example with Vitest vi.fn()), not ORM query chains:

function createMockRepo(): { [K in keyof IFooRepository]: ReturnType<typeof vi.fn> } {
  return {
    findById: vi.fn(),
    insert: vi.fn(),
  };
}

beforeEach(() => {
  repo = createMockRepo();
  service = new FooService(repo as unknown as IFooRepository);
});

If tests build chainable query mocks (select().from().where()), the service is coupled to storage implementation — refactor so the repository owns the query.

PR checklist

Check
No ORM or schema imports in service files
Service constructors take repository interfaces, not raw DB clients
Cross-domain reads use the owning domain's repository
Service tests mock repository methods only
New repos use I<Aggregate>Repository / <Storage><Aggregate>Repository

References

Drizzle + Neon layout, naming, and domain inventory from the originating engagement: references/vortexnav-drizzle-neon.md.


Troubleshooting

Symptom Likely cause Fix
Service imports ORM or schema Persistence written in the business layer Remove ORM from service; implement query in concrete repository
Tests use ORM chain mocks (mockReturnThis, .select().from()) Service coupled to storage, not interface Mock repository methods with vi.fn() / mockResolvedValue
Service takes raw DB client for another domain's data Cross-domain leakage Inject the other domain's repository interface in the constructor