Repositories层设计

要点

  • Repositories 层是 Service 层和数据库之间的隔离带——Service 不直接写 SQL,Repository 把数据访问封装成普通函数调用
  • 每个 Repository 对应一张表,暴露 findByIdcreateupdatedeletefindMany 等方法,方法签名用业务语言而非 SQL 术语
  • Drizzle ORM 提供类型安全的查询构建器,但 Repository 不把 Drizzle 查询对象暴露给上层
  • Service 只看到 findByEmail(email: string): Promise<User | null>,不关心底层用的是 Drizzle 还是原生 SQL
  • 事务通过 withTransaction 暴露给 Service 层,由 Service 控制跨 Repository 的一致性边界
  • 错误处理分两层——「记录不存在」返回 null,约束冲突转成业务错误类,基础设施故障原样上抛
  • Repository 的测试用真实数据库(本地 SQLite),mock 掉 ORM 等于跳过了最有价值的查询逻辑

1. 为什么需要 Repositories 层

06 里 Service 层调用了 userRepository.findByEmail(email)。如果不用 Repository,Service 里会直接写 db.select().from(usersTable).where(eq(usersTable.email, email)).get()

这样有两个问题:

  1. 重复findByEmail 的查询逻辑散落在 Service 的多个方法里。表加了 deletedAt 软删除字段后,所有查询都要改,漏一处就会出现脏数据
  2. 耦合 — Service 知道数据库用的是 Drizzle、表结构长什么样。换 ORM 或换数据库,所有 Service 都要改

Repository 把这两个问题收在一个地方:

// src/repositories/user.ts
import { usersTable } from '../db/schema'
import { eq, isNull } from 'drizzle-orm'
import type { DB } from '../types'
 
export function createUserRepository(db: DB) {
  async function findByEmail(email: string) {
    return db
      .select()
      .from(usersTable)
      .where(and(eq(usersTable.email, email), isNull(usersTable.deletedAt)))
      .get()
  }
 
  async function findById(id: number) {
    return db
      .select()
      .from(usersTable)
      .where(and(eq(usersTable.id, id), isNull(usersTable.deletedAt)))
      .get()
  }
 
  return { findByEmail, findById }
}

Service 层调用 userRepository.findByEmail(email)。表结构变了、ORM 换了、软删除规则改了,只改 Repository 这一个文件。

2. Repository 的职责边界

判断一段代码是否属于 Repository,可以问一个问题:这段代码是在构造 SQL 查询,还是在执行业务规则?

属于 Repository 的职责:

  • 构造和执行数据库查询
  • 数据库行记录到业务类型的字段映射
  • 分页、排序、软删除过滤等所有查询都带上的条件
  • 提供 withTransaction 事务基础设施

不属于 Repository 的职责:

  • 业务规则校验(「邮箱是否已注册」属于 Service)
  • 跨表数据编排(「创建订单后扣减库存」属于 Service)
  • HTTP 相关处理(状态码、响应格式属于 Routes 层)
// ✅ Repository 只做数据访问
async function findByEmail(email: string) {
  return db.select().from(usersTable).where(eq(usersTable.email, email)).get()
}
 
// ❌ Repository 不应该做业务判断
async function findAvailableEmail(email: string) {
  const existing = await db.select().from(usersTable).where(eq(usersTable.email, email)).get()
  if (existing) {
    throw new BusinessError('EMAIL_TAKEN', '邮箱已被占用') // 属于 Service 层
  }
}

3. CRUD 操作封装

一个 Repository 通常包含五类基础操作:

// src/repositories/user.ts
export function createUserRepository(db: DB) {
  // --- 查询 ---
  async function findById(id: number) {
    return db.select().from(usersTable)
      .where(and(eq(usersTable.id, id), isNull(usersTable.deletedAt)))
      .get()
  }
 
  async function findMany(params: { limit?: number; offset?: number; role?: string }) {
    const { limit = 20, offset = 0, role } = params
    const conditions = [isNull(usersTable.deletedAt)]
    if (role) conditions.push(eq(usersTable.role, role))
 
    return db.select().from(usersTable)
      .where(and(...conditions))
      .orderBy(desc(usersTable.createdAt))
      .limit(limit).offset(offset)
  }
 
  // --- 写入 ---
  async function create(data: { email: string; password: string; role?: string }) {
    const result = await db.insert(usersTable)
      .values({ ...data, role: data.role ?? 'user' })
      .returning()
    return result[0]
  }
 
  // --- 更新 ---
  async function update(id: number, data: Partial<{ email: string; role: string }>) {
    const result = await db.update(usersTable)
      .set({ ...data, updatedAt: new Date() })
      .where(eq(usersTable.id, id))
      .returning()
    return result[0]
  }
 
  // --- 删除 ---
  async function softDelete(id: number) {
    return db.update(usersTable).set({ deletedAt: new Date() })
      .where(eq(usersTable.id, id)).returning()
  }
 
  return { findById, findMany, create, update, softDelete }
}

