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:content 的 getCollection()。
// 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,事後證明這比我當初預期的更有用。