[CODE]

用 TypeScript 實作 DDD:多出來的那些層什麼時候值得

Code & Cast 的內容由兩個自主 CI agent 負責寫作。DDD 分層架構存在的原因不是最佳實踐,而是要讓這個系統安全運作——Zod runtime 合約、結構型別讓測試不依賴框架、bounded context 隔離兩個獨立 agent。這篇談哪些設計真正賺回成本,哪些只是架構儀式。

3 min read AI 生成
ddd typescript architecture software-engineering

CI agent 可以產出語法正確、通過 TypeScript 編譯的 MDX,然後在 render 的時候炸掉。publishedAt 是字串,component 要 Date,錯誤出現在三層 call stack 之外的某個 component 裡,沒有任何訊息指向問題來源。TypeScript 型別 compile 完就消失,它管不到 runtime。

這個失敗模式是 Code & Cast 採用 DDD 分層架構的真正原因,不是架構師的口頭禪。

這個網站的內容由兩個 CI pipeline 負責寫作。CODE 文章由一個 CI job 生產,這個 job 掃描我在 GitHub 上的 commit history,把有意義的工程工作轉成文章。CAST 貼文由另一個 CI job 生產,它追蹤我的 Instagram 和 Medium 上的釣魚記錄。兩個 pipeline 各自運作,沒有人工逐篇 review。「domain model 必須穩定、壞資料必須在進 component 之前就炸掉」是工程要求,不是設計原則的修辭。

Schema 是生產合約

開發者寫文章時,schema 違反是開發問題——review 的時候抓到,merge 前修掉。CI 是作者的時候,schema 是生產合約:滿足它,或是網站在 production 爆。

ArticleSchema 執行的是 TypeScript 型別做不到的約束:

// src/domain/code/article.ts
export const ArticleSchema = z.object({
  slug:        z.string().min(1).regex(/^[a-z0-9-]+$/),
  title:       z.string().min(1).max(120),
  description: z.string().min(1).max(300),
  publishedAt: z.date(),
  updatedAt:   z.date().optional(),
  tags:        z.array(z.string().min(1).max(32)).min(1),
  featured:    z.boolean().default(false),
  series:      z.string().optional(),
  lang:        LocaleSchema.default('en'),
})

Slug 必須符合小寫字母、數字、連字號的正規表達式。tags 不能是空陣列。publishedAt 必須是 Date 物件——字串在 infra 邊界就爆,不用等到 component 深處。Schema 的欄位名稱直接出現在 CI agent 的 prompt 裡。Schema 是 CI 產出和網站消費之間唯一的權威規格。

content.config.ts 裡的 Astro collection schema 是第一道過濾——在 build time 驗 frontmatter,抓缺少欄位或型別錯誤。toArticle() 裡的 domain schema 是第二層,更嚴格,而且負責驗 frontmatter 以外的東西。

Mapper 是隔離的邊界點

Astro collection entry 轉成 domain entity 的地方在 mapper:

// src/infra/code/articleMapper.ts
export type BlogCollectionEntry = {
  id: string
  data: { title: string; description: string; publishedAt: Date; /* ... */ }
}

export function toArticle(entry: BlogCollectionEntry): Article {
  const parts = entry.id.replace(/\.mdx?$/, '').split('/')
  const lang  = LocaleSchema.parse(parts[0] === 'zh-tw' ? 'zh-TW' : parts[0])
  const slug  = parts.slice(1).join('/')
  return ArticleSchema.parse({ slug, lang, ...entry.data })
}

BlogCollectionEntry 是一個本地結構型別,形狀和 Astro 的 CollectionEntry<'blog'> 一樣,但不 import astro:content。測試只需要傳入普通物件。Mapper 跑,Zod 驗,domain entity 出來——不需要任何 Astro runtime。測試可以直接傳 mockBlogEntry({ id: 'en/Invalid_Slug.mdx' }) 這樣的普通物件,然後斷言 Zod throw。整個測試 setup 沒有任何框架依賴。

