ExpressJS (TypeScript) – 使用 Express 與資料庫整合
主題:資料存取層(Repository / DAO)
簡介
在 Node.js 與 Express 的專案中,直接在路由處理函式裡寫 SQL 或 ORM 操作會讓程式碼變得雜亂、難以測試,也不利於未來的功能擴充。
為了把 業務邏輯 與 資料存取 明確分離,我們通常會在專案中加入 Repository(或 DAO)層。這層負責與資料庫互動,提供「純粹的 CRUD」介面,而上層的 Service、Controller 只需要呼叫這些介面即可。
使用 TypeScript 時,Repository 更能發揮型別系統的優勢:透過介面(Interface)定義資料模型與操作合約,編譯階段就能捕捉到錯誤,提升程式的可靠性與可讀性。本文將從概念說明、實作範例、常見陷阱與最佳實踐,帶你一步步建立乾淨、可測試的資料存取層。
核心概念
1. Repository / DAO 的定位
| 層級 | 主要職責 |
|---|---|
| Controller | 解析 HTTP 請求、回傳 HTTP 回應,僅負責協調 Service |
| Service | 處理業務邏輯,可能會調用多個 Repository |
| Repository / DAO | 直接與資料庫溝通,執行 CRUD,返回Domain Model或DTO |
| Entity / Model | 資料庫結構的型別描述(ORM 實體或手寫介面) |
重點:Repository 只關心「怎麼存取資料」,不牽涉「資料要怎麼用」的業務規則。
2. 介面(Interface)定義合約
在 TypeScript 中,我們先為每個資料表(或集合)定義 Entity 與 Repository 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) | 使用 dotenv 或 config 套件,將連線資訊放在環境變數 |
| 忘記關閉資料庫連線 | 測試跑完後程式不會結束 | 在測試或應用關閉時呼叫 prisma.$disconnect()(或 ORM 相應的關閉方法) |
最佳實踐總結
- 介面先行:先寫
Entity、DTO、Repository Interface,再實作。 - 單一職責:Repository 只做資料存取,Service 處理業務,Controller 負責 HTTP。
- 依賴注入:使用建構子注入或 IoC 容器(如
tsyringe),讓測試可以輕鬆換成 Mock。 - 錯誤統一處理:在 Repository 捕捉 DB 錯誤,轉成自訂的 Domain Error,讓 Service 能根據錯誤類型作回應。
- 交易(Transaction)管理:若一個 Service 需要多個 Repository 同時成功,使用 ORM 提供的 transaction 機制,避免半完成的資料寫入。
實際應用場景
1. 多資料來源的聚合服務
假設一個電商平台同時使用 PostgreSQL 儲存商品資訊、MongoDB 儲存使用者瀏覽紀錄。
我們可以為每個資料庫各寫一套 Repository(PostgresProductRepository、MongoLogRepository),在 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 專案就能像積木一樣,輕鬆拼湊、快速迭代。祝開發順利!