typescript 106 lines · 4 tabs

Repository pattern for DB access (small, pragmatic)

Shared by codesnips Jan 2026
4 tabs
import { QueryResultRow } from 'pg';
import { pool, Queryable } from './db';

export abstract class BaseRepository<T extends QueryResultRow> {
  protected abstract readonly table: string;

  constructor(protected readonly db: Queryable = pool) {}

  protected async many(text: string, values: unknown[] = []): Promise<T[]> {
    const res = await this.db.query<T>(text, values);
    return res.rows;
  }

  protected async one(text: string, values: unknown[] = []): Promise<T | null> {
    const res = await this.db.query<T>(text, values);
    return res.rows[0] ?? null;
  }

  forTransaction(client: Queryable): this {
    const Ctor = this.constructor as new (db: Queryable) => this;
    return new Ctor(client);
  }
}
4 files · typescript Explain with highlit

The repository pattern isolates persistence details behind a small, intention-revealing interface so the rest of the application talks about domain concepts (findById, insert) rather than SQL strings and connection pools. This example keeps the pattern deliberately pragmatic: no ORM, no heavy abstraction layer, just a thin base class that owns query execution and a concrete repository that owns table-specific SQL.

In db.ts, a single pg Pool is created and wrapped by a query helper and a withTransaction helper. The Queryable type is the key seam — it accepts either the pool or a checked-out PoolClient, which lets the same repository methods run inside or outside a transaction without duplication. withTransaction checks out a client, issues BEGIN, runs the callback, and commits, rolling back on any thrown error before releasing the client back to the pool. This guarantees connections are never leaked even on failure.

In BaseRepository.ts, the abstract class captures what every repository shares: a Queryable (defaulting to the shared pool) and a one/many pair that run a query and shape the result rows. The forTransaction method returns a new instance of the same repository bound to a transaction client, which is how callers opt a repository into an existing transaction. Subclasses only declare their table name and any custom row mapping.

In UserRepository.ts, the concrete repository writes plain parameterized SQL against the users table. Parameterization ($1, $2) is non-negotiable — it prevents SQL injection and lets Postgres cache query plans. insert uses RETURNING * so the created row, including database-generated defaults like id and created_at, comes back in one round trip.

In usage.ts, withTransaction composes two repositories over one transaction: a user is inserted and an audit row written atomically, so either both persist or neither does. The trade-off of this lightweight approach is that complex object graphs and relationships must be assembled by hand, but for small services it keeps data access explicit, testable by swapping the Queryable, and free of ORM surprises.


Related snips

Share this code

Here's the card — post it anywhere.

Repository pattern for DB access (small, pragmatic) — share card
Link copied