讓錯誤不可能被忽略

好的 API 設計讓正確用法是最簡單的路徑,錯誤用法很難不小心就觸發:

// ❌ 容易忘記 await
userService.create(dto);  // 誰知道這個要不要 await?
 
// ✅ 讓型別系統幫你:永遠 return Promise,讓 caller 必須決定
async create(dto: CreateUserDto): Promise<User> {
  // ...
}

使用 Result<T, E> 型別(或 Go-style [data, error] tuple)讓錯誤路徑變成必須處理的:

type Result<T, E = Error> =
  | { ok: true; value: T }
  | { ok: false; error: E };
 
async function parseConfig(raw: unknown): Promise<Result<Config>> {
  const result = configSchema.safeParse(raw);
  if (!result.success) return { ok: false, error: new Error(result.error.message) };
  return { ok: true, value: result.data };
}
 
// caller 必須 check ok 才能用 value,不能忽略錯誤
const result = await parseConfig(raw);
if (!result.ok) {
  logger.error('Config parse failed', { error: result.error });
  process.exit(1);
}

把依賴注入,不要在內部 new

// ❌ UserService 自己建立 dependencies,測試很難 mock
class UserService {
  private repo = new UserRepository();
  private emailer = new EmailService();
}
 
// ✅ 依賴從外部注入,測試時可以換掉
class UserService {
  constructor(
    private repo: UserRepository,
    private emailer: EmailService,
  ) {}
}

這不是為了框架——是為了讓 unit test 不需要啟動 DB 或打外部 API。


型別說話,不要用 string 代表一切

// ❌ string 可以是任何東西
function sendEmail(to: string, type: string) { }
sendEmail('alice@example.com', 'welcme');  // 打錯了,runtime 才發現
 
// ✅ union type 讓 compiler 幫你檢查
type EmailType = 'welcome' | 'password-reset' | 'order-confirmation';
function sendEmail(to: string, type: EmailType) { }
sendEmail('alice@example.com', 'welcme');  // compile error,立刻發現

同樣適用於 status、role、event type——這些有限集合的值都應該是 union type 或 enum,不是 string。


一個函式做一件事,名字說清楚

// ❌ 名字說一件事,但做了三件事
async function validateAndSaveUser(dto: CreateUserDto) {
  const errors = validate(dto);
  if (errors.length) throw new ValidationError(errors);
  const user = await userRepo.create(dto);
  await emailService.sendWelcome(user.email);
  return user;
}
 
// ✅ 分開,每個函式名字就是它做的事
async function createUser(dto: CreateUserDto) {
  const user = await userRepo.create(dto);
  await emailQueue.add('welcome-email', { userId: user.id });
  return user;
}
// validation 在 middleware 做,不混進 service

如果一個函式名字裡有 “and” 或 “then”,通常是一個信號:它在做兩件事。


讓 Guard/Validation 失敗的越早越好(Fail Fast)

// ❌ 做了一堆工作,最後才驗證
async function processOrder(dto: CreateOrderDto) {
  const inventory = await inventoryService.check(dto.productId);
  const pricing = await pricingService.calculate(dto);
  const user = await userService.get(dto.userId);
 
  // 最後才驗 dto,前面三個 await 都白做了
  const errors = orderSchema.safeParse(dto);
  if (!errors.success) throw new ValidationError(errors.error);
}
 
// ✅ 先驗資料格式,合法的才繼續
async function processOrder(dto: CreateOrderDto) {
  orderSchema.parse(dto);  // 先 validate,不合法直接 throw
  // 以下才做有成本的操作
  const inventory = await inventoryService.check(dto.productId);
  // ...
}

Fail fast 的原則:一旦知道這個 request 處理不了,立刻回傳——不要繼續做後面的操作。


每個 public method 都有一個清楚的「前置條件」

