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,讓程式碼的「意圖」與「實作」分工更清晰,開發效率自然提升。祝你寫程式順利 🚀!