本文 AI 產出,尚未審核

ExpressJS (TypeScript) – 架構進階模式

主題:Repository Pattern


簡介

在大型 Node.js / Express 專案中,資料存取的邏輯往往散落在各個服務、控制器或中間件裡。當需求變更(例如換成另一套資料庫、加入快取或實作軟刪除)時,若沒有明確的抽象層,必須在多處同時修改程式碼,維護成本會急速上升。

Repository Pattern(資料倉儲模式)正是為了解耦「業務層」與「資料層」而設計的。它提供一組「領域」專屬的 CRUD 介面,讓上層只需要關心 「我要做什麼」,而不必顧慮 「資料到底怎麼取得」。在 TypeScript 加上 Express 的環境裡,我們可以利用介面、泛型與依賴注入(DI)把這個模式落實得既安全又可測。

本文將從概念出發,示範如何在 Express + TypeScript 專案中實作 Repository Pattern,並說明常見陷阱、最佳實踐與實務應用場景,讓你在日後的專案中能快速上手、降低耦合、提升可測性。


核心概念

1. Repository 是什麼?

簡單來說,Repository 就是一個 「集合」 的抽象,提供 Domain Entity(領域實體)相關的資料操作方法。它的職責僅限於:

  • 取得(find、findOne)
  • 新增(create)
  • 更新(update)
  • 刪除(delete)

重點:Repository 不應該 包含業務規則、驗證或 API 回傳格式的處理,這些工作交給 Service 或 Controller 完成。

2. 為什麼要使用 Repository?

無 Repository 的情況 使用 Repository 的好處
直接在 Service 中寫 UserModel.find()、db.query() 等 單一職責:Service 只負責業務,Repository 處理資料
換資料庫(Mongo → PostgreSQL)需要改很多檔案 可替換性:只要換掉 Repository 實作,其他層不受影響
單元測試時難以 mock DB 呼叫 易測試:可以用假 Repository(In‑Memory)替代真實 DB
資料存取程式碼散落在多個檔案 集中管理:所有 CRUD 都在同一個介面/類別中

3. 基本介面設計

以下示範一個 泛型 Repository,讓不同 Entity 都能共用同一套 CRUD 方法。

// src/repositories/IRepository.ts
export interface IRepository<T, K = number> {
  /** 取得全部資料 */
  findAll(): Promise<T[]>;

  /** 依主鍵取得單筆 */
  findById(id: K): Promise<T | null>;

  /** 新增資料 */
  create(item: Partial<T>): Promise<T>;

  /** 更新資料 */
  update(id: K, item: Partial<T>): Promise<T>;

  /** 刪除資料 */
  delete(id: K): Promise<void>;
}
  • T 代表實體型別,K 為主鍵型別(預設 number)。
  • 使用 Partial<T> 讓呼叫端只需提供要變更的欄位。

4. 具體實作:以 TypeORM 為例

下面以 TypeORM 為底層 ORM,實作 UserRepository。如果未來改用 Prisma、Mongoose,只要改寫此檔即可。

// src/entities/User.ts
import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';

@Entity('users')
export class User {
  @PrimaryGeneratedColumn()
  id!: number;

  @Column({ unique: true })
  email!: string;

  @Column()
  name!: string;

  @Column({ default: true })
  isActive!: boolean;
}
// src/repositories/UserRepository.ts
import { Repository, DataSource } from 'typeorm';
import { User } from '../entities/User';
import { IRepository } from './IRepository';

export class UserRepository implements IRepository<User> {
  private ormRepo: Repository<User>;

  constructor(dataSource: DataSource) {
    this.ormRepo = dataSource.getRepository(User);
  }

  async findAll(): Promise<User[]> {
    return this.ormRepo.find();
  }

  async findById(id: number): Promise<User | null> {
    return this.ormRepo.findOneBy({ id }) ?? null;
  }

  async create(item: Partial<User>): Promise<User> {
    const user = this.ormRepo.create(item);
    return this.ormRepo.save(user);
  }

  async update(id: number, item: Partial<User>): Promise<User> {
    await this.ormRepo.update(id, item);
    const updated = await this.findById(id);
    if (!updated) throw new Error('User not found after update');
    return updated;
  }

