10 KiB
10 KiB
ADR-034: Repository Pattern Standards
Date: 2026-01-09
Status: Accepted
Implemented: 2026-01-09
Context
The application uses a repository pattern to abstract database access from business logic. However, without clear standards, repository implementations can diverge in:
- Method naming: Inconsistent verbs (get vs find vs fetch)
- Return types: Some methods return
undefined, others throw errors - Error handling: Varied approaches to database error handling
- Transaction participation: Unclear how methods participate in transactions
- Logging patterns: Inconsistent logging context and messages
This ADR establishes standards for all repository implementations, complementing ADR-001 (Error Handling) and ADR-002 (Transaction Management).
Decision
All repository implementations MUST follow these standards:
Method Naming Conventions
| Prefix | Returns | Behavior on Not Found |
|---|---|---|
get* |
Single entity | Throws NotFoundError |
find* |
Entity or null |
Returns null |
list* |
Array (possibly empty) | Returns [] |
create* |
Created entity | Throws on constraint violation |
update* |
Updated entity | Throws NotFoundError if not exists |
delete* |
void or boolean |
Throws NotFoundError if not exists |
exists* |
boolean |
Returns true/false |
count* |
number |
Returns count |
Error Handling Pattern
All repository methods MUST use the centralized handleDbError function:
import { handleDbError, NotFoundError } from './errors.db';
async getById(id: number): Promise<Entity> {
try {
const result = await this.pool.query('SELECT * FROM entities WHERE id = $1', [id]);
if (result.rows.length === 0) {
throw new NotFoundError(`Entity with ID ${id} not found.`);
}
return result.rows[0];
} catch (error) {
handleDbError(error, this.logger, 'Database error in getById', { id }, {
entityName: 'Entity',
defaultMessage: 'Failed to fetch entity.',
});
}
}
Transaction Participation
Repository methods that need to participate in transactions MUST accept an optional PoolClient:
class UserRepository {
private pool: Pool;
private client?: PoolClient;
constructor(poolOrClient?: Pool | PoolClient) {
if (poolOrClient && 'query' in poolOrClient && !('connect' in poolOrClient)) {
// It's a PoolClient (for transactions)
this.client = poolOrClient as PoolClient;
} else {
this.pool = (poolOrClient as Pool) || getPool();
}
}
private get queryable() {
return this.client || this.pool;
}
}
Or using the function-based pattern:
async function createUser(userData: CreateUserInput, client?: PoolClient): Promise<User> {
const queryable = client || getPool();
// ...
}
Implementation Details
Repository File Structure
src/services/db/
├── connection.db.ts # Pool management, withTransaction
├── errors.db.ts # Custom error types, handleDbError
├── index.db.ts # Barrel exports
├── user.db.ts # User repository
├── user.db.test.ts # User repository tests
├── flyer.db.ts # Flyer repository
├── flyer.db.test.ts # Flyer repository tests
└── ... # Other domain repositories
Standard Repository Template
// src/services/db/example.db.ts
import { Pool, PoolClient } from 'pg';
import { getPool } from './connection.db';
import { handleDbError, NotFoundError } from './errors.db';
import { logger } from '../logger.server';
import type { Example, CreateExampleInput, UpdateExampleInput } from '../../types';
const log = logger.child({ module: 'example.db' });
/**
* Gets an example by ID.
* @throws {NotFoundError} If the example doesn't exist.
*/
export async function getExampleById(id: number, client?: PoolClient): Promise<Example> {
const queryable = client || getPool();
try {
const result = await queryable.query<Example>('SELECT * FROM examples WHERE id = $1', [id]);
if (result.rows.length === 0) {
throw new NotFoundError(`Example with ID ${id} not found.`);
}
return result.rows[0];
} catch (error) {
handleDbError(
error,
log,
'Database error in getExampleById',
{ id },
{
entityName: 'Example',
defaultMessage: 'Failed to fetch example.',
},
);
}
}
/**
* Finds an example by slug, returns null if not found.
*/
export async function findExampleBySlug(
slug: string,
client?: PoolClient,
): Promise<Example | null> {
const queryable = client || getPool();
try {
const result = await queryable.query<Example>('SELECT * FROM examples WHERE slug = $1', [slug]);
return result.rows[0] || null;
} catch (error) {
handleDbError(
error,
log,
'Database error in findExampleBySlug',
{ slug },
{
entityName: 'Example',
defaultMessage: 'Failed to find example.',
},
);
}
}
/**
* Lists all examples with optional pagination.
*/
export async function listExamples(
options: { limit?: number; offset?: number } = {},
client?: PoolClient,
): Promise<Example[]> {
const queryable = client || getPool();
const { limit = 100, offset = 0 } = options;
try {
const result = await queryable.query<Example>(
'SELECT * FROM examples ORDER BY created_at DESC LIMIT $1 OFFSET $2',
[limit, offset],
);
return result.rows;
} catch (error) {
handleDbError(
error,
log,
'Database error in listExamples',
{ limit, offset },
{
entityName: 'Example',
defaultMessage: 'Failed to list examples.',
},
);
}
}
/**
* Creates a new example.
* @throws {UniqueConstraintError} If slug already exists.
*/
export async function createExample(
input: CreateExampleInput,
client?: PoolClient,
): Promise<Example> {
const queryable = client || getPool();
try {
const result = await queryable.query<Example>(
`INSERT INTO examples (name, slug, description)
VALUES ($1, $2, $3)
RETURNING *`,
[input.name, input.slug, input.description],
);
return result.rows[0];
} catch (error) {
handleDbError(
error,
log,
'Database error in createExample',
{ input },
{
entityName: 'Example',
uniqueMessage: 'An example with this slug already exists.',
defaultMessage: 'Failed to create example.',
},
);
}
}
/**
* Updates an existing example.
* @throws {NotFoundError} If the example doesn't exist.
*/
export async function updateExample(
id: number,
input: UpdateExampleInput,
client?: PoolClient,
): Promise<Example> {
const queryable = client || getPool();
try {
const result = await queryable.query<Example>(
`UPDATE examples
SET name = COALESCE($2, name), description = COALESCE($3, description)
WHERE id = $1
RETURNING *`,
[id, input.name, input.description],
);
if (result.rows.length === 0) {
throw new NotFoundError(`Example with ID ${id} not found.`);
}
return result.rows[0];
} catch (error) {
handleDbError(
error,
log,
'Database error in updateExample',
{ id, input },
{
entityName: 'Example',
defaultMessage: 'Failed to update example.',
},
);
}
}
/**
* Deletes an example.
* @throws {NotFoundError} If the example doesn't exist.
*/
export async function deleteExample(id: number, client?: PoolClient): Promise<void> {
const queryable = client || getPool();
try {
const result = await queryable.query('DELETE FROM examples WHERE id = $1', [id]);
if (result.rowCount === 0) {
throw new NotFoundError(`Example with ID ${id} not found.`);
}
} catch (error) {
handleDbError(
error,
log,
'Database error in deleteExample',
{ id },
{
entityName: 'Example',
defaultMessage: 'Failed to delete example.',
},
);
}
}
Using with Transactions
import { withTransaction } from './connection.db';
import { createExample, updateExample } from './example.db';
import { createRelated } from './related.db';
async function createExampleWithRelated(data: ComplexInput): Promise<Example> {
return withTransaction(async (client) => {
const example = await createExample(data.example, client);
await createRelated({ exampleId: example.id, ...data.related }, client);
return example;
});
}
Key Files
src/services/db/connection.db.ts-getPool(),withTransaction()src/services/db/errors.db.ts-handleDbError(), custom error classessrc/services/db/index.db.ts- Barrel exports for all repositoriessrc/services/db/*.db.ts- Individual domain repositories
Consequences
Positive
- Consistency: All repositories follow the same patterns
- Predictability: Method names clearly indicate behavior
- Testability: Consistent interfaces make mocking straightforward
- Error Handling: Centralized error handling prevents inconsistent responses
- Transaction Safety: Clear pattern for transaction participation
Negative
- Learning Curve: Developers must learn and follow conventions
- Boilerplate: Each method requires similar error handling structure
- Refactoring: Existing repositories may need updates to conform
Compliance Checklist
For new repository methods:
- Method name follows prefix convention (get/find/list/create/update/delete)
- Throws
NotFoundErrorforget*methods when entity not found - Returns
nullforfind*methods when entity not found - Uses
handleDbErrorfor database error handling - Accepts optional
PoolClientparameter for transaction support - Includes JSDoc with
@throwsdocumentation - Has corresponding unit tests