几个设计选择:

  1. 软删除优先softDelete 只设置 deletedAt,所有查询自动过滤已删除记录。物理删除(GDPR 合规)才用 hardDelete
  2. returning() 返回完整记录INSERTUPDATE 都用了 .returning(),调用方可以拿到自动生成的 idcreatedAt 等字段,不需要再查一次
  3. findMany 接收结构化参数 — 不传 WHERE 条件对象,而是传业务参数。Repository 内部决定怎么翻译成查询条件

4. 查询方法设计

用业务语言命名

// ❌ 暴露了 SQL 细节
async function selectWhereRoleAndStatus(role: string, status: string) { ... }
 
// ✅ 表达业务含义
async function findActiveUsersByRole(role: string) { ... }

控制方法数量

一个 Repository 暴露的方法数不宜超过 15-20 个。复杂查询(报表、统计、搜索)可以拆到单独的文件:

// src/repositories/user-query.ts
export function createUserQueryRepository(db: DB) {
  async function searchByKeyword(keyword: string, options: { limit?: number }) {
    // 多字段模糊搜索
    return db.select().from(usersTable)
      .where(and(isNull(usersTable.deletedAt),
        or(like(usersTable.email, `%${keyword}%`), like(usersTable.name, `%${keyword}%`))
      ))
      .limit(options.limit ?? 20)
  }
 
  async function getRegistrationStats(days: number) {
    // 聚合查询:按天统计注册人数
    return db.select({
      date: sql<string>`date(created_at)`,
      count: sql<number>`count(*)`,
    }).from(usersTable)
      .where(sql`created_at >= datetime('now', '-${days} days')`)
      .groupBy(sql`date(created_at)`)
  }
 
  return { searchByKeyword, getRegistrationStats }
}

不要把 Drizzle 查询对象暴露给上层

// ❌ Service 还需要自己调用 .get() 执行查询
function buildUserQuery() {
  return db.select().from(usersTable).where(isNull(usersTable.deletedAt))
}
 
// ✅ 直接返回结果对象
async function findById(id: number) {
  return db.select().from(usersTable).where(eq(usersTable.id, id)).get()
}

5. 事务处理

06 提到事务边界由 Service 层控制。Repository 层要做的是暴露 withTransaction 方法:

// src/repositories/user.ts
export function createUserRepository(db: DB) {
  // ... CRUD 方法 ...
 
  async function withTransaction<T>(fn: (tx: DB) => Promise<T>): Promise<T> {
    return db.transaction(fn) // 异常时自动 ROLLBACK,正常返回自动 COMMIT
  }
 
  return { findById, create, update, softDelete, withTransaction }
}

每个方法接受一个可选的事务参数:

// src/repositories/account.ts
export function createAccountRepository(db: DB) {
  async function findById(id: number, tx?: DB) {
    const executor = tx ?? db // 有 tx 用 tx,没有用 db
    return executor.select().from(accountsTable).where(eq(accountsTable.id, id)).get()
  }
 
  async function deduct(id: number, amount: number, tx?: DB) {
    const executor = tx ?? db
    return executor.update(accountsTable)
      .set({ balance: sql`${accountsTable.balance} - ${amount}` })
      .where(eq(accountsTable.id, id)).returning()
  }
 
  return { findById, deduct, withTransaction }
}

Service 层这样使用:

// src/services/transfer.ts
async function transfer(fromId: number, toId: number, amount: number) {
  return accountRepo.withTransaction(async (tx) => {
    const fromAccount = await accountRepo.findById(fromId, tx)
    if (!fromAccount || fromAccount.balance < amount) {
      throw new BusinessError('INSUFFICIENT_BALANCE', '余额不足')
    }
    await accountRepo.deduct(fromId, amount, tx)
    await accountRepo.add(toId, amount, tx)
  })
}

D1 的事务注意事项

  1. D1 基于 SQLite,不支持嵌套事务
  2. 事务内不缓存 Prepared Statement,避免在事务里循环上千条查询
  3. Drizzle 的 D1 驱动通过 db.transaction(fn) 自动处理 BEGIN/COMMIT/ROLLBACK

6. 错误处理

Repository 层的错误分两类。

预期内的结果 — 用返回值表达,不抛异常:

// ✅ 记录不存在时 .get() 返回 undefined,上层判断即可
const user = await userRepository.findById(999) // undefined
if (!user) return c.json({ error: 'not found' }, 404)

预期外的故障 — 抛出让上层处理:

async function create(data: { email: string; password: string }) {
  try {
    const result = await db.insert(usersTable).values(data).returning()
    return result[0]
  } catch (error) {
    // 唯一约束冲突——转成业务错误
    if (isUniqueConstraintError(error, 'email')) {
      throw new ConflictError('EMAIL_ALREADY_EXISTS', '该邮箱已被注册')
    }
    // 其他错误原样上抛,不在 Repository 层吞掉
    throw error
  }
}

Repository 把 D1 的 SQLITE_CONSTRAINT_UNIQUE 转成 ConflictError,Service 层不需要知道数据库的错误码体系。只转换明确知道的错误类型,未知错误原样向上抛。

7. 与 Service 层的协作

Service 不持有数据库连接

Service 通过闭包拿到 Repository 实例,不直接接触 db 对象:

