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.mdx → lang = '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 完全沒意義的欄位:waterType(sea 或 freshwater)、species(目標魚種,最多 3 個)、location、heroImage、images。我在 CAST domain 加欄位,CODE context 的任何東西都不需要動——沒有共用型別要更新,沒有 migration 風險,不可能出現 cross-context 的型別錯誤。
兩個 CI agent 寫到不同的 Astro collection(blog 和 cast),通過各自的 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 就要從外部穿進來,反而製造耦合。測試明確斷言 publishedAt 是 Date 實例,不是字串。
成本何時賺回來,哪裡不值得套
架構分層在整個專案裡只有一次清楚賺回成本的事件:改 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 改動,其他的全部原地不動。