346 lines
10 KiB
Markdown
346 lines
10 KiB
Markdown
# 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:
|
|
|
|
1. **Method naming**: Inconsistent verbs (get vs find vs fetch)
|
|
2. **Return types**: Some methods return `undefined`, others throw errors
|
|
3. **Error handling**: Varied approaches to database error handling
|
|
4. **Transaction participation**: Unclear how methods participate in transactions
|
|
5. **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:
|
|
|
|
```typescript
|
|
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`:
|
|
|
|
```typescript
|
|
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:
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
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 classes
|
|
- `src/services/db/index.db.ts` - Barrel exports for all repositories
|
|
- `src/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 `NotFoundError` for `get*` methods when entity not found
|
|
- [ ] Returns `null` for `find*` methods when entity not found
|
|
- [ ] Uses `handleDbError` for database error handling
|
|
- [ ] Accepts optional `PoolClient` parameter for transaction support
|
|
- [ ] Includes JSDoc with `@throws` documentation
|
|
- [ ] Has corresponding unit tests
|