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」,而在:

  1. Transaction scope 不清楚:兩個 ORM 操作應該是原子的,但 Service 層不知道怎麼管 transaction
  2. 單元測試難:要測 UserService 就要有真實的 DB,否則 User.create 會噴錯
  3. 換 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 需要跟著調整。兩個解法:

  1. PgBouncer:在應用和 DB 之間加 connection pooler,pool 管理交給 PgBouncer,應用的 pool size 可以設小(2–5)
  2. 固定 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
FastAPIDepends() 注入SQLAlchemy Session 傳入
NestJS@InjectRepository()TypeORM @Transaction() 或手動 EntityManager
Spring Boot@Repository bean@Transactional annotation(AOP proxy)
Laravel慣例 Repository patternDB::transaction() closure

Spring 的 @Transactional 要注意一個陷阱:private method 上的 @Transactional 無效,因為 Spring 的 transaction 是透過 AOP proxy 實作的,proxy 無法攔截 private method 呼叫。


延伸閱讀