Files
flyer-crawler.projectium.com/docs/adr/0034-repository-pattern-standards.md

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:

  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:

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