class OrderService {
  // 前置條件隱含在型別系統:user 是已認證的 AuthUser,不是可能為 undefined 的東西
  async create(dto: CreateOrderDto, user: AuthUser): Promise<Order> {
    // 不需要 if (!user) throw,型別保證了 user 存在
  }
}

把條件推進型別,比在函式內部 guard 更省力。null / undefined 只有在系統邊界(external data)才需要出現,內部傳遞應該已經是確定的型別。


Response 只暴露必要的欄位

不要用 entity 直接當 response,要過 serializer:

class UserSerializer {
  static toPublicResponse(user: User) {
    return {
      id: user.id,
      name: user.name,
      createdAt: user.createdAt,
    };
  }
 
  static toAdminResponse(user: User) {
    return {
      ...this.toPublicResponse(user),
      email: user.email,
      role: user.role,
      lastLoginAt: user.lastLoginAt,
    };
  }
}

DB schema 和 API contract 獨立演進,DB 加新欄位不會自動洩漏出去。


寫有意義的 log,而不是 console.log

// ❌ 完全沒用
console.log('user created');
console.log(error);
 
// ✅ 有 context,能 debug
logger.info('User created', {
  userId: user.id,
  email: user.email,
  requestId: getRequestId(),
});
 
logger.error('Payment failed', {
  error: error.message,
  stack: error.stack,
  userId,
  amount,
  provider: 'stripe',
});

log 的讀者是未來的你在凌晨兩點排查 incident。讓他能從一條 log 知道發生什麼事、在哪裡發生、影響誰。

詳見 Structured Logging


Transaction scope 由 Service 決定

Repository 提供操作,但 transaction 的邊界由 Service 控制——因為只有 Service 知道哪些操作要 atomic:

class OrderService {
  async create(dto: CreateOrderDto, userId: string): Promise<Order> {
    return db.transaction(async (trx) => {
      const order = await orderRepo.create(dto, userId, trx);
      await inventoryRepo.decrement(dto.productId, dto.quantity, trx);
      // 兩個操作在同一個 transaction
      return order;
    });
  }
}

Repository 不應該開 transaction——它不知道自己是不是更大 transaction 的一部分。

詳見 DB Integration 設計模式


配置驗證在啟動時做

// ✅ 啟動時驗證所有必要配置,缺少就不啟動
const config = z.object({
  DATABASE_URL: z.string().url(),
  JWT_SECRET: z.string().min(32),
  REDIS_URL: z.string().url(),
}).parse(process.env);
// 如果 DATABASE_URL 沒設定,process.exit(1),不要讓服務跑起來卻在 runtime 才掛

在啟動時 fail 比在 runtime 才 fail 好:deploy 的時候就發現配置問題,不是用戶用到一半才噴。

詳見 Config Management


冪等操作要明確設計,不是靠運氣

任何可能被重複觸發的操作(webhook、queue retry、用戶重複點擊),都要主動設計冪等性:

// 用資料庫的唯一約束作為最後防線
CREATE UNIQUE INDEX ON orders (idempotency_key);
 
// 應用層也要做 check
async createOrder(dto: CreateOrderDto, idempotencyKey: string): Promise<Order> {
  const existing = await Order.findOne({ where: { idempotencyKey } });
  if (existing) return existing;  // 回傳既有的,不重複建立
  // ...
}

詳見 Idempotency 設計


重的外部操作要有 timeout 和 fallback

// ❌ 外部 API 沒回應,你的 request 就卡住等
const result = await externalApi.call(data);
 
// ✅ 設 timeout,失敗有 fallback
try {
  const result = await externalApi.call(data, {
    signal: AbortSignal.timeout(5000),  // 5 秒 timeout
  });
  return result;
} catch (error) {
  if (error.name === 'TimeoutError') {
    logger.warn('External API timeout', { api: 'externalApi' });
    return fallbackValue;  // 或 throw,讓 caller 決定
  }
  throw error;
}

第三方服務可以隨時變慢或掛掉。你的系統不能因為別人慢就跟著掛。


延伸閱讀