Code & Cast has two CI pipelines that write the site’s content. One monitors my GitHub commit history and produces CODE articles about engineering work; the other tracks my Instagram and Medium for fishing posts and produces CAST entries. Neither pipeline involves manual review of every output — the architecture has to be stable enough that invalid content fails before it reaches a render component.
That requirement is the actual reason for the DDD structure. Not pattern adoption for its own sake, but a specific failure mode: a CI agent can produce structurally valid MDX that compiles, passes TypeScript type-checking, and then fails at render time because publishedAt arrived as a string where a Date was expected. The error surfaces three call frames deep in a component that has nothing to do with the problem. TypeScript types compile away. They cannot catch this.
The schema is the production contract
When a human writes articles, a schema violation is a developer problem — catch it in review, fix before merge. When CI is the author, the schema is a production contract: satisfy it or the site breaks.
ArticleSchema enforces constraints that TypeScript types cannot:
// 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 must match a lowercase-alphanumeric-hyphen regex. An empty tags array throws. publishedAt must be a Date — a string fails at the infra boundary, not three frames up. The schema also functions as shared vocabulary: agent prompts reference these field names directly, making the schema the authoritative spec for what CI produces and what the site consumes.
There is also a first-pass filter in content.config.ts, where Astro’s own collection schema validates frontmatter at build time. That catches missing or malformed fields before the content even loads. The domain schema in toArticle() is the second layer — stricter, and responsible for the fields that aren’t in frontmatter at all.
The mapper as isolation boundary
The point where Astro collection data becomes a domain entity is the 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 is a local structural type that mirrors the shape of Astro’s CollectionEntry<'blog'> without importing from astro:content. Tests pass plain objects. The mapper runs, Zod validates, a domain entity comes out — no Astro runtime required. This is why the test suite covers toArticle without any framework setup: mockBlogEntry({ id: 'en/Invalid_Slug.mdx' }) is a plain object and the Zod throw is asserted directly.
The slug extraction is a domain decision embedded in the mapper: en/hello-world.mdx → lang = 'en', slug = 'hello-world'. Slug is not stored in frontmatter — it’s derived from the file path. The file system convention is the identity system, and the mapper is where that convention becomes a validated domain invariant.
The repository: satisfies over implements
astro:content appears once in the codebase — in astroArticleRepository.ts:
export const astroArticleRepository = {
async getAll() { /* ... */ },
async getBySlug(slug: string, lang: Locale) { /* ... */ },
async getAllByLang(lang: Locale) { /* ... */ },
} satisfies ArticleRepository
satisfies rather than a class with implements. The object is a plain value — no new, no constructor, no inheritance. Type safety is enforced at the assignment site. The mock in tests is identical in form:
function makeRepository(articles: Article[]): ArticleRepository {
return {
async getAll() { return articles },
async getBySlug(slug, lang) { /* ... */ },
async getAllByLang(lang) { /* ... */ },
}
}
No module mocking, no class instantiation, no test framework gymnastics. A function returning a plain object that satisfies the interface is the complete setup. The pattern also clarifies what the repository actually is: a collection of async data-access functions, not an object with encapsulated state.
Two agents, two contexts that cannot contaminate each other
CODE and CAST are separate bounded contexts. Different directories, no shared domain objects — the one exception is domain/shared/locale.ts, a deliberate shared kernel for the Locale type that both contexts need.
CastPost carries fields that have no meaning in the CODE context: waterType (sea or freshwater), species (target fish, max 3 entries), location, heroImage, images. When I add a CAST-specific field, nothing in the CODE domain compiles differently, runs differently, or risks breaking. The two CI agents write to separate Astro collections (blog and cast), process through separate mappers, and never share a domain type beyond Locale.
The boundary is structural, not conventional. There is no path by which a CAST query handler can accidentally import a CODE domain type. The directory structure makes the wrong thing impossible, not just discouraged.
Where Date formatting actually happens
publishedAt stays as a Date object through the entire TypeScript layer — domain entity, ViewModel, query handler output:
// src/queries/code/viewModels.ts
export type ArticleListItem = {
publishedAt: Date // formatted in .astro components, not here
// ...
}
The .astro component has locale context; the render layer already knows which language it’s serving. Date formatting belongs there, not in the TypeScript query layer where locale would need to be threaded through explicitly. The test explicitly asserts publishedAt instanceof Date — not a string.
Where the overhead paid off, and where it didn’t
The layering proved its worth exactly once in a single, visible event: when I changed the CAST context’s data shape, I updated the domain schema and the mapper. Query handlers were untouched. Pages were untouched. The dependency direction — pages → queries → domain ← infra — held without deliberate effort. The change was mechanical: follow the type errors, fix two files, done.
Most of the time the cost is real and diffuse. Every new field requires touching the schema, the mapper, and potentially the ViewModel. That’s overhead.
For static display content — a tag list, a static about page — a repository interface adds cost with no payoff. The test has two questions: will the storage layer change? Is the logic complex enough to isolate? Both no → a plain function is the right answer.
The seams cost something on every feature addition. The day the cast-writer’s publication volume outgrows flat file scanning — when I need date-range filtering, species search, or pagination without loading every entry first — the migration path is already implied: one object implementing CastPostRepository, one registration change. Everything else stays exactly as it is.