# 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
