本文 AI 產出,尚未審核

ExpressJS (TypeScript) – 使用 Express 與資料庫整合

主題:資料存取層(Repository / DAO)


簡介

Node.jsExpress 的專案中,直接在路由處理函式裡寫 SQL 或 ORM 操作會讓程式碼變得雜亂、難以測試,也不利於未來的功能擴充。
為了把 業務邏輯資料存取 明確分離,我們通常會在專案中加入 Repository(或 DAO)層。這層負責與資料庫互動,提供「純粹的 CRUD」介面,而上層的 Service、Controller 只需要呼叫這些介面即可。

使用 TypeScript 時,Repository 更能發揮型別系統的優勢:透過介面(Interface)定義資料模型與操作合約,編譯階段就能捕捉到錯誤,提升程式的可靠性與可讀性。本文將從概念說明、實作範例、常見陷阱與最佳實踐,帶你一步步建立乾淨、可測試的資料存取層。


核心概念

1. Repository / DAO 的定位

層級 主要職責
Controller 解析 HTTP 請求、回傳 HTTP 回應,僅負責協調 Service
Service 處理業務邏輯,可能會調用多個 Repository
Repository / DAO 直接與資料庫溝通,執行 CRUD,返回Domain ModelDTO
Entity / Model 資料庫結構的型別描述(ORM 實體或手寫介面)

重點:Repository 只關心「怎麼存取資料」,不牽涉「資料要怎麼用」的業務規則。

2. 介面(Interface)定義合約

在 TypeScript 中,我們先為每個資料表(或集合)定義 EntityRepository Interface,讓實作與使用者之間形成明確的合約。

// src/domain/user.entity.ts
export interface User {
  id: number;
  name: string;
  email: string;
  createdAt: Date;
}
// src/repositories/user.repository.interface.ts
import { User } from '../domain/user.entity';

export interface UserRepository {
  /** 取得單一使用者 */
  findById(id: number): Promise<User | null>;

  /** 取得所有使用者 */
  findAll(): Promise<User[]>;

  /** 新增使用者 */
  create(user: Omit<User, 'id' | 'createdAt'>): Promise<User>;

  /** 更新使用者 */
  update(id: number, user: Partial<User>): Promise<User>;

  /** 刪除使用者 */
  delete(id: number): Promise<void>;
}

透過介面,我們可以在單元測試時換成 Mock Repository,而不必真的連線資料庫。

3. 以 Prisma 為例的實作

以下示範如何把 Prisma(一個 TypeScript‑first 的 ORM)實作於 UserRepository

// src/repositories/prisma/user.repository.ts
import { PrismaClient, User as PrismaUser } from '@prisma/client';
import { UserRepository } from '../user.repository.interface';
import { User } from '../../domain/user.entity';

export class PrismaUserRepository implements UserRepository {
  private prisma: PrismaClient;

  constructor(prisma?: PrismaClient) {
    // 允許注入自訂的 PrismaClient,方便測試
    this.prisma = prisma ?? new PrismaClient();
  }

  async findById(id: number): Promise<User | null> {
    const user = await this.prisma.user.findUnique({ where: { id } });
    return user ? this.toDomain(user) : null;
  }

  async findAll(): Promise<User[]> {
    const users = await this.prisma.user.findMany();
    return users.map(this.toDomain);
  }

  async create(user: Omit<User, 'id' | 'createdAt'>): Promise<User> {
    const created = await this.prisma.user.create({
      data: { ...user },
    });
    return this.toDomain(created);
  }

  async update(id: number, user: Partial<User>): Promise<User> {
    const updated = await this.prisma.user.update({
      where: { id },
      data: user,
    });
    return this.toDomain(updated);
  }

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

  /** 內部工具:把 Prisma 的型別轉成 Domain Model */
  private toDomain(prismaUser: PrismaUser): User {
    return {
      id: prismaUser.id,
      name: prismaUser.name,
      email: prismaUser.email,
      createdAt: prismaUser.createdAt,
    };
  }
}

技巧toDomain 方法把 ORM 的實體抽象成我們自己的 Domain Model,避免上層被 ORM 的細節耦合。

4. 在 Service 中注入 Repository

// src/services/user.service.ts
import { UserRepository } from '../repositories/user.repository.interface';
import { User } from '../domain/user.entity';

export class UserService {
  constructor(private readonly userRepo: UserRepository) {}

  async getUserProfile(id: number): Promise<User | null> {
    // 只負責呼叫 Repository,任何驗證、授權邏輯可在此加入
    return this.userRepo.findById(id);
  }

  async registerUser(payload: Omit<User, 'id' | 'createdAt'>): Promise<User> {
    // 例:檢查 Email 是否已被使用
    const exists = await this.userRepo.findAll();
    if (exists.some(u => u.email === payload.email)) {
      throw new Error('Email already exists');
    }
    return this.userRepo.create(payload);
  }

  // 其他業務方法...
}

5. 在 Controller 中使用 Service

// src/controllers/user.controller.ts
import { Request, Response, NextFunction } from 'express';
import { UserService } from '../services/user.service';
import { PrismaUserRepository } from '../repositories/prisma/user.repository';

const userRepo = new PrismaUserRepository(); // 依賴注入可換成 Mock
const userService = new UserService(userRepo);

