# 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 { 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 { 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 { const queryable = client || getPool(); try { const result = await queryable.query('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 { const queryable = client || getPool(); try { const result = await queryable.query('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 { const queryable = client || getPool(); const { limit = 100, offset = 0 } = options; try { const result = await queryable.query( '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 { const queryable = client || getPool(); try { const result = await queryable.query( `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 { const queryable = client || getPool(); try { const result = await queryable.query( `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 { 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 { 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