本文 AI 產出,尚未審核

ExpressJS (TypeScript) – 架構進階模式

主題:Repository Pattern


簡介

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

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

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


核心概念

1. Repository 是什麼?

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

  • 取得(findfindOne
  • 新增(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 或第三方套件(如 typediinversify)完成。下面示範使用 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)BaseRepositoryUnit of Work 等技巧,使得整體架構更具彈性,未來換資料庫或加入交易都不會影響上層程式。
  • 常見陷阱包括把業務邏輯塞進 Repository、忘記處理 Transaction、以及過度抽象。遵循 單一職責錯誤統一處理DTO 映射 等最佳實踐,可讓系統更健壯、可維護。
  • 在實務上,從 多資料來源單元測試軟刪除批次事務,Repository Pattern 都能提供一致且可預測的開發體驗。

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