export const getUser = async (req: Request, res: Response, next: NextFunction) => {
  try {
    const id = Number(req.params.id);
    const user = await userService.getUserProfile(id);
    if (!user) return res.status(404).json({ message: 'User not found' });
    res.json(user);
  } catch (err) {
    next(err);
  }
};

export const createUser = async (req: Request, res: Response, next: NextFunction) => {
  try {
    const newUser = await userService.registerUser(req.body);
    res.status(201).json(newUser);
  } catch (err) {
    next(err);
  }
};

小結:從 Controller → Service → Repository 的呼叫鏈,形成 單向依賴,每層只關心自己的責任,程式碼更易於維護與測試。

6. 單元測試範例(使用 Jest)

// tests/user.service.spec.ts
import { UserService } from '../src/services/user.service';
import { UserRepository } from '../src/repositories/user.repository.interface';
import { User } from '../src/domain/user.entity';

const mockRepo = {
  findById: jest.fn(),
  findAll: jest.fn(),
  create: jest.fn(),
  update: jest.fn(),
  delete: jest.fn(),
} as unknown as UserRepository;

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

  it('should return user when id exists', async () => {
    const fakeUser: User = { id: 1, name: 'Tony', email: 'tony@example.com', createdAt: new Date() };
    mockRepo.findById = jest.fn().mockResolvedValue(fakeUser);

    const result = await service.getUserProfile(1);
    expect(result).toEqual(fakeUser);
    expect(mockRepo.findById).toHaveBeenCalledWith(1);
  });

  // 更多測試...
});

常見陷阱與最佳實踐

陷阱 可能的後果 建議的解決方式
把業務邏輯寫在 Repository 讓資料層變得龐雜、難以重用 僅保留 CRUD,所有驗證、計算放在 Service
直接在 Controller 中使用 ORM 失去抽象層,測試困難 透過 DI(Dependency Injection) 注入 Service/Repository
未使用型別或 any 編譯時無法捕捉錯誤,執行期易崩潰 為 Entity、DTO、Repository 都寫 interface,盡量避免 any
硬編碼資料庫連線 不易切換環境(dev / test / prod) 使用 dotenvconfig 套件,將連線資訊放在環境變數
忘記關閉資料庫連線 測試跑完後程式不會結束 在測試或應用關閉時呼叫 prisma.$disconnect()(或 ORM 相應的關閉方法)

最佳實踐總結

  1. 介面先行:先寫 EntityDTORepository Interface,再實作。
  2. 單一職責:Repository 只做資料存取,Service 處理業務,Controller 負責 HTTP。
  3. 依賴注入:使用建構子注入或 IoC 容器(如 tsyringe),讓測試可以輕鬆換成 Mock。
  4. 錯誤統一處理:在 Repository 捕捉 DB 錯誤,轉成自訂的 Domain Error,讓 Service 能根據錯誤類型作回應。
  5. 交易(Transaction)管理:若一個 Service 需要多個 Repository 同時成功,使用 ORM 提供的 transaction 機制,避免半完成的資料寫入。

實際應用場景

1. 多資料來源的聚合服務

假設一個電商平台同時使用 PostgreSQL 儲存商品資訊、MongoDB 儲存使用者瀏覽紀錄。
我們可以為每個資料庫各寫一套 Repository(PostgresProductRepositoryMongoLogRepository),在 Service 中組合它們,形成 聚合根,而 Controller 只要呼叫一次 Service 即可得到完整回應。

2. 讀寫分離與快取

在高流量的 API 中,讀取 常常佔大頭。可以在 Repository 的 find* 方法裡加入 Redis 快取層,寫入或更新時同時更新快取。這樣的「Cache‑Aside」策略只需要在 Repository 做一次實作,所有使用者都能受惠。

// Cache decorator example
async findById(id: number): Promise<User | null> {
  const cacheKey = `user:${id}`;
  const cached = await this.redis.get(cacheKey);
  if (cached) return JSON.parse(cached);

  const user = await this.prisma.user.findUnique({ where: { id } });
  if (user) await this.redis.set(cacheKey, JSON.stringify(user), 'EX', 3600);
  return user ? this.toDomain(user) : null;
}

3. 多租戶(SaaS)系統

對於 SaaS 產品,往往需要根據租戶(Tenant)切換資料庫或資料表。
在 Repository 中加入 tenantId 參數,或在建構子注入不同的 PrismaClient 實例,即可做到 資料隔離,而不必在每個 Service 裡重複寫切換邏輯。


總結

  • Repository / DAO 是將資料庫操作抽象化、模組化的關鍵層級,配合 TypeScript 的型別系統,可讓專案在 可維護性、可測試性 以及 未來擴充 上都有顯著提升。
  • 透過 介面定義合約依賴注入單一職責 的原則,我們能在 Express 專案中建立乾淨的「Controller → Service → Repository」流程。
  • 在實務上,Repository 不僅負責 CRUD,還可以加入 快取、交易、跨資料庫聚合 等進階功能,讓整體系統更具彈性與效能。

掌握了資料存取層的設計模式,接下來只要把 業務邏輯 放在 Service,HTTP 放在 Controller,你的 Express + TypeScript 專案就能像積木一樣,輕鬆拼湊、快速迭代。祝開發順利!