Skip to content
advanced Phase 12 · TypeScript & Databases

Type-Safe Database Patterns

Implement repository patterns and type-safe query builders.

1h
0 problems
Topic Progress 0%

The Repository Boundary

The Repository Boundary

A repository is an interface owned by the domain, implemented by the ORM adapter. Callers depend on the interface, so swapping Prisma for SQL or a test double never leaks into business logic:

interface UserRepository {
  findByEmail(email: string): Promise<User | null>;
  save(user: NewUser): Promise<User>;
}

class PrismaUserRepo implements UserRepository {
  constructor(private db: PrismaClient) {}
  findByEmail(email: string) {
    return this.db.user.findUnique({ where: { email } });
  }
}

Return domain-shaped values, not ORM payloads: mapping once at the boundary keeps include structures and scalar aliases out of service code.

Typed Filters and Query Helpers

Typed Filters and Query Helpers

Generic helpers remove repeated, error-prone mapping while staying specific:

type Filter<T> = {
  [K in keyof T]?: T[K] | { equals?: T[K]; contains?: string };
};

async function search<T>(
  repo: { findMany(f: Filter<T>): Promise<T[]> },
  filter: Filter<T>,
) {
  return repo.findMany(filter);
}

type Page<T> = { items: T[]; nextCursor: string | null };

Mapped filters autocomplete valid columns, and a Page<T> wrapper gives every list endpoint one predictable shape. For SQL you control yourself, a typed builder (Kysely, Drizzle) gives the same guarantees: table types come from one schema definition shared by every query.

Pitfalls and Interview Angles

Pitfalls and Interview Angles

Transactions do not span objects. Two repositories sharing no transaction client commit independently and can half-apply a change. Fix it with a unit of work: start the transaction, construct repositories over that client, commit or roll back once.

Abstraction tax. A repository that only wraps findUnique adds a layer without value; extract interfaces where substitution, testing, or dialect switching actually happens.

N+1 hides behind helpers. A generic findById called in a loop still queries per row — batch with where: { id: { in } }.

Interview: design a repository over a real table, then extend it to participate in one transaction across aggregates.

Quiz

1. Why pass a transaction client into repository construction?

Question 1 options

2. What is the main cost of a repository that only delegates each ORM call one-to-one?

Question 2 options

3. Which type best models a paged list result?

Question 3 options

Flashcards

Question

Repository pattern

Answer

Domain-owned interface for persistence; ORM adapter implements it so business code never imports the ORM.

Question

Unit of work

Answer

One transaction shared by several repositories; commit or roll back as a single unit.

Question

Mapped filter type

Answer

{ [K in keyof T]?: ... } — only real columns are accepted, with autocomplete.

Question

Leaky abstraction

Answer

Exposing ORM types (include graphs, scalars) past the boundary defeats the repository's purpose.

Revision Notes

Key Takeaways

  • 1. Repositories return domain types; mapping happens once at the boundary
  • 2. Transactions need an explicit shared client or they silently split
  • 3. Generic helpers must not hide per-row queries

Interview Tips

  • • Sketch the interface first, then show the Prisma/Kysely adapter implementing it
  • • Walk through rollback: which objects exist, who calls commit, what happens on throw

Cheat Sheet

Domain interface → ORM adapter maps at the boundary; tx client flows into repos for atomic commits; mapped Filter for safe search; batch by id-in.

Continue Learning