[CODE]

TypeScript 裡的 DDD:實際長什麼樣子

把 Domain-Driven Design 套在自己的專案上:Bounded Context 劃分、Repository 介面、用 Zod schema 來強制 invariant。這篇看的是各層實際的程式碼長相,還有哪裡的複雜度是值得的。

3 min read
ddd typescript architecture software-engineering

DDD 的文章我看了不少。Bounded Context、Aggregate、Repository Pattern — 在部落格文章裡讀起來都很乾淨。但當我真的把它套在 Code & Cast 上面的時候,還是撞上了理論和「所以這個檔案到底長什麼樣子」之間的落差。

所以這篇就來記一下我最後做出來的東西。真實程式碼、真實取捨,還有幾個讓我懷疑這一切是否值得的時刻。

Bounded Context 怎麼分

Code & Cast 有兩個主要領域:

  • CODE Context — 關於軟體專案的技術文章
  • CAST Context — 釣魚記錄(出釣報告、裝備筆記等)

它們放在獨立的目錄裡,彼此之間沒有共用的 domain 物件。這個隔離就是重點所在。如果哪天我決定把釣魚貼文搬到資料庫,但文章繼續用 MDX,兩個 context 不需要管對方在做什麼。

每一層做什麼

Domain 層

這是唯一一層完全沒有 framework import 的地方。沒有 Astro、沒有 Prisma,什麼都沒有。只有 Zod schema 定義實體的形狀、推導出來的 TypeScript 型別,還有 repository 介面。

// src/domain/code/article.ts
import { z } from "zod";

export const ArticleSchema = z.object({
  slug: z.string(),
  title: z.string(),
  description: z.string(),
  publishedAt: z.date(),
  tags: z.array(z.string()),
  featured: z.boolean().default(false),
  locale: z.enum(["en", "zh-TW"]),
});

export type Article = z.infer<typeof ArticleSchema>;

export type ArticleRepository = {
  findAll(locale: string): Promise<Article[]>;
  findBySlug(slug: string, locale: string): Promise<Article | null>;
  findFeatured(locale: string, limit?: number): Promise<Article[]>;
};

Zod schema 同時扮演兩個角色:驗證從 Content Collections 進來的資料,以及定義 Article 到底是什麼。如果有一篇文章缺少必填欄位,它在邊界就會爆,不會等到某個深層的 component 才出事。

Infrastructure 層

Astro 相關的程式碼放在這裡。AstroArticleRepository 類別實作 ArticleRepository,底層用 astro:contentgetCollection()

// src/infra/code/astroArticleRepository.ts
import { getCollection } from "astro:content";
import type { ArticleRepository } from "../../domain/code/article";
import { ArticleSchema } from "../../domain/code/article";

export class AstroArticleRepository implements ArticleRepository {
  async findAll(locale: string) {
    const entries = await getCollection("articles");
    return entries
      .filter((e) => e.data.locale === locale)
      .map((e) => ArticleSchema.parse({ ...e.data, slug: e.slug }));
  }

  async findBySlug(slug: string, locale: string) {
    const entries = await getCollection("articles");
    const entry = entries.find(
      (e) => e.slug === slug && e.data.locale === locale
    );
    if (!entry) return null;
    return ArticleSchema.parse({ ...entry.data, slug: entry.slug });
  }

  async findFeatured(locale: string, limit = 3) {
    const all = await this.findAll(locale);
    return all.filter((a) => a.featured).slice(0, limit);
  }
}

沒什麼特別的。但注意所有 getCollection 的呼叫都被關在這裡。Domain 層對這個東西的存在一無所知。

Queries 層

Query handler 坐在 infra 和 UI 之間。它們組合邏輯、呼叫 repository,然後回傳 ViewModel — 一個單純的物件,形狀剛好就是頁面需要的,不多也不少。

// src/queries/code/getFeaturedArticles.ts
import type { ArticleRepository } from "../../domain/code/article";

export type FeaturedArticleViewModel = {
  slug: string;
  title: string;
  description: string;
  publishedAt: string; // 已格式化的字串
  tags: string[];
};

export async function getFeaturedArticles(
  repo: ArticleRepository,
  locale: string,
  limit = 3
): Promise<FeaturedArticleViewModel[]> {
  const articles = await repo.findFeatured(locale, limit);
  return articles.map((a) => ({
    slug: a.slug,
    title: a.title,
    description: a.description,
    publishedAt: a.publishedAt.toLocaleDateString(locale, {
      year: "numeric",
      month: "long",
      day: "numeric",
    }),
    tags: a.tags,
  }));
}

日期格式化在這裡處理,不在 component 裡。Component 拿到的就是字串。這是刻意的 — Astro 頁面不應該跑業務邏輯。

測試

因為 query handler 接受的是 repository 介面而不是類別,mock 起來非常簡單:

const mockRepo: ArticleRepository = {
  findAll: vi.fn().mockResolvedValue([...]),
  findBySlug: vi.fn().mockResolvedValue(null),
  findFeatured: vi.fn().mockResolvedValue([mockArticle]),
};

不需要什麼 test double 函式庫,也不用想辦法繞過 getCollection。測試跑很快,完全不在乎 Astro 的存在。

哪裡的複雜度真的值得

依賴方向很清楚,一路往同一個方向走:pages → queries → domain ← infra。沒有東西往反方向流。當我把 CAST context 從舊的資料結構改成現在的 CastPost 模型時,只動了 domain 和 infra 兩層。Query handler 和頁面完全沒有動。

測試的可靠性也是真實的。因為注入的是純 TypeScript 介面,測試 query 邏輯的過程很直接,不用跟 Astro 的模組系統搏鬥。

哪裡可能不值得

如果是純展示的內容、完全沒有業務邏輯 — 像是單純列出標籤、或是靜態的「關於我」頁面 — 這樣做就是過度設計。如果你只是讀一個檔案然後渲染它,repository 介面只是增加了形式,沒有帶來好處。我不是到處都用這個模式。只有在有真正的 domain 邏輯、或者將來可能換掉基礎設施的地方才用。

「應不應該用 DDD?」老實的答案是:看你需不需要這些接縫。對 Code & Cast 來說,有兩個可以獨立演化的 context,事後證明這比我當初預期的更有用。