  async delete(id: number): Promise<void> {
    await this.ormRepo.delete(id);
  }
}

小技巧:在 update 中先執行 update 再重新查一次,確保回傳的是最新的實體,避免直接回傳 UpdateResult 造成使用者混淆。

5. 結合依賴注入(DI)

Express 本身沒有 DI 機制,但可以藉由簡易的 Service Container 或第三方套件(如 typedi、inversify)完成。下面示範使用 typedi:

// src/container.ts
import { Container } from 'typedi';
import { DataSource } from 'typeorm';
import { UserRepository } from './repositories/UserRepository';

// 假設 dataSource 已在程式啟動時建立
export const initContainer = (dataSource: DataSource) => {
  Container.set('UserRepository', new UserRepository(dataSource));
};

在路由或 Service 中直接注入:

// src/services/UserService.ts
import { Service, Inject } from 'typedi';
import { IRepository } from '../repositories/IRepository';
import { User } from '../entities/User';

@Service()
export class UserService {
  constructor(
    @Inject('UserRepository') private readonly userRepo: IRepository<User>
  ) {}

  async getAllUsers() {
    return this.userRepo.findAll();
  }

  async getUser(id: number) {
    const user = await this.userRepo.findById(id);
    if (!user) throw new Error('User not found');
    return user;
  }

  // 其他業務邏輯…
}

6. 在 Express 路由中使用

// src/routes/user.routes.ts
import { Router, Request, Response, NextFunction } from 'express';
import { Container } from 'typedi';
import { UserService } from '../services/UserService';

const router = Router();
const userService = Container.get(UserService);

router.get('/', async (req: Request, res: Response, next: NextFunction) => {
  try {
    const users = await userService.getAllUsers();
    res.json(users);
  } catch (err) {
    next(err);
  }
});

router.get('/:id', async (req: Request, res: Response, next: NextFunction) => {
  try {
    const user = await userService.getUser(Number(req.params.id));
    res.json(user);
  } catch (err) {
    next(err);
  }
});

export default router;
  • Controller 只負責 HTTP 協定與回傳格式,所有資料操作都委派給 Service,而 Service 再透過 Repository 完成。

常見陷阱與最佳實踐

陷阱 說明 解法 / Best Practice
把業務邏輯塞進 Repository 會導致 Repository 變得難以測試,且不符合單一職責原則。 僅保留 CRUD,所有驗證、計算、流程控制放在 Service。
Repository 直接回傳 ORM 實體 若外部程式碼直接改變實體,可能繞過 Repository 的控制。 回傳 DTO 或 深拷貝,或在 Service 層做映射。
過度抽象 為每一個小表格都寫一套 Repository,代碼膨脹。 針對 共通行為 使用 BaseRepository,僅在特殊情況才自訂。
忽略 Transaction(交易) 多個 Repository 操作需要原子性時,若不使用 Transaction,資料不一致。 引入 Unit of Work(或使用 TypeORM QueryRunner)統一管理 Transaction。
未妥善處理錯誤 直接拋出 ORM 錯誤會讓 API 回傳不友善訊息。 在 Repository 捕獲錯誤並拋出自訂的 DomainError,讓上層統一處理。

建議的程式結構

src/
 ├─ entities/          # TypeORM / Prisma 實體
 ├─ repositories/      # IRepository、BaseRepository、各 Domain Repository
 ├─ services/          # 業務 Service
 ├─ controllers/       # Express Router / Controller
 ├─ middlewares/       # 錯誤處理、驗證
 ├─ container.ts       # DI 設定
 └─ app.ts              # Express 初始化

使用 BaseRepository 減少重複

// src/repositories/BaseRepository.ts
import { Repository, DataSource } from 'typeorm';
import { IRepository } from './IRepository';

export abstract class BaseRepository<T, K = number> implements IRepository<T, K> {
  protected ormRepo: Repository<T>;

  constructor(entity: { new (): T }, dataSource: DataSource) {
    this.ormRepo = dataSource.getRepository(entity);
  }

  findAll(): Promise<T[]> {
    return this.ormRepo.find();
  }

  findById(id: K): Promise<T | null> {
    return (this.ormRepo.findOneBy({ id } as any) as Promise<T | null>);
  }