Slug 從檔案路徑提取,是一個 domain 決策:en/hello-world.mdxlang = 'en'slug = 'hello-world'。Slug 不在 frontmatter 裡。檔名規範就是 identity 系統,mapper 把這個規範轉成被 Zod 驗證過的 domain 不變量。

Repository:satisfies 而不是 class implements

astro:content 在整個 codebase 只出現一次,在 astroArticleRepository.ts

export const astroArticleRepository = {
  async getAll()                          { /* ... */ },
  async getBySlug(slug: string, lang: Locale) { /* ... */ },
  async getAllByLang(lang: Locale)         { /* ... */ },
} satisfies ArticleRepository

satisfies 而不是 class + implements。這個物件是普通 value——不需要 new,沒有 constructor,沒有繼承。型別安全在賦值點執行。測試的 mock 形式完全一樣:

function makeRepository(articles: Article[]): ArticleRepository {
  return {
    async getAll()              { return articles },
    async getBySlug(slug, lang) { /* ... */ },
    async getAllByLang(lang)    { /* ... */ },
  }
}

沒有 module mock,沒有 class 實例化,沒有 test framework 魔術。一個回傳普通物件的函式就是完整的測試 setup。satisfies 模式也讓 repository 的本質更清楚:一組 async 資料存取函式,不是有封裝狀態的物件。

兩個 agent,兩套不能相交的 context

CODE 和 CAST 是兩個獨立的 bounded context。不同目錄,沒有共用的 domain 物件。唯一例外是 domain/shared/locale.ts,這是一個刻意設計的 shared kernel,只負責 Locale 這個兩個 context 都需要的型別。

CastPost 有對 CODE context 完全沒意義的欄位:waterTypeseafreshwater)、species(目標魚種,最多 3 個)、locationheroImageimages。我在 CAST domain 加欄位,CODE context 的任何東西都不需要動——沒有共用型別要更新,沒有 migration 風險,不可能出現 cross-context 的型別錯誤。

兩個 CI agent 寫到不同的 Astro collection(blogcast),通過各自的 mapper,從不共用 domain 型別。這條線靠目錄結構執行,不是靠約定。不存在一條路徑可以讓 CAST 的 query handler 意外 import 到 CODE 的 domain 型別。

Date 格式化發生在哪裡

publishedAt 在整個 TypeScript 層——domain entity、ViewModel、query handler 的輸出——都是 Date 物件:

// src/queries/code/viewModels.ts
export type ArticleListItem = {
  publishedAt: Date  // 格式化在 .astro component 執行,不在這裡
  // ...
}

.astro component 有 locale context,render layer 知道它在服務哪個語言版本。日期格式化屬於 render layer 的責任。在 TypeScript 的 query layer 做格式化,locale 就要從外部穿進來,反而製造耦合。測試明確斷言 publishedAtDate 實例,不是字串。

成本何時賺回來,哪裡不值得套

架構分層在整個專案裡只有一次清楚賺回成本的事件:改 CAST context 的資料格式時,我更新了 domain schema 和 mapper。Query handler 沒動,pages 沒動。依賴方向(pages → queries → domain ← infra)在沒有刻意維護的情況下自動成立。改動是機械性的:跟著型別錯誤走,修兩個檔案,結束。

大多數時候這個開銷是分散的。每個新欄位都需要動 schema、動 mapper、可能動 ViewModel。這是真實的成本。

純展示的東西——tag 清單、靜態頁面——repository interface 是沒有效益的架構儀式。判斷只有兩個問題:storage layer 以後有沒有可能換?邏輯複雜到值得獨立測試嗎?兩個都不是,普通 function 就是對的答案。

接縫每加一個 feature 都在收成本。等到 cast-writer 的發布量大到 flat file 掃描撐不住——需要依日期區間過濾、搜尋魚種、或不把所有 entry 撈起來才能分頁——遷移路徑已經在架構裡了:一個實作 CastPostRepository 的新物件,一個 registration 改動,其他的全部原地不動。