4 min read

Applying Deep Modules to a Small Astro Blog (And What I Actually Learned)

My personal blog is built with Astro 5 — static pages for posts, SSR for API routes, an MSSQL database for comments and view counts. It’s small: maybe 30 source files. But it had accumulated the kind of architectural friction that makes changes risky and navigation confusing.

The worst offender was the comment submission system. The concept of “submitting a comment” was spread across 5+ files with a hidden contract that nothing enforced. Obfuscated field names for spam protection (ILKHDA, DGHJKE, ASDFGB) were hardcoded independently in the React form component, the Zod schema, and the API route. Change one without the others and submission silently breaks. There were zero tests.

Beyond comments, every API route manually constructed new Response(JSON.stringify(data), { headers: { 'Content-Type': 'application/json' } }). Param validation (Number(params.postId) + isNaN check + error response) was copy-pasted across 6 routes. Tag handling had inconsistent matching strategies. Database query files were scattered across unrelated directories.

None of these were bugs. The site worked. But the codebase was full of “shallow modules” — files whose interface was nearly as complex as their implementation, requiring you to understand multiple files to grasp a single concept.

The Journey

I used Matt Pocock’s (https://github.com/mattpocock) improve-codebase-architecture skill for Claude Code to analyse the codebase. It explores the code like an AI would — tracing imports, mapping coupling, identifying where modules are shallow — and surfaces opportunities ranked by impact. It found 5 areas, ordered by severity:

  1. Comment Submission Pipeline — 5 files co-owning one concept with a hidden contract
  2. API Route Error Handling — identical boilerplate across 9 endpoints
  3. Post & Tag Aggregation — no canonical tag representation, inconsistent matching
  4. OG Image Generation — magic numbers, no asset caching, scattered concerns
  5. Database Layer — thin wrapper with duplicated internals, poor discoverability

The key insight from the analysis was framing these as “shallow modules” (John Ousterhout’s concept from A Philosophy of Software Design). A shallow module has a complex interface relative to the functionality it hides. The comment system was the extreme case — you needed to understand schema.ts, spamProtection.ts, comments.ts, NewComment.tsx, and the API route to make any change. The module boundary hid nothing.

What worked: phased, incremental refactoring

Each improvement was broken into small phases with a build verification after every step. For the comment module alone, that was 6 phases:

  1. Create types + barrel index (no consumers broken)
  2. Create server.ts — extract API route logic
  3. Create form.ts — encapsulate field names and spam tokens
  4. Unify the Comment type across React components
  5. Move internal files into the module
  6. Add Vitest and boundary tests

The critical discovery in Phase 3 was that the barrel index.ts couldn’t be used from client components — it transitively imported server-only database code via the re-exports. The client/server boundary in Astro is real and you hit it fast when consolidating modules. The solution: client components import from form.ts directly, server code uses server.ts.

The Solution

1. Comment Module — Deep Instead of Wide

Before: 5 files, hidden contract, zero tests.

After: src/lib/comments/ with a clear client/server split:

src/lib/comments/
  index.ts          -- barrel re-exports (public API)
  types.ts          -- Comment, SubmitResult
  form.ts           -- FIELD_NAMES, getDefaultValues, stampSpamTokens, submitComment
  server.ts         -- processSubmission, listComments, removeComment, getCommentCount
  schema.ts         -- (internal) Zod schema with obfuscated names
  spamProtection.ts -- (internal) salt, timestamp encode/decode/validate
  __tests__/        -- 24 boundary tests

The obfuscated field names are now centralised in a single FIELD_NAMES map:

export const FIELD_NAMES = {
  author: 'ILKHDA',
  email: 'DGHJKE',
  body: 'ASDFGB',
  postId: 'SDFFPI',
  honeypot: 'DFGDDH',
  timestamp: 'LJIKRT',
  hidden1: 'DLLLSH',
  salt: 'ROPSFS',
} as const;

The form component no longer knows about obfuscated strings:

<FormField control={form.control} name={FIELD_NAMES.author} ... />

And the API route went from 40+ lines of inline logic to:

export const POST: APIRoute = async ({ request }) => {
  const body = await request.json();
  const ipAddress = request.headers.get('x-real-ip') || request.headers.get('x-forwarded-for');
  const result = await processSubmission(body, ipAddress);
  return json(result, result.success ? 200 : 400);
};

2. API Helpers — Three Functions, Nine Routes Simplified

// src/lib/api.ts
export function json(data: unknown, status = 200): Response {
  return new Response(JSON.stringify(data), {
    status,
    headers: { 'Content-Type': 'application/json' },
  });
}

export function parseId(params: Record<string, string | undefined>, key: string): number | Response {
  const id = Number(params[key]);
  if (isNaN(id)) return json({ error: `Invalid ${key}` }, 400);
  return id;
}

export function requireAdmin(request: Request): Response | null {
  if (!isAdmin(request)) return json({ error: 'Unauthorized' }, 401);
  return null;
}

The delete comment route before and after tells the story:

// Before: 15 lines of boilerplate
if (!isAdmin(request)) {
  return new Response(JSON.stringify({ error: 'Unauthorized' }), {
    status: 401, headers: { 'Content-Type': 'application/json' } });
}
const commentId = Number(params.commentId);
if (isNaN(commentId)) {
  return new Response(JSON.stringify({ error: 'Invalid comment ID' }), {
    status: 400, headers: { 'Content-Type': 'application/json' } });
}
await deleteComment(commentId);
return new Response(JSON.stringify({ success: true }), {
  headers: { 'Content-Type': 'application/json' } });

// After: 5 lines of business logic
const denied = requireAdmin(request);
if (denied) return denied;
const commentId = parseId(params, 'commentId');
if (commentId instanceof Response) return commentId;
await removeComment(commentId);
return json({ success: true });

3. Tags Module — One Canonical Representation

Tags were strings everywhere but handled inconsistently. getPostsByTag used case-insensitive comparison, but the tag page did its own filtering. Tag URLs used raw strings, which broke for tags with spaces (like “unicorn studio”).

Extracted src/lib/tags.ts with pure, testable slug functions:

export function tagToSlug(tag: string): string {
  return tag.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
}

export function tagUrl(tag: string): string {
  return `/posts/tagged/${tagToSlug(tag)}`;
}

Also moved getAllTags and getPostsByTag out of posts.ts into tags.ts — tag concepts belong in the tag module, not scattered across the post module.

4. Data Layer — Co-location Over Abstraction

The database was discussed but deliberately kept simple. For 2 tables and 6 queries, a repository pattern would be over-engineering. Instead, the improvement was purely about discoverability — moving all database query files into src/lib/data/:

src/lib/data/
  db.ts          -- connection pool + query/execute
  comments.ts    -- comment queries
  views.ts       -- view count queries

Gotchas

Astro’s client/server boundary is strict. When I created a barrel index.ts that re-exported both client-safe form utilities and server-only database functions, the build broke immediately — Vite tried to bundle server code into the client island. Client components must import from client-safe files directly, not through a barrel that touches server code.

Path aliases need vitest config too. Adding @/ aliases to tsconfig.json made imports clean, but Vitest doesn’t read tsconfig paths. You need a matching resolve.alias in vitest.config.ts:

resolve: {
  alias: {
    '@': path.resolve(__dirname, 'src'),
  },
},

Testing files that import astro:content fails. Pure functions extracted into their own file (like tag slug helpers) can be tested directly. But any file that imports from astro:content at the top level will fail in Vitest since that’s an Astro virtual module. The fix: either mock the Astro import or (better) keep pure logic in separate files that don’t touch Astro internals.

Key Takeaway

“Deep modules” isn’t about project size — it’s about whether a module hides complexity behind a simple interface. A blog with 30 files can benefit from the same thinking as a large codebase. The question isn’t “do I need abstractions?” but “when I change one concept, how many files do I need to touch?”

Matt Pocock’s improve-codebase-architecture skill gave this analysis structure — surfacing the coupling clusters and ranking them by impact, rather than just refactoring whatever felt messy. The result: 43 tests, cleaner module boundaries, and a codebase where each concept lives in one place.