Use when the user asks about database schema design, migrations, query optimization, N+1 queries, pagination strategies, ORM patterns, repository pattern, unit of work, connection pooling, transaction management, indexing, or choosing between ORMs like Prisma, TypeORM, or Drizzle.
| Normal Form | Rule | Example |
|---|---|---|
| 1NF | No repeating groups | Tags as join table, not comma-separated string |
| 2NF | No partial key dependencies | Move product name out of order_items |
| 3NF | No transitive dependencies | Move city/state to an address table |
When to denormalize:
order.totalAmount computed from line items)Always start normalized. Denormalize with data and evidence, not assumptions.
-- Tables: plural, snake_case
CREATE TABLE order_line_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
order_id UUID NOT NULL REFERENCES orders(id),
product_id UUID NOT NULL REFERENCES products(id),
quantity INTEGER NOT NULL CHECK (quantity > 0),
unit_price_cents INTEGER NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
id (UUID preferred over auto-increment for distributed systems)<singular_table>_id (e.g., order_id)created_at, updated_at on every tableis_ or has_ prefix (is_active, has_verified_email)Never make breaking changes in a single deploy. Use expand-contract:
Phase 1 — Expand (backward-compatible):
-- Add new column as nullable
ALTER TABLE users ADD COLUMN display_name VARCHAR(255);
Phase 2 — Migrate (backfill data):
UPDATE users SET display_name = name WHERE display_name IS NULL;
Phase 3 — Contract (after code is deployed):
ALTER TABLE users ALTER COLUMN display_name SET NOT NULL;
ALTER TABLE users DROP COLUMN name;
Dangerous operations to avoid in a single migration:
-- Safe index creation (PostgreSQL)
CREATE INDEX CONCURRENTLY idx_users_email ON users(email);
The most common performance issue with ORMs.
// BAD: N+1 — 1 query for posts, then N queries for authors
const posts = await db.post.findMany()
for (const post of posts) {
post.author = await db.user.findUnique({ where: { id: post.authorId } })
}
// GOOD: Eager loading — 2 queries total
const posts = await db.post.findMany({
include: { author: true },
})
// GOOD: Manual join — 1 query
const posts = await db.$queryRaw`
SELECT p.*, u.name as author_name
FROM posts p JOIN users u ON p.author_id = u.id
`
Cursor-based (recommended for large tables):
async function getPosts(cursor?: string, limit = 20) {
const posts = await db.post.findMany({
take: limit + 1,
...(cursor && {
cursor: { id: cursor },
skip: 1,
}),
orderBy: { createdAt: 'desc' },
})
const hasMore = posts.length > limit
if (hasMore) posts.pop()
return {
data: posts,
nextCursor: hasMore ? posts[posts.length - 1].id : null,
}
}
Offset-based (simple, for admin UIs):
async function getPosts(page = 1, pageSize = 20) {
const [data, total] = await Promise.all([
db.post.findMany({ skip: (page - 1) * pageSize, take: pageSize }),
db.post.count(),
])
return { data, total, page, pageSize, totalPages: Math.ceil(total / pageSize) }
}
// Batch inserts
await db.post.createMany({
data: posts.map(p => ({ title: p.title, content: p.content })),
skipDuplicates: true,
})
// Batch updates with different values (Prisma transaction)
await db.$transaction(
updates.map(u =>
db.post.update({ where: { id: u.id }, data: { title: u.title } })
),
)
Abstract database access behind an interface. One repository per aggregate/entity.
interface PostRepository {
findById(id: string): Promise<Post | null>
findByAuthor(authorId: string): Promise<Post[]>
save(post: Post): Promise<void>
delete(id: string): Promise<void>
}
class PrismaPostRepository implements PostRepository {
constructor(private readonly prisma: PrismaClient) {}
async findById(id: string): Promise<Post | null> {
const row = await this.prisma.post.findUnique({
where: { id },
include: { comments: true },
})
return row ? PostMapper.toDomain(row) : null
}
async save(post: Post): Promise<void> {
const data = PostMapper.toPersistence(post)
await this.prisma.post.upsert({
where: { id: data.id },
create: data,
update: data,
})
}
}
| Pattern | Description | Good For |
|---|---|---|
| Active Record | Entity knows how to save itself (user.save()) | Simple CRUD, rapid prototyping |
| Data Mapper | Separate mapper/repository handles persistence | Complex domains, Clean Architecture |
TypeORM supports both. Prisma is data mapper by design. Drizzle is query-builder-first.
| Feature | Prisma | TypeORM | Drizzle |
|---|---|---|---|
| Schema definition | schema.prisma file | Decorators on classes | TypeScript schema |
| Migrations | Auto-generated | Manual or auto | Manual or auto |
| Type safety | Generated types | Moderate | Excellent |
| Raw queries | $queryRaw | query() | sql template |
| Learning curve | Low | Medium | Low |
| Best for | Rapid development | Enterprise with DI | Performance, control |
// Prisma interactive transaction
await db.$transaction(async (tx) => {
const account = await tx.account.findUnique({ where: { id: fromId } })
if (account.balance < amount) throw new Error('Insufficient funds')
await tx.account.update({
where: { id: fromId },
data: { balance: { decrement: amount } },
})
await tx.account.update({
where: { id: toId },
data: { balance: { increment: amount } },
})
})
Transaction rules:
// Prisma — configure in connection string
// postgresql://user:pass@host:5432/db?connection_limit=20&pool_timeout=10
// For serverless (NextJS on Vercel), use Prisma Accelerate or PgBouncer
// Serverless functions create many short-lived connections
Serverless considerations:
connection_limit=1 per serverless instance when using a pooler-- Single column: filter/sort on one column
CREATE INDEX idx_posts_author_id ON posts(author_id);
-- Composite: queries that filter on multiple columns (leftmost prefix rule)
CREATE INDEX idx_posts_author_status ON posts(author_id, status);
-- Partial: only index rows matching a condition
CREATE INDEX idx_active_users ON users(email) WHERE is_active = true;
-- Covering: includes all columns needed by the query (index-only scan)
CREATE INDEX idx_posts_list ON posts(author_id, created_at) INCLUDE (title);
When to index:
When not to index:
status VARCHAR is a bug waiting to happen.