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:
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 |