Service 直接呼叫 ORM 的問題
// ❌ 最常見的寫法:Service 直接用 ORM
class UserService {
async createUserWithProfile(dto: CreateUserDto) {
const user = await User.create({ name: dto.name, email: dto.email });
await Profile.create({ userId: user.id, bio: dto.bio });
// 問題:這兩個操作應該在同一個 transaction,但沒有
// 如果 Profile.create 失敗,User 已經寫進去了
return user;
}
}問題不在「用了 ORM」,而在:
- Transaction scope 不清楚:兩個 ORM 操作應該是原子的,但 Service 層不知道怎麼管 transaction
- 單元測試難:要測
UserService就要有真實的 DB,否則User.create會噴錯 - 換 ORM 成本高:如果哪天要從 Sequelize 換 Prisma,
User.create散在所有 Service 裡面
Repository Pattern
Repository 在 Service 和 ORM 之間加一層,讓 Service 只知道「我要什麼資料」,不知道「資料怎麼存取」:
// Repository interface(定義契約)
interface UserRepository {
findById(id: string): Promise<User | null>;
findByEmail(email: string): Promise<User | null>;
create(data: CreateUserData): Promise<User>;
update(id: string, data: UpdateUserData): Promise<User>;
delete(id: string): Promise<void>;
}
// 實作(Sequelize 版本)
class SequelizeUserRepository implements UserRepository {
async findById(id: string): Promise<User | null> {
const model = await UserModel.findByPk(id);
return model ? this.toDomain(model) : null;
}
async create(data: CreateUserData): Promise<User> {
const model = await UserModel.create(data);
return this.toDomain(model);
}
private toDomain(model: UserModel): User {
return { id: model.id, name: model.name, email: model.email };
}
}
// Service:只依賴 interface,不知道背後是 Sequelize 還是 Prisma
class UserService {
constructor(private userRepo: UserRepository) {}
async findById(id: string): Promise<User> {
const user = await this.userRepo.findById(id);
if (!user) throw new NotFoundError(`User ${id} not found`);
return user;
}
}測試時換 mock:
// Unit test:不碰 DB
const mockRepo: jest.Mocked<UserRepository> = {
findById: jest.fn().mockResolvedValue(null),
// ...
};
const service = new UserService(mockRepo);
await expect(service.findById('999')).rejects.toThrow(NotFoundError);Transaction Scope 設計
Transaction 的問題是:誰負責開始、誰負責提交?
原則:Transaction 屬於 Service 層,Repository 加入它,不要自己開。
// ✅ Service 管 transaction scope
class UserService {
constructor(
private userRepo: UserRepository,
private profileRepo: ProfileRepository,
private db: Database, // transaction manager
) {}
async createUserWithProfile(dto: CreateUserDto): Promise<User> {
return this.db.transaction(async (trx) => {
// 把 transaction 往下傳
const user = await this.userRepo.create({ name: dto.name, email: dto.email }, trx);
await this.profileRepo.create({ userId: user.id, bio: dto.bio }, trx);
return user;
// 任何一步失敗 → 整個 transaction rollback
});
}
}
// Repository:接受可選的 transaction context
class SequelizeUserRepository implements UserRepository {
async create(data: CreateUserData, trx?: Transaction): Promise<User> {
const model = await UserModel.create(data, { transaction: trx });
return this.toDomain(model);
}
}常見陷阱:巢狀 transaction
// ❌ 在 transaction 裡又開 transaction
async function outer(db) {
await db.transaction(async (outerTrx) => {
await userService.createUser(dto); // createUser 裡又 db.transaction()
// 行為取決於 DB:MySQL 會 savepoint;PostgreSQL 預設會忽略內層
});
}解法:把 transaction context 往下傳,不要在 Service 內部再開 transaction。
Connection Pool 設計
Connection Pool 太大比太小更危險。
PostgreSQL 每個 connection 消耗約 5–10MB RAM。一個 Postgres 實例預設 max_connections = 100。如果你有 5 個 pod,每個 pool size 設 30,你就需要 150 個 connections——超過 Postgres 上限。
K8s 的 pool size 計算公式:
pool_size_per_pod = (DB max_connections - 5 reserved) / pod_count
例:max_connections=100, pod_count=5
pool_size_per_pod = (100 - 5) / 5 = 19
// Sequelize pool 設定
const sequelize = new Sequelize({
dialect: 'postgres',
pool: {
max: 10, // pool size(不是越大越好)
min: 2, // 保持至少 2 個 idle connection
acquire: 30000, // 等待 connection 的 timeout(ms)
idle: 10000, // connection idle 超過 10 秒就關掉
}
});HPA(Horizontal Pod Autoscaler)的問題:pod 數量動態變化,pool size 需要跟著調整。兩個解法:
- PgBouncer:在應用和 DB 之間加 connection pooler,pool 管理交給 PgBouncer,應用的 pool size 可以設小(2–5)
- 固定 pool size + 預留 buffer:保守計算最大 pod 數,pool size 設小一點,多的 connection 請求等待而不是失敗
N+1 Detection
N+1 是 ORM 的經典問題:查 100 個 user,然後每個 user 又去查一次 profile,變成 101 次查詢。
// ❌ N+1:這段 code 看起來沒問題,但執行了 N+1 次查詢
const users = await User.findAll(); // 1 次
for (const user of users) {
user.profile = await Profile.findOne({ where: { userId: user.id } }); // N 次
}
// ✅ 正確:eager loading
const users = await User.findAll({
include: [{ model: Profile }] // JOIN 一次搞定
});開發環境 N+1 偵測(Express/Sequelize):
// Sequelize 的 afterFind hook 可以用來統計
let queryCount = 0;
sequelize.addHook('beforeQuery', () => { queryCount++; });
// 在 integration test 或開發環境包住需要監控的操作
queryCount = 0;
const users = await userService.findAllWithProfiles();
if (queryCount > 2) {
console.warn(`N+1 detected: ${queryCount} queries for findAllWithProfiles`);
}Rails 有 bullet gem 自動偵測、Python 有 nplusone 套件自動偵測。Express/Node.js 沒有成熟的自動偵測工具,但約 50 行的 Sequelize hook 可以做到基本的 query count 監控。
更可靠的方法:在 integration test 裡斷言 query 數量:
it('findAllWithProfiles should not trigger N+1', async () => {
await seedUsers(10);
let queryCount = 0;
sequelize.addHook('beforeQuery', () => queryCount++);
await userService.findAllWithProfiles();
sequelize.removeAllHooks('beforeQuery');
expect(queryCount).toBeLessThanOrEqual(3); // 最多 3 次查詢(users + profiles + roles)
});各框架的 Repository 整合方式
| 框架 | Repository 整合 | Transaction 傳遞 |
|---|---|---|
| Express | 手動 DI,constructor 注入 | Sequelize transaction option 或 Knex trx |
| FastAPI | Depends() 注入 | SQLAlchemy Session 傳入 |
| NestJS | @InjectRepository() | TypeORM @Transaction() 或手動 EntityManager |
| Spring Boot | @Repository bean | @Transactional annotation(AOP proxy) |
| Laravel | 慣例 Repository pattern | DB::transaction() closure |
Spring 的 @Transactional 要注意一個陷阱:private method 上的 @Transactional 無效,因為 Spring 的 transaction 是透過 AOP proxy 實作的,proxy 無法攔截 private method 呼叫。