// ✅ Service 只和 Repository 打交道
export function createUserService(deps: {
  userRepo: ReturnType<typeof createUserRepository>
}) {
  const { userRepo } = deps
 
  async function register(email: string, password: string) {
    const existing = await userRepo.findByEmail(email)
    if (existing) throw new BusinessError('EMAIL_ALREADY_EXISTS', '该邮箱已被注册')
    const hashedPassword = await hashPassword(password)
    return userRepo.create({ email, password: hashedPassword })
  }
 
  return { register }
}

数据转换在哪里做

  • 结构性字段映射(合并字段、类型转换、去掉敏感字段)放在 Repository 层——和数据库结构直接相关
  • 业务性数据组装(根据角色过滤返回字段、关联其他服务的数据)放在 Service 层

在入口组装依赖

// src/container.ts
export function createContainer(db: DB) {
  const userRepository = createUserRepository(db)
  const postRepository = createPostRepository(db)
  const userService = createUserService({ userRepo: userRepository })
  const postService = createPostService({ postRepo: postRepository, userRepo: userRepository })
  return { userService, postService }
}

8. 测试 Repository

Repository 的测试需要真实数据库。mock 掉 ORM 等于跳过了 SQL 生成和执行,测不到查询逻辑本身。

D1 的底层是 SQLite,测试时可以直接用本地 SQLite 模拟:

// tests/repositories/user.test.ts
import { describe, it, expect, beforeEach } from 'vitest'
import { drizzle } from 'drizzle-orm/better-sqlite3'
import Database from 'better-sqlite3'
import { createUserRepository } from '../../src/repositories/user'
 
describe('UserRepository', () => {
  let userRepo: ReturnType<typeof createUserRepository>
 
  beforeEach(() => {
    // 每次测试创建干净的内存数据库
    const sqlite = new Database(':memory:')
    sqlite.exec(`CREATE TABLE users (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      email TEXT NOT NULL UNIQUE, password TEXT NOT NULL,
      role TEXT DEFAULT 'user', created_at TEXT DEFAULT (datetime('now')),
      updated_at TEXT DEFAULT (datetime('now')), deleted_at TEXT
    )`)
    userRepo = createUserRepository(drizzle(sqlite))
  })
 
  it('create 插入记录并返回完整数据', async () => {
    const user = await userRepo.create({ email: '[email protected]', password: 'hashed' })
    expect(user.id).toBe(1)
    expect(user.role).toBe('user')
  })
 
  it('findByEmail 找不到时返回 undefined', async () => {
    expect(await userRepo.findByEmail('[email protected]')).toBeUndefined()
  })
 
  it('softDelete 后 findByEmail 查不到', async () => {
    const user = await userRepo.create({ email: '[email protected]', password: 'hashed' })
    await userRepo.softDelete(user.id)
    expect(await userRepo.findByEmail('[email protected]')).toBeUndefined()
  })
})

注意:每个测试用独立的内存数据库,避免测试之间互相污染。

9. 常见反模式

胖 Repository

Repository 里出现业务规则判断:

// ❌ 「30 天内有登录」是业务规则,应该由 Service 传入参数
async function findActivePremiumUsers() {
  return db.select().from(usersTable).where(and(
    eq(usersTable.status, 'active'),
    eq(usersTable.plan, 'premium'),
    gt(usersTable.lastLoginAt, sql`datetime('now', '-30 days')`) // 业务规则
  ))
}
 
// ✅ Repository 接收参数,Service 决定规则
async function findMany(params: { status?: string; lastLoginAfter?: Date }) {
  const conditions = [isNull(usersTable.deletedAt)]
  if (params.status) conditions.push(eq(usersTable.status, params.status))
  if (params.lastLoginAfter) conditions.push(gt(usersTable.lastLoginAt, params.lastLoginAfter))
  return db.select().from(usersTable).where(and(...conditions))
}

上帝 Repository

一个 Repository 管多张表、方法超过 20 个。每张表应该对应一个独立的 Repository,跨表组装放在 Service 层。

Repository 互相调用

Repository 应该只和数据库打交道,不和另一个 Repository 通信。跨表数据组装是 Service 层的职责。

总结

Repositories 层的角色是「数据访问的隔离带」。它把 Service 层和数据库隔开——Service 不知道底层用的是 Drizzle 还是别的 ORM,不知道表结构长什么样。Repository 不知道业务规则、不管 HTTP 协议、不做跨表编排,只专注于把数据从数据库里正确地读出来、写进去。

几个关键判断:

  1. 归属判断 — 在构造 SQL 查询的代码属于 Repository,在执行业务规则的代码属于 Service
  2. 接口方向 — Repository 面向 Service 定义方法签名,Service 看到的是 findByEmail 而非 db.select().where(eq(...))
  3. 事务支持 — Repository 提供 withTransaction 基础设施,事务边界由 Service 层控制
  4. 错误分类 — 查无记录返回 null,约束冲突转成业务错误类,其他错误原样上抛
  5. 测试策略 — 用真实数据库测试查询逻辑,不用 mock 跳过 SQL 执行