Repositories层设计
要点
- Repositories 层是 Service 层和数据库之间的隔离带——Service 不直接写 SQL,Repository 把数据访问封装成普通函数调用
- 每个 Repository 对应一张表,暴露
findById、create、update、delete、findMany等方法,方法签名用业务语言而非 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()。
这样有两个问题:
- 重复 —
findByEmail的查询逻辑散落在 Service 的多个方法里。表加了deletedAt软删除字段后,所有查询都要改,漏一处就会出现脏数据 - 耦合 — 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 }
}几个设计选择:
- 软删除优先 —
softDelete只设置deletedAt,所有查询自动过滤已删除记录。物理删除(GDPR 合规)才用hardDelete returning()返回完整记录 —INSERT和UPDATE都用了.returning(),调用方可以拿到自动生成的id、createdAt等字段,不需要再查一次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 的事务注意事项
- D1 基于 SQLite,不支持嵌套事务
- 事务内不缓存 Prepared Statement,避免在事务里循环上千条查询
- 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 协议、不做跨表编排,只专注于把数据从数据库里正确地读出来、写进去。
几个关键判断:
- 归属判断 — 在构造 SQL 查询的代码属于 Repository,在执行业务规则的代码属于 Service
- 接口方向 — Repository 面向 Service 定义方法签名,Service 看到的是
findByEmail而非db.select().where(eq(...)) - 事务支持 — Repository 提供
withTransaction基础设施,事务边界由 Service 层控制 - 错误分类 — 查无记录返回
null,约束冲突转成业务错误类,其他错误原样上抛 - 测试策略 — 用真实数据库测试查询逻辑,不用 mock 跳过 SQL 执行