  async create(item: Partial<T>): Promise<T> {
    const entity = this.ormRepo.create(item);
    return this.ormRepo.save(entity);
  }

  async update(id: K, item: Partial<T>): Promise<T> {
    await this.ormRepo.update(id as any, item);
    const updated = await this.findById(id);
    if (!updated) throw new Error('Entity not found after update');
    return updated;
  }

  async delete(id: K): Promise<void> {
    await this.ormRepo.delete(id as any);
  }
}

只要繼承 BaseRepository,即可快速產生新 Repository,減少樣板程式碼。


實際應用場景

場景 為什麼適合使用 Repository Pattern
多資料來源(SQL + NoSQL) 每個資料來源都有各自的 Repository,Service 只需要聚合結果。
需要單元測試 用 InMemoryUserRepository 取代真實 DB,測試 Service 的業務邏輯。
切換 ORM(例如從 TypeORM 換成 Prisma) 只改 UserRepository 實作,Controller、Service 完全不受影響。
實作軟刪除 在 Repository 中統一加入 where: { isDeleted: false } 條件,避免每個 Service 重複寫。
批次作業或事務(例如下單流程) 透過 Unit of Work 包裹多個 Repository,確保整個流程在同一個 Transaction 內。

範例:使用 In‑Memory Repository 進行測試

// test/mocks/InMemoryUserRepository.ts
import { IRepository } from '../../src/repositories/IRepository';
import { User } from '../../src/entities/User';

export class InMemoryUserRepository implements IRepository<User> {
  private store = new Map<number, User>();
  private autoId = 1;

  async findAll(): Promise<User[]> {
    return Array.from(this.store.values());
  }

  async findById(id: number): Promise<User | null> {
    return this.store.get(id) ?? null;
  }

  async create(item: Partial<User>): Promise<User> {
    const user: User = {
      id: this.autoId++,
      email: item.email ?? '',
      name: item.name ?? '',
      isActive: item.isActive ?? true,
    } as User;
    this.store.set(user.id, user);
    return user;
  }

  async update(id: number, item: Partial<User>): Promise<User> {
    const existing = await this.findById(id);
    if (!existing) throw new Error('User not found');
    const updated = { ...existing, ...item };
    this.store.set(id, updated);
    return updated;
  }

  async delete(id: number): Promise<void> {
    this.store.delete(id);
  }
}

在測試中:

// test/UserService.test.ts
import { UserService } from '../src/services/UserService';
import { InMemoryUserRepository } from './mocks/InMemoryUserRepository';

describe('UserService', () => {
  const repo = new InMemoryUserRepository();
  const service = new UserService(repo);

  it('should create a user', async () => {
    const user = await service.getAllUsers(); // 先取得空陣列
    expect(user).toHaveLength(0);

    const newUser = await service['userRepo'].create({
      email: 'test@example.com',
      name: 'Test User',
    });
    expect(newUser.id).toBeGreaterThan(0);
  });
});

透過 Repository Pattern,測試不再依賴實體資料庫,執行速度快且不受外部環境影響。


總結

  • Repository Pattern 為 Express + TypeScript 專案提供 乾淨的資料抽象層,讓業務邏輯與資料存取徹底分離。
  • 透過 介面 + 泛型 的設計,我們可以一次寫出可重用、可測試的 CRUD 基礎,並在需要時針對特定 Entity 擴充自訂方法。
  • 依賴注入(DI)、BaseRepository 與 Unit of Work 等技巧,使得整體架構更具彈性,未來換資料庫或加入交易都不會影響上層程式。
  • 常見陷阱包括把業務邏輯塞進 Repository、忘記處理 Transaction、以及過度抽象。遵循 單一職責、錯誤統一處理 與 DTO 映射 等最佳實踐,可讓系統更健壯、可維護。
  • 在實務上,從 多資料來源、單元測試、軟刪除 到 批次事務,Repository Pattern 都能提供一致且可預測的開發體驗。

掌握了這套模式,你的 Express + TypeScript 專案將會更具可讀性、可測性與可擴充性。從今天開始,為每個 Domain Entity 建立專屬的 Repository,讓程式碼的「意圖」與「實作」分工更清晰,開發效率自然提升。祝你寫程式順利 🚀!