TypeScript 實務開發與架構應用 — Repository Pattern 型別實作
簡介
在大型 TypeScript 專案中,資料存取的抽象化是維持程式碼可讀性、可測試性與可維護性的關鍵。
Repository Pattern(資料庫倉儲模式)提供了一層介於領域模型與資料來源(如資料庫、API、檔案系統)的抽象,使業務邏輯不必直接耦合於具體的 CRUD 實作。
本篇文章將說明如何在 TypeScript 中以 型別安全 的方式實作 Repository Pattern,從基礎介面、通用抽象類別,到具體的 MongoDB、PostgreSQL、In‑Memory 實作,並分享常見陷阱與最佳實踐,讓你在實務開發中快速上手、降低重構成本。
核心概念
1. 為什麼要使用 Repository?
- 分離關注點:領域服務只關心「要做什麼」,不必知道「怎麼存取」。
- 易於單元測試:可以以 mock repository 替代真實資料庫,測試不受 I/O 影響。
- 支援多種資料來源:同一套業務邏輯可同時支援 MySQL、MongoDB、REST API 等。
2. 基本介面定義
在 TypeScript 中,我們先以 泛型 來描述最常見的 CRUD 操作。以下範例展示 IRepository<T, K>,其中 T 為實體型別,K 為主鍵型別(如 string、number)。
// IRepository.ts
export interface IRepository<T, K> {
/** 取得單筆資料 */
findById(id: K): Promise<T | null>;
/** 取得多筆資料,支援簡易過濾 */
find(filter?: Partial<T>): Promise<T[]>;
/** 新增資料,回傳完整的實體(含自動生成欄位) */
create(item: T): Promise<T>;
/** 更新資料,回傳更新後的實體 */
update(id: K, item: Partial<T>): Promise<T | null>;
/** 刪除資料,成功回傳 true */
delete(id: K): Promise<boolean>;
}
Partial<T>:允許只傳入需要更新的欄位,保持型別安全。- 回傳值皆為
Promise:因為大多數資料來源皆是非同步操作。
3. 通用抽象類別(BaseRepository)
若多個資料庫的實作有相似的邏輯(例如 findById 只是一個 find 包裝),可以把共用程式碼抽到抽象類別中。
// BaseRepository.ts
import { IRepository } from "./IRepository";
export abstract class BaseRepository<T, K> implements IRepository<T, K> {
abstract findById(id: K): Promise<T | null>;
abstract find(filter?: Partial<T>): Promise<T[]>;
abstract create(item: T): Promise<T>;
abstract update(id: K, item: Partial<T>): Promise<T | null>;
abstract delete(id: K): Promise<boolean>;
/** 便利方法:檢查資料是否存在 */
async exists(id: K): Promise<boolean> {
const entity = await this.findById(id);
return !!entity;
}
}
重點:抽象類別不會直接依賴任何資料庫套件,保持「純粹」的型別層。
4. 具體實作範例
以下示範三種常見的 Repository 實作,分別對應 MongoDB、PostgreSQL(使用 TypeORM) 以及 In‑Memory(單元測試用)。
4.1 MongoDB Repository
// MongoUserRepository.ts
import { Collection, ObjectId } from "mongodb";
import { BaseRepository } from "./BaseRepository";
import { User } from "./entities/User";
export class MongoUserRepository extends BaseRepository<User, string> {
private readonly coll: Collection<User>;
constructor(collection: Collection<User>) {
super();
this.coll = collection;
}
async findById(id: string): Promise<User | null> {
return this.coll.findOne({ _id: new ObjectId(id) } as any);
}
async find(filter: Partial<User> = {}): Promise<User[]> {
return this.coll.find(filter as any).toArray();
}
async create(item: User): Promise<User> {
const result = await this.coll.insertOne(item);
// Mongo 會自動產生 _id,回傳時補回去
return { ...item, _id: result.insertedId.toHexString() };
}
async update(id: string, item: Partial<User>): Promise<User | null> {
const { value } = await this.coll.findOneAndUpdate(
{ _id: new ObjectId(id) } as any,
{ $set: item },
{ returnDocument: "after" }
);
return value ?? null;
}
async delete(id: string): Promise<boolean> {
const { deletedCount } = await this.coll.deleteOne({
_id: new ObjectId(id),
} as any);
return deletedCount === 1;
}
}
- 型別安全:
User介面定義在entities/User.ts,所有 CRUD 都必須遵守。 - ObjectId 轉換:把字串
id轉成ObjectId,避免在呼叫端自行處理。
4.2 PostgreSQL Repository(使用 TypeORM)
// TypeOrmUserRepository.ts
import { Repository } from "typeorm";
import { BaseRepository } from "./BaseRepository";
import { User } from "./entities/User";
export class TypeOrmUserRepository extends BaseRepository<User, number> {
private readonly repo: Repository<User>;
constructor(repo: Repository<User>) {
super();
this.repo = repo;
}
async findById(id: number): Promise<User | null> {
return this.repo.findOneBy({ id });
}
async find(filter: Partial<User> = {}): Promise<User[]> {
return this.repo.findBy(filter);
}
async create(item: User): Promise<User> {
return this.repo.save(item);
}
async update(id: number, item: Partial<User>): Promise<User | null> {
await this.repo.update(id, item);
return this.findById(id);
}
async delete(id: number): Promise<boolean> {
const result = await this.repo.delete(id);
return result.affected === 1;
}
}
Repository<User>為 TypeORM 提供的資料存取物件,直接利用其方法即可完成 CRUD。- 回傳值:
save會自動回傳包含自增 ID 的完整實體。
4.3 In‑Memory Repository(測試專用)
// InMemoryUserRepository.ts
import { BaseRepository } from "./BaseRepository";
import { User } from "./entities/User";
export class InMemoryUserRepository extends BaseRepository<User, string> {
private readonly store = new Map<string, User>();
async findById(id: string): Promise<User | null> {
return this.store.get(id) ?? null;
}
async find(filter: Partial<User> = {}): Promise<User[]> {
const result: User[] = [];
for (const user of this.store.values()) {
let match = true;
for (const key in filter) {
if ((user as any)[key] !== (filter as any)[key]) {
match = false;
break;
}
}
if (match) result.push(user);
}
return result;
}
async create(item: User): Promise<User> {
const id = (Math.random() * 1e9).toFixed(0);
const newUser = { ...item, id };
this.store.set(id, newUser);
return newUser;
}
async update(id: string, item: Partial<User>): Promise<User | null> {
const existing = await this.findById(id);
if (!existing) return null;
const updated = { ...existing, ...item };
this.store.set(id, updated);
return updated;
}
async delete(id: string): Promise<boolean> {
return this.store.delete(id);
}
}
- 適合單元測試:不需要外部資源,所有資料都保存在記憶體中。
- 使用
Map:讓findById、delete的時間複雜度為 O(1)。
5. 在 Service 中使用 Repository
// UserService.ts
import { IRepository } from "./IRepository";
import { User } from "./entities/User";
export class UserService {
constructor(private readonly userRepo: IRepository<User, string>) {}
async register(userDto: Omit<User, "id">): Promise<User> {
// 可能在此加入密碼雜湊、驗證等業務邏輯
const user = await this.userRepo.create({ ...userDto, id: "" });
return user;
}
async getProfile(id: string): Promise<User | null> {
return this.userRepo.findById(id);
}
async changeEmail(id: string, newEmail: string): Promise<User | null> {
return this.userRepo.update(id, { email: newEmail });
}
}
技巧:在
UserService中只依賴IRepository介面,讓實體儲存方式可以在程式啟動時自由切換(DI 容器、環境變數)。
常見陷阱與最佳實踐
| 常見問題 | 為何會發生 | 解決方式 |
|---|---|---|
| Repository 變得過於肥大 | 把所有查詢、統計、複雜業務寫在同一個 Repository | 把 Query Object 或 Specification 抽離,讓 Repository 只負責基本 CRUD。 |
| 忘記釋放資料庫連線 | 在測試或短暫腳本中直接建立 MongoClient、TypeORM 連線卻未呼叫 close() |
使用 依賴注入容器 或 finally 釋放連線;測試時可使用 afterAll 清理。 |
| 主鍵型別不一致 | IRepository<User, string> 與實作使用 number,導致編譯錯誤 |
在介面與實作上保持 相同的泛型參數,或建立 型別別名 如 type UserId = string; |
| 過度依賴 ORM 特性 | 在 Repository 中直接使用 QueryBuilder,導致切換資料庫困難 |
把 ORM‑specific 查詢封裝在 private helper,外部只看到 IRepository 定義。 |
| 忘記測試錯誤路徑 | 只測試成功的 CRUD,忽略 null、例外情況 |
為每個方法寫 成功 + 失敗 兩套測試,尤其是 findById 回傳 null 時的行為。 |
最佳實踐:
- 只暴露必要方法:若領域不需要
delete,不要在介面中定義它。 - 使用 DTO:在 Service 與 Repository 之間傳遞 Data Transfer Object,避免直接暴露實體。
- 遵守 SOLID:尤其是 單一職責(SRP)與 依賴反轉(DIP),讓 Repository 成為可插拔的組件。
- 加上 TypeScript 的
readonly:對於不會被修改的屬性(如id)使用readonly,提升型別安全。 - 利用泛型的條件型別:若需要針對不同主鍵型別提供不同的輔助方法,可使用
K extends string | number進行條件分支。
實際應用場景
微服務間共享資料存取層
多個微服務都需要存取同一套User資料表,透過共用的UserRepository(介面 + 多種實作),每個服務只要注入對應的實作即可,減少重複程式碼。多資料庫遷移
想從 MySQL 遷移到 PostgreSQL 時,只需要實作PostgresUserRepository,而不必改動任何業務服務(UserService),因為服務只依賴IRepository。測試驅動開發(TDD)
在單元測試階段,使用InMemoryUserRepository取代真實資料庫,測試速度提升 10 倍以上,同時保留完整的型別檢查。CQRS(Command Query Responsibility Segregation)
為讀取操作建立只讀的UserReadRepository(例如使用 ElasticSearch),寫入操作仍使用UserWriteRepository,兩者皆遵循相同的IRepository介面,保持一致性。
總結
Repository Pattern 在 TypeScript 中的實作,核心在於 以泛型介面抽象 CRUD,再透過 抽象類別 提供共用工具,最後根據不同的資料來源(MongoDB、PostgreSQL、In‑Memory)撰寫具體實作。
透過這樣的層級劃分,我們可以:
- 保持程式碼乾淨:業務邏輯不會被資料存取細節污染。
- 提升可測試性:輕鬆切換成 mock 或 in‑memory repository。
- 支援未來擴充:新增資料庫或改變儲存方式,只需新增或修改少量程式碼。
實務建議:在新專案建立時即規劃好
IRepository與BaseRepository,並在 DI 容器中註冊相應實作,讓整個系統從一開始就具備良好的架構基礎。
祝你在 TypeScript 專案中運用 Repository Pattern,寫出更可維護、可測試且易擴充的程式碼!