I’ve read about DDD plenty of times. Bounded contexts, aggregates, repository patterns — it all sounds clean in a blog post. But when I actually tried to apply it to Code & Cast, I ran into the usual gap between theory and “OK but what does this file actually look like?”
So here’s what I ended up with. Real code, real trade-offs, and a couple of spots where I questioned whether any of this was worth it.
The Bounded Contexts
Code & Cast has two main domains:
- CODE context — blog articles about software projects
- CAST context — fishing session posts (trip reports, gear notes, etc.)
They live in separate directories, talk to each other through nothing (no shared domain objects), and each has the same four-layer structure. That isolation is the whole point. If I decide to move fishing posts to a database while keeping articles as MDX files, neither context needs to know about the other’s mess.
What Each Layer Does
Domain Layer
This is the only layer with zero framework imports. No Astro, no Prisma, nothing. Just a Zod schema for the entity shape, the inferred TypeScript type, and a repository interface.
// 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[]>;
};
The Zod schema pulls double duty — it validates incoming data from Content Collections and acts as the contract for what an Article actually is. If a piece of content is missing a required field, it fails at the boundary, not somewhere deep in a component.
Infrastructure Layer
This is where Astro-specific code lives. The AstroArticleRepository class implements ArticleRepository using getCollection() from astro:content.
// 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);
}
}
Nothing fancy. But notice that all the getCollection calls are confined here. The domain has no idea this exists.
Queries Layer
Query handlers sit between infra and UI. They compose logic, call the repository, and return a ViewModel — a plain object shaped exactly for what the page needs, nothing more.
// src/queries/code/getFeaturedArticles.ts
import type { ArticleRepository } from "../../domain/code/article";
export type FeaturedArticleViewModel = {
slug: string;
title: string;
description: string;
publishedAt: string; // already formatted
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,
}));
}
The date formatting happens here, not in the component. The component gets a string. That’s intentional — Astro pages shouldn’t be doing business logic.
Testing
Because query handlers accept a repository interface (not a class), mocking is trivial:
const mockRepo: ArticleRepository = {
findAll: vi.fn().mockResolvedValue([...]),
findBySlug: vi.fn().mockResolvedValue(null),
findFeatured: vi.fn().mockResolvedValue([mockArticle]),
};
No test doubles, no mock libraries, no hacking around getCollection. The tests are fast and don’t care about Astro at all.
Where the Complexity Is Actually Worth It
The dependency direction is clear and consistent: pages → queries → domain ← infra. Nothing flows backward. When I switched from an older data shape to the current CastPost model in the CAST context, I only touched the domain and infra layers. The query handlers and pages stayed exactly the same.
The testability is also real. Writing tests for query logic is straightforward because I’m injecting a plain TypeScript interface, not fighting Astro’s module system.
Where It Probably Isn’t Worth It
For pure display content with no business logic — like a simple list of tags or a static “about” page — this is overkill. If you’re just reading a file and rendering it, a repository interface adds ceremony without benefit. I don’t use this pattern everywhere. Only where there’s real domain logic or where I might swap infrastructure later.
The honest answer to “should you use DDD?” is: depends on whether you’ll need the seams. For Code & Cast, having two contexts that can evolve independently turned out to matter more than I expected.