本文 AI 產出,尚未審核

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 為主鍵型別(如 stringnumber)。

// 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:讓 findByIddelete 的時間複雜度為 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 ObjectSpecification 抽離,讓 Repository 只負責基本 CRUD。
忘記釋放資料庫連線 在測試或短暫腳本中直接建立 MongoClientTypeORM 連線卻未呼叫 close() 使用 依賴注入容器finally 釋放連線;測試時可使用 afterAll 清理。
主鍵型別不一致 IRepository<User, string> 與實作使用 number,導致編譯錯誤 在介面與實作上保持 相同的泛型參數,或建立 型別別名type UserId = string;
過度依賴 ORM 特性 在 Repository 中直接使用 QueryBuilder,導致切換資料庫困難 把 ORM‑specific 查詢封裝在 private helper,外部只看到 IRepository 定義。
忘記測試錯誤路徑 只測試成功的 CRUD,忽略 null、例外情況 為每個方法寫 成功 + 失敗 兩套測試,尤其是 findById 回傳 null 時的行為。

最佳實踐

  1. 只暴露必要方法:若領域不需要 delete,不要在介面中定義它。
  2. 使用 DTO:在 Service 與 Repository 之間傳遞 Data Transfer Object,避免直接暴露實體。
  3. 遵守 SOLID:尤其是 單一職責(SRP)與 依賴反轉(DIP),讓 Repository 成為可插拔的組件。
  4. 加上 TypeScript 的 readonly:對於不會被修改的屬性(如 id)使用 readonly,提升型別安全。
  5. 利用泛型的條件型別:若需要針對不同主鍵型別提供不同的輔助方法,可使用 K extends string | number 進行條件分支。

實際應用場景

  1. 微服務間共享資料存取層
    多個微服務都需要存取同一套 User 資料表,透過共用的 UserRepository(介面 + 多種實作),每個服務只要注入對應的實作即可,減少重複程式碼。

  2. 多資料庫遷移
    想從 MySQL 遷移到 PostgreSQL 時,只需要實作 PostgresUserRepository,而不必改動任何業務服務(UserService),因為服務只依賴 IRepository

  3. 測試驅動開發(TDD)
    在單元測試階段,使用 InMemoryUserRepository 取代真實資料庫,測試速度提升 10 倍以上,同時保留完整的型別檢查。

  4. CQRS(Command Query Responsibility Segregation)
    為讀取操作建立只讀的 UserReadRepository(例如使用 ElasticSearch),寫入操作仍使用 UserWriteRepository,兩者皆遵循相同的 IRepository 介面,保持一致性。


總結

Repository Pattern 在 TypeScript 中的實作,核心在於 以泛型介面抽象 CRUD,再透過 抽象類別 提供共用工具,最後根據不同的資料來源(MongoDB、PostgreSQL、In‑Memory)撰寫具體實作。

透過這樣的層級劃分,我們可以:

  • 保持程式碼乾淨:業務邏輯不會被資料存取細節污染。
  • 提升可測試性:輕鬆切換成 mock 或 in‑memory repository。
  • 支援未來擴充:新增資料庫或改變儲存方式,只需新增或修改少量程式碼。

實務建議:在新專案建立時即規劃好 IRepositoryBaseRepository,並在 DI 容器中註冊相應實作,讓整個系統從一開始就具備良好的架構基礎。

祝你在 TypeScript 專案中運用 Repository Pattern,寫出更可維護可測試易擴充的程式碼!