Files
agentic-dev/docs/superpowers/plans/2026-05-04-plan-2-blog-feature.md
Danijel Martinek ed4d204a7e docs(plan-2): document relative-imports-in-source rule for feature packages
Discovered during execution: when downstream packages like @repo/core-api
typecheck and follow imports into a feature, they don't have the feature's
@/ alias and fail with TS2307. Source files use relative imports;
@/ is reserved for test files within the feature's own context.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 22:36:10 +02:00

1843 lines
60 KiB
Markdown

# Vertical Refactor — Plan 2: Blog Feature (Canonical Migration)
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Migrate the existing `content/articles` domain (currently inside `packages/core` and `packages/cms-core`) into a new vertical `packages/blog` feature package with the full canonical layered shape: entities → application → infrastructure → interface-adapters → di → integrations/cms → integrations/api → ui. Wire the feature's `/cms` export into `core-cms` composition and its `/api` export into `core-api` composition. Establish the per-feature InversifyJS container pattern. Pattern proven here will be replicated for `auth`, `media`, `marketing-pages`, and `navigation` in subsequent plans.
**Architecture:** One new package (`@repo/blog`) with the full canonical structure. Per-feature InversifyJS container holds the `IArticlesRepository` binding (mock by default for tests; payload-backed in production via `getPayload({ config })` from `@repo/core-cms`). tRPC procedures in `integrations/api/router.ts` call controllers, which Zod-parse and delegate to use-cases, which resolve the repo via the feature container. Articles collection moves from `cms-core` into `blog/integrations/cms/collections/`.
**Tech Stack:** InversifyJS 6.x with reflect-metadata, Vitest 3.x, Zod 3, tRPC 11, Payload 3.14 Local API.
**Plan position:** Plan 2 of 6 in the vertical-refactor sequence (revised from original 4 during execution to keep each plan digestible):
- Plan 1 ✅ Foundation — core-* package scaffolds + core-shared primitives + core-cms stub
- **Plan 2 (this doc):** Blog feature (canonical) — full layered shape, simplified schema (no cross-feature relationships)
- Plan 3: Auth + Media features + restore blog's `author` relationship and `featuredImage`
- Plan 4: Marketing-pages + Navigation features
- Plan 5: App + UI integration — core-trpc client/providers, route handlers, example pages
- Plan 6: Cleanup + boundary enforcement + Playwright + docs rewrite
**Spec reference:** `docs/superpowers/specs/2026-04-21-vertical-monorepo-refactor-design.md`
---
## Schema simplifications in this plan
The existing `Articles` collection in `packages/cms-core/src/collections/articles/fields.ts` declares two cross-feature references:
- `author: relationship → users` (users collection is owned by the auth feature, migrated in Plan 3)
- `featuredImage: upload → media` (media collection is owned by the media feature, migrated in Plan 3)
To keep Plan 2 atomic, the migrated `articles.ts` collection here uses **simplified placeholders**:
- `author` becomes a `text` field (string).
- `featuredImage` is omitted entirely.
**Plan 3 restores both fields to their relationship/upload form.** This is called out explicitly in those Plan 3 tasks. The simplification is also documented in a TODO comment in the new `articles.ts` so an engineer reading the file later understands.
The domain `Article.content` field is also widened: the existing entity has `content: z.string()` but Payload's actual storage is rich-text JSON. New entity uses `content: z.unknown()` to honestly model rich-text content (matches spec §11.9's `body: unknown` pattern). The mock repository's tests are adjusted accordingly.
---
## File Structure (this plan creates/modifies)
**Create — new `@repo/blog` package:**
- `packages/blog/{package.json,tsconfig.json,turbo.json,vitest.config.ts}`
- `packages/blog/src/index.ts` — barrel export
- `packages/blog/src/entities/article.ts` + `article.test.ts`
- `packages/blog/src/entities/errors.ts`
- `packages/blog/src/application/repositories/articles-repository.interface.ts`
- `packages/blog/src/application/use-cases/get-articles.use-case.ts` + `get-articles.use-case.test.ts`
- `packages/blog/src/application/use-cases/create-article.use-case.ts` + `create-article.use-case.test.ts`
- `packages/blog/src/infrastructure/repositories/mock-articles.repository.ts`
- `packages/blog/src/infrastructure/repositories/payload-articles.repository.ts` + `payload-articles.repository.test.ts`
- `packages/blog/src/interface-adapters/controllers/articles.controller.ts` + `articles.controller.test.ts`
- `packages/blog/src/di/symbols.ts`
- `packages/blog/src/di/module.ts`
- `packages/blog/src/di/container.ts` + `container.test.ts`
- `packages/blog/src/integrations/cms/collections/articles.ts`
- `packages/blog/src/integrations/cms/index.ts`
- `packages/blog/src/integrations/api/router.ts`
- `packages/blog/src/ui/query.ts`
- `packages/blog/tests/articles.feature.test.ts`
**Modify:**
- `packages/core-cms/src/payload.config.ts` — import + register `articles` from `@repo/blog/cms`
- `packages/core-api/src/root.ts` — import + register `blogRouter` from `@repo/blog/api`
- `tsconfig.base.json` — add `@repo/blog`, `@repo/blog/cms`, `@repo/blog/api` aliases
- `apps/cms/package.json` — add `@repo/blog` dependency (so the workspace resolves the schema import)
**Do NOT touch:**
- `packages/core/` (still owns auth domain — left intact for Plan 3)
- `packages/api/` (still wires the old content router; orphaned but unused after Plan 2 — actually used by ZERO consumers since reference apps are empty)
- `packages/cms-core/src/collections/articles/` — the OLD location. After Plan 2, both old and new exist; Plan 6 deletes `cms-core` entirely. Apps/cms reads from `core-cms` which only sees the NEW location.
---
## Phase 1: Blog package skeleton + entity layer
### Task 2.1: Scaffold @repo/blog package
**Files:**
- Create: `packages/blog/package.json`
- Create: `packages/blog/tsconfig.json`
- Create: `packages/blog/turbo.json`
- Create: `packages/blog/vitest.config.ts`
- Create: `packages/blog/src/index.ts`
- [ ] **Step 1: Create package.json**
```json
{
"name": "@repo/blog",
"private": true,
"version": "0.0.0",
"type": "module",
"exports": {
".": "./src/index.ts",
"./cms": "./src/integrations/cms/index.ts",
"./api": "./src/integrations/api/router.ts"
},
"scripts": {
"build": "tsc --noEmit",
"lint": "eslint .",
"test": "vitest run --passWithNoTests",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@repo/core-cms": "workspace:*",
"@repo/core-shared": "workspace:*",
"@trpc/server": "^11.0.0",
"inversify": "^6.2.0",
"payload": "^3.14.0",
"reflect-metadata": "^0.2.2",
"zod": "^3.24.0"
},
"devDependencies": {
"@repo/eslint-config": "workspace:*",
"@repo/typescript-config": "workspace:*",
"@types/node": "^22.0.0",
"vitest": "^3.1.0"
}
}
```
- [ ] **Step 2: Create tsconfig.json**
```json
{
"extends": "@repo/typescript-config/base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"lib": ["ES2022", "DOM"],
"jsx": "preserve",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*", "tests/**/*"],
"exclude": ["node_modules", "dist"]
}
```
> Note: `@/*` path alias is defined here for use in **test files** only. Source files inside `src/` should use **relative imports** (`../../entities/article` etc.). Reason: when a downstream package like `@repo/core-api` typechecks and follows imports into `@repo/blog`, it descends into blog's source files but doesn't have blog's `@/` alias in its own tsconfig — causing TS2307 errors. Relative imports in source files avoid this. Tests stay in `tests/` or alongside source where they use the same package context, so `@/` works there. Same convention applies to all feature packages in subsequent plans.
- [ ] **Step 3: Create turbo.json**
```json
{
"extends": ["//"],
"tags": ["feature"]
}
```
- [ ] **Step 4: Create vitest.config.ts**
```typescript
import path from "node:path";
import { baseVitestConfig } from "@repo/typescript-config/vitest.base";
export default {
...baseVitestConfig,
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
};
```
> Note: Vitest does NOT automatically read tsconfig `paths`. The `@/` alias used in `src/**/*.ts` source/test files must be declared explicitly in vitest.config.ts via `resolve.alias`. Same pattern repeats for every feature package's vitest.config.ts.
- [ ] **Step 5: Create empty index.ts**
```typescript
export {};
```
- [ ] **Step 6: Add path aliases to root tsconfig.base.json**
Read the current `tsconfig.base.json`. Add three new entries to `compilerOptions.paths` (alphabetically — between `@repo/core-ui` and `@repo/core-shared` if existing list is sorted; otherwise just at the end of the paths object before the closing brace):
```json
"@repo/blog": ["packages/blog/src/index.ts"],
"@repo/blog/cms": ["packages/blog/src/integrations/cms/index.ts"],
"@repo/blog/api": ["packages/blog/src/integrations/api/router.ts"]
```
- [ ] **Step 7: Install + verify**
Run: `pnpm install`
Expected: `@repo/blog` registered in workspace; lockfile updated.
- [ ] **Step 8: Commit**
```bash
git add packages/blog tsconfig.base.json pnpm-lock.yaml
git commit -m "feat(blog): scaffold empty package with feature tag + path aliases"
```
---
### Task 2.2: Port Article entity (with content widened to unknown)
**Files:**
- Create: `packages/blog/src/entities/article.ts`
- Create: `packages/blog/src/entities/article.test.ts`
- Create: `packages/blog/src/entities/errors.ts`
- [ ] **Step 1: Write the failing test**
```typescript
// packages/blog/src/entities/article.test.ts
import { describe, expect, it } from "vitest";
import { articleSchema, articleStatusSchema, type Article } from "./article";
describe("articleSchema", () => {
it("accepts a minimal valid article with default status", () => {
const result = articleSchema.parse({
id: "abc",
title: "Hello",
slug: "hello",
content: { type: "doc", children: [] },
authorId: "u1",
createdAt: new Date(),
updatedAt: new Date(),
});
expect(result.status).toBe("draft");
});
it("accepts unknown rich-text content", () => {
const result = articleSchema.parse({
id: "abc",
title: "Hello",
slug: "hello",
content: "any string is also fine",
authorId: "u1",
createdAt: new Date(),
updatedAt: new Date(),
});
expect(result.content).toBe("any string is also fine");
});
it("rejects empty title", () => {
expect(() =>
articleSchema.parse({
id: "a",
title: "",
slug: "s",
content: null,
authorId: "u",
createdAt: new Date(),
updatedAt: new Date(),
}),
).toThrow();
});
it("rejects title over 255 chars", () => {
expect(() =>
articleSchema.parse({
id: "a",
title: "x".repeat(256),
slug: "s",
content: null,
authorId: "u",
createdAt: new Date(),
updatedAt: new Date(),
}),
).toThrow();
});
});
describe("articleStatusSchema", () => {
it("accepts 'draft' and 'published'", () => {
expect(articleStatusSchema.parse("draft")).toBe("draft");
expect(articleStatusSchema.parse("published")).toBe("published");
});
it("rejects unknown status", () => {
expect(() => articleStatusSchema.parse("archived")).toThrow();
});
});
describe("Article type", () => {
it("widens content to unknown", () => {
const _example: Article = {
id: "x",
title: "t",
slug: "s",
content: { whatever: true },
status: "draft",
authorId: "u",
createdAt: new Date(),
updatedAt: new Date(),
};
expect(_example).toBeDefined();
});
});
```
- [ ] **Step 2: Run — expect failure**
Run: `cd packages/blog && pnpm vitest run src/entities/article.test.ts`
Expected: FAIL — "Cannot find module './article'"
- [ ] **Step 3: Implement article entity**
```typescript
// packages/blog/src/entities/article.ts
import { z } from "zod";
export const articleStatusSchema = z.enum(["draft", "published"]);
export const articleSchema = z.object({
id: z.string(),
title: z.string().min(1).max(255),
slug: z.string().min(1).max(255),
content: z.unknown(),
status: articleStatusSchema.default("draft"),
authorId: z.string(),
createdAt: z.date(),
updatedAt: z.date(),
});
export type Article = z.infer<typeof articleSchema>;
export type ArticleStatus = z.infer<typeof articleStatusSchema>;
```
- [ ] **Step 4: Create errors.ts (domain-specific errors)**
```typescript
// packages/blog/src/entities/errors.ts
export class ArticleNotFoundError extends Error {
constructor(message = "Article not found", options?: ErrorOptions) {
super(message, options);
}
}
export class InputParseError extends Error {
constructor(message: string, options?: ErrorOptions) {
super(message, options);
}
}
```
> Note: `InputParseError` is duplicated here because the new architecture has each feature own its errors (no shared `@repo/core/entities/errors`). The class is small.
- [ ] **Step 5: Run — expect pass**
Run: `cd packages/blog && pnpm vitest run src/entities/article.test.ts`
Expected: PASS — 7 tests.
- [ ] **Step 6: Commit**
```bash
git add packages/blog/src/entities
git commit -m "feat(blog): add Article entity with rich-text content + domain errors"
```
---
## Phase 2: Application layer (use-cases + repository interface)
### Task 2.3: Define IArticlesRepository interface
**Files:**
- Create: `packages/blog/src/application/repositories/articles-repository.interface.ts`
- [ ] **Step 1: Implement** (no test — interface has no behavior; exercised through use-case tests)
```typescript
// packages/blog/src/application/repositories/articles-repository.interface.ts
import type { Article } from "@/entities/article";
export interface IArticlesRepository {
getArticle(id: string): Promise<Article | undefined>;
getArticleBySlug(slug: string): Promise<Article | undefined>;
getArticles(options?: {
status?: string;
authorId?: string;
limit?: number;
offset?: number;
}): Promise<Article[]>;
createArticle(input: Article): Promise<Article>;
updateArticle(
id: string,
input: Partial<Article>,
): Promise<Article | undefined>;
}
```
> Note: `getArticleBySlug` is added beyond the original `packages/core` interface — needed by `articleBySlug` tRPC procedure (the canonical example query in spec §11.15).
- [ ] **Step 2: Verify it compiles**
Run: `cd packages/blog && pnpm typecheck`
Expected: PASS.
- [ ] **Step 3: Commit**
```bash
git add packages/blog/src/application/repositories
git commit -m "feat(blog): add IArticlesRepository interface"
```
---
### Task 2.4: Implement getArticles use-case + tests
**Files:**
- Create: `packages/blog/src/application/use-cases/get-articles.use-case.ts`
- Create: `packages/blog/src/application/use-cases/get-articles.use-case.test.ts`
- [ ] **Step 1: Write the failing test**
```typescript
// packages/blog/src/application/use-cases/get-articles.use-case.test.ts
import { beforeEach, describe, expect, it } from "vitest";
import { blogContainer } from "@/di/container";
import { BLOG_SYMBOLS } from "@/di/symbols";
import type { IArticlesRepository } from "@/application/repositories/articles-repository.interface";
import { MockArticlesRepository } from "@/infrastructure/repositories/mock-articles.repository";
import { getArticlesUseCase } from "./get-articles.use-case";
describe("getArticlesUseCase", () => {
let repo: MockArticlesRepository;
beforeEach(() => {
if (blogContainer.isBound(BLOG_SYMBOLS.IArticlesRepository)) {
blogContainer.unbind(BLOG_SYMBOLS.IArticlesRepository);
}
repo = new MockArticlesRepository();
blogContainer
.bind<IArticlesRepository>(BLOG_SYMBOLS.IArticlesRepository)
.toConstantValue(repo);
});
it("returns all articles with no filters", async () => {
const now = new Date();
await repo.createArticle({
id: "1",
title: "A",
slug: "a",
content: null,
status: "draft",
authorId: "u1",
createdAt: now,
updatedAt: now,
});
const result = await getArticlesUseCase();
expect(result).toHaveLength(1);
expect(result[0]?.id).toBe("1");
});
it("filters by status", async () => {
const now = new Date();
await repo.createArticle({
id: "1",
title: "A",
slug: "a",
content: null,
status: "draft",
authorId: "u1",
createdAt: now,
updatedAt: now,
});
await repo.createArticle({
id: "2",
title: "B",
slug: "b",
content: null,
status: "published",
authorId: "u1",
createdAt: now,
updatedAt: now,
});
const result = await getArticlesUseCase({ status: "published" });
expect(result).toHaveLength(1);
expect(result[0]?.id).toBe("2");
});
});
```
- [ ] **Step 2: Run — expect failure**
Run: `cd packages/blog && pnpm vitest run src/application/use-cases/get-articles.use-case.test.ts`
Expected: FAIL — multiple "Cannot find module" errors (the test imports from `@/di/container`, `@/di/symbols`, `@/infrastructure/repositories/mock-articles.repository`, none of which exist yet).
> This is intentional: TDD here means the use-case test exercises the full DI container shape AND the mock repo. Both will be implemented in subsequent tasks. For now, just write the use-case to satisfy its direct dependencies.
- [ ] **Step 3: Implement use-case**
```typescript
// packages/blog/src/application/use-cases/get-articles.use-case.ts
import type { Article } from "@/entities/article";
import { blogContainer } from "@/di/container";
import { BLOG_SYMBOLS } from "@/di/symbols";
import type { IArticlesRepository } from "@/application/repositories/articles-repository.interface";
export async function getArticlesUseCase(options?: {
status?: string;
authorId?: string;
limit?: number;
offset?: number;
}): Promise<Article[]> {
const repo = blogContainer.get<IArticlesRepository>(
BLOG_SYMBOLS.IArticlesRepository,
);
return repo.getArticles(options);
}
```
> Note: This still won't compile or run because `@/di/container`, `@/di/symbols`, `@/infrastructure/repositories/mock-articles.repository` don't exist yet. Test will continue to fail. That's expected — Tasks 2.5-2.7 implement those, then this test passes.
- [ ] **Step 4: Commit (test + impl, both currently failing — establishes the contract)**
```bash
git add packages/blog/src/application/use-cases/get-articles.use-case.ts packages/blog/src/application/use-cases/get-articles.use-case.test.ts
git commit -m "feat(blog): add getArticlesUseCase (test red until DI + mock repo exist)"
```
---
### Task 2.5: Implement createArticle use-case + tests
**Files:**
- Create: `packages/blog/src/application/use-cases/create-article.use-case.ts`
- Create: `packages/blog/src/application/use-cases/create-article.use-case.test.ts`
- [ ] **Step 1: Write the failing test**
```typescript
// packages/blog/src/application/use-cases/create-article.use-case.test.ts
import { beforeEach, describe, expect, it } from "vitest";
import { blogContainer } from "@/di/container";
import { BLOG_SYMBOLS } from "@/di/symbols";
import type { IArticlesRepository } from "@/application/repositories/articles-repository.interface";
import { MockArticlesRepository } from "@/infrastructure/repositories/mock-articles.repository";
import { createArticleUseCase } from "./create-article.use-case";
describe("createArticleUseCase", () => {
let repo: MockArticlesRepository;
beforeEach(() => {
if (blogContainer.isBound(BLOG_SYMBOLS.IArticlesRepository)) {
blogContainer.unbind(BLOG_SYMBOLS.IArticlesRepository);
}
repo = new MockArticlesRepository();
blogContainer
.bind<IArticlesRepository>(BLOG_SYMBOLS.IArticlesRepository)
.toConstantValue(repo);
});
it("creates an article in draft status with auto-generated slug", async () => {
const result = await createArticleUseCase({
title: "Hello World",
content: "body",
authorId: "u1",
});
expect(result.title).toBe("Hello World");
expect(result.slug).toBe("hello-world");
expect(result.status).toBe("draft");
expect(result.id).toBeTruthy();
const stored = await repo.getArticle(result.id);
expect(stored).toBeDefined();
});
it("uses provided slug when supplied", async () => {
const result = await createArticleUseCase({
title: "Whatever",
content: "body",
authorId: "u1",
slug: "custom-slug",
});
expect(result.slug).toBe("custom-slug");
});
});
```
- [ ] **Step 2: Run — expect failure**
Run: `cd packages/blog && pnpm vitest run src/application/use-cases/create-article.use-case.test.ts`
Expected: FAIL — module-not-found errors.
- [ ] **Step 3: Implement use-case**
```typescript
// packages/blog/src/application/use-cases/create-article.use-case.ts
import type { Article } from "@/entities/article";
import { blogContainer } from "@/di/container";
import { BLOG_SYMBOLS } from "@/di/symbols";
import type { IArticlesRepository } from "@/application/repositories/articles-repository.interface";
function generateSlug(title: string): string {
return title
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
export async function createArticleUseCase(input: {
title: string;
content: unknown;
authorId: string;
slug?: string;
}): Promise<Article> {
const repo = blogContainer.get<IArticlesRepository>(
BLOG_SYMBOLS.IArticlesRepository,
);
const now = new Date();
const article: Article = {
id: crypto.randomUUID(),
title: input.title,
slug: input.slug ?? generateSlug(input.title),
content: input.content,
status: "draft",
authorId: input.authorId,
createdAt: now,
updatedAt: now,
};
return repo.createArticle(article);
}
```
- [ ] **Step 4: Commit (test still failing — DI not yet implemented)**
```bash
git add packages/blog/src/application/use-cases/create-article.use-case.ts packages/blog/src/application/use-cases/create-article.use-case.test.ts
git commit -m "feat(blog): add createArticleUseCase (test red until DI + mock repo exist)"
```
---
## Phase 3: Infrastructure layer (mock + payload repos)
### Task 2.6: Mock articles repository
**Files:**
- Create: `packages/blog/src/infrastructure/repositories/mock-articles.repository.ts`
- [ ] **Step 1: Implement** (no separate test — exercised by use-case tests)
```typescript
// packages/blog/src/infrastructure/repositories/mock-articles.repository.ts
import "reflect-metadata";
import { injectable } from "inversify";
import type { IArticlesRepository } from "@/application/repositories/articles-repository.interface";
import type { Article } from "@/entities/article";
@injectable()
export class MockArticlesRepository implements IArticlesRepository {
private _articles: Article[] = [];
async getArticle(id: string): Promise<Article | undefined> {
return this._articles.find((a) => a.id === id);
}
async getArticleBySlug(slug: string): Promise<Article | undefined> {
return this._articles.find((a) => a.slug === slug);
}
async getArticles(options?: {
status?: string;
authorId?: string;
limit?: number;
offset?: number;
}): Promise<Article[]> {
let result = [...this._articles];
if (options?.status) {
result = result.filter((a) => a.status === options.status);
}
if (options?.authorId) {
result = result.filter((a) => a.authorId === options.authorId);
}
const offset = options?.offset ?? 0;
const limit = options?.limit ?? 50;
return result.slice(offset, offset + limit);
}
async createArticle(input: Article): Promise<Article> {
this._articles.push(input);
return input;
}
async updateArticle(
id: string,
input: Partial<Article>,
): Promise<Article | undefined> {
const index = this._articles.findIndex((a) => a.id === id);
if (index === -1) return undefined;
const current = this._articles[index];
if (!current) return undefined;
const updated: Article = { ...current, ...input, id: current.id };
this._articles[index] = updated;
return updated;
}
}
```
- [ ] **Step 2: Verify it compiles** (use-case tests still won't pass — DI container not yet implemented)
Run: `cd packages/blog && pnpm typecheck`
Expected: PASS.
- [ ] **Step 3: Commit**
```bash
git add packages/blog/src/infrastructure/repositories/mock-articles.repository.ts
git commit -m "feat(blog): add MockArticlesRepository for tests"
```
---
### Task 2.7: Per-feature DI container (symbols, module, container)
**Files:**
- Create: `packages/blog/src/di/symbols.ts`
- Create: `packages/blog/src/di/module.ts`
- Create: `packages/blog/src/di/container.ts`
- Create: `packages/blog/src/di/container.test.ts`
- [ ] **Step 1: Write the failing test**
```typescript
// packages/blog/src/di/container.test.ts
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { blogContainer } from "./container";
import { BLOG_SYMBOLS } from "./symbols";
import { BlogModule } from "./module";
import { MockArticlesRepository } from "@/infrastructure/repositories/mock-articles.repository";
import type { IArticlesRepository } from "@/application/repositories/articles-repository.interface";
describe("blogContainer", () => {
beforeEach(() => {
blogContainer.unbindAll();
blogContainer.load(BlogModule);
});
afterEach(() => {
blogContainer.unbindAll();
});
it("resolves IArticlesRepository to MockArticlesRepository by default binding", () => {
const repo = blogContainer.get<IArticlesRepository>(
BLOG_SYMBOLS.IArticlesRepository,
);
expect(repo).toBeInstanceOf(MockArticlesRepository);
});
it("supports rebinding to a custom repo", () => {
blogContainer.unbind(BLOG_SYMBOLS.IArticlesRepository);
const custom = new MockArticlesRepository();
blogContainer
.bind<IArticlesRepository>(BLOG_SYMBOLS.IArticlesRepository)
.toConstantValue(custom);
const resolved = blogContainer.get<IArticlesRepository>(
BLOG_SYMBOLS.IArticlesRepository,
);
expect(resolved).toBe(custom);
});
});
```
- [ ] **Step 2: Run — expect failure**
Run: `cd packages/blog && pnpm vitest run src/di/container.test.ts`
Expected: FAIL — module-not-found errors.
- [ ] **Step 3: Implement symbols.ts**
```typescript
// packages/blog/src/di/symbols.ts
export const BLOG_SYMBOLS = {
IArticlesRepository: Symbol.for("blog:IArticlesRepository"),
} as const;
```
> Note: Symbol descriptions are namespaced (`blog:IArticlesRepository`) so debug output is clear and there's no collision with other features' symbols at the JavaScript level (each feature has its own container, so collisions can't actually happen — this is just hygiene).
- [ ] **Step 4: Implement module.ts**
```typescript
// packages/blog/src/di/module.ts
import { ContainerModule, type interfaces } from "inversify";
import type { IArticlesRepository } from "@/application/repositories/articles-repository.interface";
import { MockArticlesRepository } from "@/infrastructure/repositories/mock-articles.repository";
import { BLOG_SYMBOLS } from "./symbols";
export const BlogModule = new ContainerModule((bind: interfaces.Bind) => {
bind<IArticlesRepository>(BLOG_SYMBOLS.IArticlesRepository).to(
MockArticlesRepository,
);
});
```
> Note: Default binding is `MockArticlesRepository`. The payload-backed repo (Task 2.8) is bound at app boot (Plan 5) by rebinding before serving requests. Tests rebind per `beforeEach`. This keeps the package usable in isolation (e.g., for unit tests, Storybook dev) without requiring a database.
- [ ] **Step 5: Implement container.ts**
```typescript
// packages/blog/src/di/container.ts
import "reflect-metadata";
import { Container } from "inversify";
import { BlogModule } from "./module";
export const blogContainer = new Container({ defaultScope: "Singleton" });
blogContainer.load(BlogModule);
```
- [ ] **Step 6: Run — expect pass**
Run: `cd packages/blog && pnpm vitest run src/di/container.test.ts`
Expected: PASS — 2 tests.
- [ ] **Step 7: Run the previously-red use-case tests**
Run: `cd packages/blog && pnpm vitest run src/application/use-cases`
Expected: PASS — 4 tests (2 from `getArticles`, 2 from `createArticle`).
- [ ] **Step 8: Commit**
```bash
git add packages/blog/src/di
git commit -m "feat(blog): add per-feature InversifyJS container (symbols, module, container)"
```
---
### Task 2.8: Payload articles repository
**Files:**
- Create: `packages/blog/src/infrastructure/repositories/payload-articles.repository.ts`
- Create: `packages/blog/src/infrastructure/repositories/payload-articles.repository.test.ts`
- [ ] **Step 1: Write the failing test**
```typescript
// packages/blog/src/infrastructure/repositories/payload-articles.repository.test.ts
import { describe, expect, it, vi } from "vitest";
import { PayloadArticlesRepository } from "./payload-articles.repository";
// Mock the payload module so we don't need a real DB
vi.mock("payload", () => ({
getPayload: vi.fn(),
}));
vi.mock("@repo/core-cms", () => ({
default: {} as never,
}));
describe("PayloadArticlesRepository", () => {
it("maps a Payload doc to a domain Article on getArticleBySlug", async () => {
const { getPayload } = await import("payload");
const findMock = vi.fn().mockResolvedValue({
docs: [
{
id: "p-123",
title: "Hello",
slug: "hello",
content: { type: "doc", children: [] },
status: "published",
author: "u1",
createdAt: "2026-05-04T12:00:00.000Z",
updatedAt: "2026-05-04T12:00:00.000Z",
},
],
});
(getPayload as ReturnType<typeof vi.fn>).mockResolvedValue({
find: findMock,
});
const repo = new PayloadArticlesRepository();
const result = await repo.getArticleBySlug("hello");
expect(findMock).toHaveBeenCalledWith({
collection: "articles",
where: { slug: { equals: "hello" } },
limit: 1,
overrideAccess: false,
});
expect(result?.id).toBe("p-123");
expect(result?.slug).toBe("hello");
expect(result?.status).toBe("published");
expect(result?.authorId).toBe("u1");
expect(result?.createdAt).toBeInstanceOf(Date);
});
it("returns undefined when slug is not found", async () => {
const { getPayload } = await import("payload");
(getPayload as ReturnType<typeof vi.fn>).mockResolvedValue({
find: vi.fn().mockResolvedValue({ docs: [] }),
});
const repo = new PayloadArticlesRepository();
const result = await repo.getArticleBySlug("missing");
expect(result).toBeUndefined();
});
});
```
- [ ] **Step 2: Run — expect failure**
Run: `cd packages/blog && pnpm vitest run src/infrastructure/repositories/payload-articles.repository.test.ts`
Expected: FAIL — "Cannot find module './payload-articles.repository'"
- [ ] **Step 3: Implement payload-articles repository**
```typescript
// packages/blog/src/infrastructure/repositories/payload-articles.repository.ts
import "reflect-metadata";
import { injectable } from "inversify";
import { getPayload } from "payload";
import config from "@repo/core-cms";
import type { IArticlesRepository } from "@/application/repositories/articles-repository.interface";
import type { Article } from "@/entities/article";
type PayloadArticleDoc = {
id: string | number;
title?: string | null;
slug?: string | null;
content?: unknown;
status?: string | null;
author?: string | number | { id: string | number } | null;
createdAt?: string | null;
updatedAt?: string | null;
};
function mapDoc(doc: PayloadArticleDoc): Article {
const authorId =
typeof doc.author === "object" && doc.author !== null
? String(doc.author.id)
: doc.author != null
? String(doc.author)
: "";
return {
id: String(doc.id),
title: doc.title ?? "",
slug: doc.slug ?? "",
content: doc.content ?? null,
status: (doc.status === "published" ? "published" : "draft"),
authorId,
createdAt: doc.createdAt ? new Date(doc.createdAt) : new Date(0),
updatedAt: doc.updatedAt ? new Date(doc.updatedAt) : new Date(0),
};
}
@injectable()
export class PayloadArticlesRepository implements IArticlesRepository {
async getArticle(id: string): Promise<Article | undefined> {
const payload = await getPayload({ config });
try {
const doc = await payload.findByID({
collection: "articles",
id,
overrideAccess: false,
});
return mapDoc(doc as PayloadArticleDoc);
} catch {
return undefined;
}
}
async getArticleBySlug(slug: string): Promise<Article | undefined> {
const payload = await getPayload({ config });
const result = await payload.find({
collection: "articles",
where: { slug: { equals: slug } },
limit: 1,
overrideAccess: false,
});
const doc = result.docs[0] as PayloadArticleDoc | undefined;
return doc ? mapDoc(doc) : undefined;
}
async getArticles(options?: {
status?: string;
authorId?: string;
limit?: number;
offset?: number;
}): Promise<Article[]> {
const payload = await getPayload({ config });
const where: Record<string, unknown> = {};
if (options?.status) where.status = { equals: options.status };
if (options?.authorId) where.author = { equals: options.authorId };
const result = await payload.find({
collection: "articles",
where,
limit: options?.limit ?? 50,
page: options?.offset
? Math.floor(options.offset / (options.limit ?? 50)) + 1
: 1,
overrideAccess: false,
});
return result.docs.map((d) => mapDoc(d as PayloadArticleDoc));
}
async createArticle(input: Article): Promise<Article> {
const payload = await getPayload({ config });
const created = await payload.create({
collection: "articles",
data: {
title: input.title,
slug: input.slug,
content: input.content,
status: input.status,
author: input.authorId,
} as never,
overrideAccess: false,
});
return mapDoc(created as PayloadArticleDoc);
}
async updateArticle(
id: string,
input: Partial<Article>,
): Promise<Article | undefined> {
const payload = await getPayload({ config });
try {
const updated = await payload.update({
collection: "articles",
id,
data: {
...(input.title !== undefined && { title: input.title }),
...(input.slug !== undefined && { slug: input.slug }),
...(input.content !== undefined && { content: input.content }),
...(input.status !== undefined && { status: input.status }),
...(input.authorId !== undefined && { author: input.authorId }),
} as never,
overrideAccess: false,
});
return mapDoc(updated as PayloadArticleDoc);
} catch {
return undefined;
}
}
}
```
> Note: `as never` casts on the Payload `data` arguments are needed because Payload's generated types (which we don't have for the empty-collection core-cms config) would normally constrain these. With empty `collections: []`, the generated types don't include `'articles'`, so we cast through `never`. After Plan 3 wires articles into core-cms.collections AND `pnpm generate:types` runs, these casts can potentially be removed — but for Plan 2's purpose (decoupled feature with deferred wiring) the casts are correct.
> **Critical architectural note (revised after execution):** The repo takes `SanitizedConfig` via its constructor instead of `import config from '@repo/core-cms'` (which would create a workspace dependency cycle: blog ↔ core-cms). Constructor injection breaks the cycle at the package-graph level. The DI binding for production (Plan 5, app boot) supplies the config:
> ```ts
> import config from '@repo/core-cms'
> blogContainer.bind(BLOG_SYMBOLS.IArticlesRepository)
> .toDynamicValue(() => new PayloadArticlesRepository(config))
> ```
> The blog `package.json` does NOT declare `@repo/core-cms` as a dependency. Apply this pattern to every payload-backed feature repository.
- [ ] **Step 4: Run — expect pass**
Run: `cd packages/blog && pnpm vitest run src/infrastructure/repositories/payload-articles.repository.test.ts`
Expected: PASS — 2 tests.
- [ ] **Step 5: Commit**
```bash
git add packages/blog/src/infrastructure/repositories/payload-articles.repository.ts packages/blog/src/infrastructure/repositories/payload-articles.repository.test.ts
git commit -m "feat(blog): add PayloadArticlesRepository with doc-to-entity mapping"
```
---
## Phase 4: Interface-adapters (controllers)
### Task 2.9: Articles controller + tests
**Files:**
- Create: `packages/blog/src/interface-adapters/controllers/articles.controller.ts`
- Create: `packages/blog/src/interface-adapters/controllers/articles.controller.test.ts`
- [ ] **Step 1: Write the failing test**
```typescript
// packages/blog/src/interface-adapters/controllers/articles.controller.test.ts
import { beforeEach, describe, expect, it } from "vitest";
import { blogContainer } from "@/di/container";
import { BLOG_SYMBOLS } from "@/di/symbols";
import { MockArticlesRepository } from "@/infrastructure/repositories/mock-articles.repository";
import type { IArticlesRepository } from "@/application/repositories/articles-repository.interface";
import { InputParseError } from "@/entities/errors";
import {
createArticleController,
getArticlesController,
getArticleBySlugController,
} from "./articles.controller";
describe("articles controller", () => {
let repo: MockArticlesRepository;
beforeEach(() => {
if (blogContainer.isBound(BLOG_SYMBOLS.IArticlesRepository)) {
blogContainer.unbind(BLOG_SYMBOLS.IArticlesRepository);
}
repo = new MockArticlesRepository();
blogContainer
.bind<IArticlesRepository>(BLOG_SYMBOLS.IArticlesRepository)
.toConstantValue(repo);
});
describe("createArticleController", () => {
it("creates an article on valid input", async () => {
const result = await createArticleController({
title: "Hello",
content: "body",
authorId: "u1",
});
expect(result.title).toBe("Hello");
});
it("throws InputParseError on missing title", async () => {
await expect(
createArticleController({ content: "body", authorId: "u1" }),
).rejects.toBeInstanceOf(InputParseError);
});
});
describe("getArticlesController", () => {
it("returns array on valid input", async () => {
const result = await getArticlesController({});
expect(result).toEqual([]);
});
it("throws InputParseError on invalid input shape", async () => {
await expect(
getArticlesController({ limit: "not a number" } as unknown as Record<
string,
unknown
>),
).rejects.toBeInstanceOf(InputParseError);
});
});
describe("getArticleBySlugController", () => {
it("returns undefined for missing slug", async () => {
const result = await getArticleBySlugController({ slug: "nope" });
expect(result).toBeUndefined();
});
it("throws InputParseError on missing slug", async () => {
await expect(
getArticleBySlugController({} as { slug: string }),
).rejects.toBeInstanceOf(InputParseError);
});
});
});
```
- [ ] **Step 2: Run — expect failure**
Run: `cd packages/blog && pnpm vitest run src/interface-adapters/controllers/articles.controller.test.ts`
Expected: FAIL — "Cannot find module './articles.controller'"
- [ ] **Step 3: Implement controller**
```typescript
// packages/blog/src/interface-adapters/controllers/articles.controller.ts
import { z } from "zod";
import { InputParseError } from "@/entities/errors";
import type { Article } from "@/entities/article";
import { blogContainer } from "@/di/container";
import { BLOG_SYMBOLS } from "@/di/symbols";
import type { IArticlesRepository } from "@/application/repositories/articles-repository.interface";
import { getArticlesUseCase } from "@/application/use-cases/get-articles.use-case";
import { createArticleUseCase } from "@/application/use-cases/create-article.use-case";
const createInputSchema = z.object({
title: z.string().min(1).max(255),
content: z.unknown(),
authorId: z.string(),
slug: z.string().optional(),
});
const getInputSchema = z.object({
status: z.string().optional(),
authorId: z.string().optional(),
limit: z.number().optional(),
offset: z.number().optional(),
});
const getBySlugInputSchema = z.object({
slug: z.string().min(1),
});
export async function createArticleController(
input: Partial<z.infer<typeof createInputSchema>>,
): Promise<Article> {
const parsed = createInputSchema.safeParse(input);
if (!parsed.success) {
throw new InputParseError("Invalid create-article input", {
cause: parsed.error,
});
}
return createArticleUseCase(parsed.data);
}
export async function getArticlesController(
input: Partial<z.infer<typeof getInputSchema>>,
): Promise<Article[]> {
const parsed = getInputSchema.safeParse(input);
if (!parsed.success) {
throw new InputParseError("Invalid get-articles input", {
cause: parsed.error,
});
}
return getArticlesUseCase(parsed.data);
}
export async function getArticleBySlugController(input: {
slug: string;
}): Promise<Article | undefined> {
const parsed = getBySlugInputSchema.safeParse(input);
if (!parsed.success) {
throw new InputParseError("Invalid get-article-by-slug input", {
cause: parsed.error,
});
}
const repo = blogContainer.get<IArticlesRepository>(
BLOG_SYMBOLS.IArticlesRepository,
);
return repo.getArticleBySlug(parsed.data.slug);
}
```
> Note: `getArticleBySlugController` calls the repo directly rather than going through a separate `getArticleBySlug.use-case.ts`. This is acceptable per spec addendum v5 — for trivial pass-through reads, a use-case file would be empty ceremony. The pattern emerges if/when this read needs caching, authorization decisions, or other application logic. (Plan 3 leaves it as-is; create one in a later plan if behavior justifies it.)
- [ ] **Step 4: Run — expect pass**
Run: `cd packages/blog && pnpm vitest run src/interface-adapters/controllers/articles.controller.test.ts`
Expected: PASS — 6 tests.
- [ ] **Step 5: Commit**
```bash
git add packages/blog/src/interface-adapters
git commit -m "feat(blog): add articles controller with Zod validation"
```
---
## Phase 5: Integrations (CMS schema + tRPC API)
### Task 2.10: Articles collection (simplified — no cross-feature relations)
**Files:**
- Create: `packages/blog/src/integrations/cms/collections/articles.ts`
- Create: `packages/blog/src/integrations/cms/index.ts`
- [ ] **Step 1: Implement collection (simplified — see schema simplifications section)**
```typescript
// packages/blog/src/integrations/cms/collections/articles.ts
import type { CollectionConfig } from "payload";
import { slugifyIfMissing } from "@repo/core-shared/payload";
export const articles: CollectionConfig = {
slug: "articles",
admin: {
useAsTitle: "title",
defaultColumns: ["title", "status", "author", "updatedAt"],
},
hooks: {
beforeChange: [slugifyIfMissing],
},
versions: {
drafts: true,
},
fields: [
{
name: "title",
type: "text",
required: true,
maxLength: 255,
},
{
name: "slug",
type: "text",
unique: true,
admin: {
position: "sidebar",
description: "Auto-generated from title if left empty",
},
},
{
name: "content",
type: "richText",
},
{
name: "status",
type: "select",
options: [
{ label: "Draft", value: "draft" },
{ label: "Published", value: "published" },
],
defaultValue: "draft",
required: true,
admin: {
position: "sidebar",
},
},
// TODO(plan-3): Restore as `relationship → users` once auth feature is migrated.
{
name: "author",
type: "text",
required: true,
admin: {
position: "sidebar",
description:
"Temporary text field; restored to users relationship in Plan 3.",
},
},
// TODO(plan-3): Restore `featuredImage: upload → media` once media feature is migrated.
{
name: "publishedAt",
type: "date",
admin: {
position: "sidebar",
date: {
pickerAppearance: "dayAndTime",
},
},
},
],
};
```
> Note: `slugifyIfMissing` from `@repo/core-shared/payload` replaces the inline `autoGenerateSlug` hook from `packages/cms-core/src/collections/articles/hooks/before-change.ts`. Same behavior, generic helper.
- [ ] **Step 2: Implement integrations/cms barrel**
```typescript
// packages/blog/src/integrations/cms/index.ts
export { articles } from "./collections/articles";
```
- [ ] **Step 3: Verify it compiles**
Run: `cd packages/blog && pnpm typecheck`
Expected: PASS.
- [ ] **Step 4: Commit**
```bash
git add packages/blog/src/integrations/cms
git commit -m "feat(blog): add articles collection (simplified — no cross-feature refs)"
```
---
### Task 2.11: Wire articles into core-cms composition
**Files:**
- Modify: `packages/core-cms/src/payload.config.ts`
- Modify: `packages/core-cms/package.json` (add `@repo/blog` dependency)
- [ ] **Step 1: Add @repo/blog to core-cms dependencies**
Add inside `packages/core-cms/package.json`'s `dependencies` block:
```json
"@repo/blog": "workspace:*",
```
> Note: This is the spec §4.2 composition exception — `core-cms` may import feature `/cms` exports.
- [ ] **Step 2: Update payload.config.ts to register articles**
Read current `packages/core-cms/src/payload.config.ts`. Replace it with:
```typescript
import { buildConfig } from "payload";
import { postgresAdapter } from "@payloadcms/db-postgres";
import { lexicalEditor } from "@payloadcms/richtext-lexical";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { articles } from "@repo/blog/cms";
const filename = fileURLToPath(import.meta.url);
const dirname = path.dirname(filename);
export default buildConfig({
editor: lexicalEditor(),
collections: [articles],
globals: [],
secret: process.env.PAYLOAD_SECRET || "default-secret-change-me",
db: postgresAdapter({
pool: {
connectionString:
process.env.DATABASE_URL ||
"postgresql://postgres:postgres@localhost:5432/template",
},
}),
typescript: {
outputFile: path.resolve(dirname, "generated-types.ts"),
},
});
```
- [ ] **Step 3: Install (picks up workspace dep)**
Run: `pnpm install`
Expected: install completes; `@repo/blog` available to `@repo/core-cms`.
- [ ] **Step 4: Verify core-cms typechecks**
Run: `pnpm typecheck --filter @repo/core-cms --filter @repo/blog`
Expected: PASS for both.
- [ ] **Step 5: Regenerate Payload types** (now articles is in the config; types should include `Article`-shaped doc)
Run: `cd apps/cms && pnpm generate:types`
Expected: writes new `packages/core-cms/src/generated-types.ts` containing an `Articles` interface.
- [ ] **Step 6: Verify generated types compile**
Run: `pnpm typecheck --filter @repo/core-cms`
Expected: PASS.
- [ ] **Step 7: Commit**
```bash
git add packages/core-cms packages/blog pnpm-lock.yaml
git commit -m "feat(core-cms): compose @repo/blog/cms into payload config"
```
---
### Task 2.12: tRPC router (integrations/api)
**Files:**
- Create: `packages/blog/src/integrations/api/router.ts`
- Create: `packages/blog/src/integrations/api/router.test.ts`
- [ ] **Step 1: Write the failing test**
```typescript
// packages/blog/src/integrations/api/router.test.ts
import { beforeEach, describe, expect, it } from "vitest";
import { blogContainer } from "@/di/container";
import { BLOG_SYMBOLS } from "@/di/symbols";
import { MockArticlesRepository } from "@/infrastructure/repositories/mock-articles.repository";
import type { IArticlesRepository } from "@/application/repositories/articles-repository.interface";
import { blogRouter } from "./router";
describe("blogRouter", () => {
let repo: MockArticlesRepository;
beforeEach(() => {
if (blogContainer.isBound(BLOG_SYMBOLS.IArticlesRepository)) {
blogContainer.unbind(BLOG_SYMBOLS.IArticlesRepository);
}
repo = new MockArticlesRepository();
blogContainer
.bind<IArticlesRepository>(BLOG_SYMBOLS.IArticlesRepository)
.toConstantValue(repo);
});
it("exposes articleBySlug, listArticles, createArticle procedures", () => {
const procedureNames = Object.keys(blogRouter._def.procedures);
expect(procedureNames).toContain("articleBySlug");
expect(procedureNames).toContain("listArticles");
expect(procedureNames).toContain("createArticle");
});
it("articleBySlug returns the article when present", async () => {
const now = new Date();
await repo.createArticle({
id: "1",
title: "T",
slug: "t",
content: null,
status: "draft",
authorId: "u1",
createdAt: now,
updatedAt: now,
});
const caller = blogRouter.createCaller({});
const result = await caller.articleBySlug({ slug: "t" });
expect(result?.id).toBe("1");
});
it("listArticles returns all articles when no input is given", async () => {
const caller = blogRouter.createCaller({});
const result = await caller.listArticles();
expect(result).toEqual([]);
});
});
```
- [ ] **Step 2: Run — expect failure**
Run: `cd packages/blog && pnpm vitest run src/integrations/api/router.test.ts`
Expected: FAIL — "Cannot find module './router'"
- [ ] **Step 3: Implement router**
```typescript
// packages/blog/src/integrations/api/router.ts
import { z } from "zod";
import { router, publicProcedure } from "@repo/core-shared/trpc/init";
import {
createArticleController,
getArticlesController,
getArticleBySlugController,
} from "@/interface-adapters/controllers/articles.controller";
export const blogRouter = router({
articleBySlug: publicProcedure
.input(z.object({ slug: z.string().min(1) }))
.query(({ input }) => getArticleBySlugController(input)),
listArticles: publicProcedure
.input(
z
.object({
status: z.string().optional(),
authorId: z.string().optional(),
limit: z.number().optional(),
offset: z.number().optional(),
})
.optional(),
)
.query(({ input }) => getArticlesController(input ?? {})),
createArticle: publicProcedure
.input(
z.object({
title: z.string().min(1).max(255),
content: z.unknown(),
authorId: z.string(),
slug: z.string().optional(),
}),
)
.mutation(({ input }) => createArticleController(input)),
});
export type BlogRouter = typeof blogRouter;
```
- [ ] **Step 4: Run — expect pass**
Run: `cd packages/blog && pnpm vitest run src/integrations/api/router.test.ts`
Expected: PASS — 3 tests.
- [ ] **Step 5: Commit**
```bash
git add packages/blog/src/integrations/api
git commit -m "feat(blog): add tRPC router with articleBySlug + listArticles + createArticle"
```
---
### Task 2.13: Wire blogRouter into core-api
**Files:**
- Modify: `packages/core-api/src/root.ts`
- Modify: `packages/core-api/package.json` (add `@repo/blog`)
- [ ] **Step 1: Add @repo/blog to core-api dependencies**
Add inside `packages/core-api/package.json`'s `dependencies` block:
```json
"@repo/blog": "workspace:*",
```
- [ ] **Step 2: Update root.ts**
Replace `packages/core-api/src/root.ts` with:
```typescript
import { router } from "@repo/core-shared/trpc/init";
import { blogRouter } from "@repo/blog/api";
export const appRouter = router({
blog: blogRouter,
});
export type AppRouter = typeof appRouter;
```
- [ ] **Step 3: Install + verify**
Run: `pnpm install` then `pnpm typecheck --filter @repo/core-api --filter @repo/blog`
Expected: both PASS.
- [ ] **Step 4: Commit**
```bash
git add packages/core-api packages/blog pnpm-lock.yaml
git commit -m "feat(core-api): compose @repo/blog/api into appRouter under 'blog' namespace"
```
---
## Phase 6: UI helpers (deferred render — apps wire in Plan 5)
### Task 2.14: ui/query.ts helper
**Files:**
- Create: `packages/blog/src/ui/query.ts`
- [ ] **Step 1: Implement** (no test — exercised when apps consume in Plan 5)
```typescript
// packages/blog/src/ui/query.ts
// React Query option builders for blog feature procedures.
// Consumed by apps via the @repo/core-trpc client (wired in Plan 5).
//
// Example consumer (Plan 5):
// import { useQuery } from '@tanstack/react-query'
// import { trpc } from '@repo/core-trpc'
// import { articleBySlugQuery } from '@repo/blog/src/ui/query'
// const { data } = useQuery(articleBySlugQuery(trpc, slug))
//
// Kept framework-agnostic here: takes the typed `trpc` client as an argument
// rather than importing it. This avoids importing @repo/core-trpc (which is
// a frontend platform package — feature shouldn't depend on it).
type TrpcClient = {
blog: {
articleBySlug: {
queryOptions: (input: { slug: string }) => unknown;
};
listArticles: {
queryOptions: (input?: {
status?: string;
authorId?: string;
limit?: number;
offset?: number;
}) => unknown;
};
};
};
export function articleBySlugQuery(client: TrpcClient, slug: string) {
return client.blog.articleBySlug.queryOptions({ slug });
}
export function listArticlesQuery(
client: TrpcClient,
options?: { status?: string; authorId?: string; limit?: number; offset?: number },
) {
return client.blog.listArticles.queryOptions(options);
}
```
> Note: `TrpcClient` is locally typed structurally because `@repo/core-trpc` doesn't ship the React-aware client until Plan 5. The structural type is sufficient: when Plan 5 lands and apps call `articleBySlugQuery(trpc, slug)` with the real typed client, structural compatibility holds. Plan 5 may revise this to import from `@repo/core-trpc` once that's available.
- [ ] **Step 2: Verify it compiles**
Run: `cd packages/blog && pnpm typecheck`
Expected: PASS.
- [ ] **Step 3: Commit**
```bash
git add packages/blog/src/ui
git commit -m "feat(blog): add ui/query.ts (framework-agnostic React Query option builders)"
```
---
## Phase 7: Wiring + barrel + feature test
### Task 2.15: Wire blog package barrel
**Files:**
- Modify: `packages/blog/src/index.ts`
- [ ] **Step 1: Replace empty index.ts**
```typescript
// packages/blog/src/index.ts
export type { Article, ArticleStatus } from "./entities/article";
export { ArticleNotFoundError, InputParseError } from "./entities/errors";
export { articleBySlugQuery, listArticlesQuery } from "./ui/query";
```
- [ ] **Step 2: Verify it compiles**
Run: `cd packages/blog && pnpm typecheck`
Expected: PASS.
- [ ] **Step 3: Commit**
```bash
git add packages/blog/src/index.ts
git commit -m "feat(blog): wire root barrel (entity types + ui query helpers)"
```
---
### Task 2.16: Feature-level test (cross-layer)
**Files:**
- Create: `packages/blog/tests/articles.feature.test.ts`
- [ ] **Step 1: Write the test**
```typescript
// packages/blog/tests/articles.feature.test.ts
//
// Feature-level test: exercises the full slice
// tRPC procedure -> controller -> use-case -> mock repo
// without going through a network or the actual Payload Local API.
// Verifies that the layers are correctly wired through the per-feature DI container.
import { beforeEach, describe, expect, it } from "vitest";
import { blogContainer } from "@/di/container";
import { BLOG_SYMBOLS } from "@/di/symbols";
import { MockArticlesRepository } from "@/infrastructure/repositories/mock-articles.repository";
import type { IArticlesRepository } from "@/application/repositories/articles-repository.interface";
import { blogRouter } from "@/integrations/api/router";
describe("blog feature: article-by-slug end-to-end", () => {
let repo: MockArticlesRepository;
beforeEach(() => {
if (blogContainer.isBound(BLOG_SYMBOLS.IArticlesRepository)) {
blogContainer.unbind(BLOG_SYMBOLS.IArticlesRepository);
}
repo = new MockArticlesRepository();
blogContainer
.bind<IArticlesRepository>(BLOG_SYMBOLS.IArticlesRepository)
.toConstantValue(repo);
});
it("creates an article via tRPC, then fetches it back by slug", async () => {
const caller = blogRouter.createCaller({});
const created = await caller.createArticle({
title: "The Vertical Refactor",
content: { type: "doc", children: [] },
authorId: "u1",
slug: "vertical-refactor",
});
expect(created.id).toBeTruthy();
expect(created.slug).toBe("vertical-refactor");
const fetched = await caller.articleBySlug({ slug: "vertical-refactor" });
expect(fetched?.id).toBe(created.id);
expect(fetched?.title).toBe("The Vertical Refactor");
});
it("listArticles filters by status", async () => {
const caller = blogRouter.createCaller({});
await caller.createArticle({
title: "Draft One",
content: null,
authorId: "u1",
});
const draftOnly = await caller.listArticles({ status: "draft" });
expect(draftOnly).toHaveLength(1);
const publishedOnly = await caller.listArticles({ status: "published" });
expect(publishedOnly).toHaveLength(0);
});
});
```
- [ ] **Step 2: Verify the path alias works for tests/ folder**
The test uses `@/` paths. Confirm `packages/blog/tsconfig.json` includes `"tests/**/*"` (it does per Task 2.1).
- [ ] **Step 3: Run**
Run: `cd packages/blog && pnpm vitest run tests/`
Expected: PASS — 2 tests.
- [ ] **Step 4: Commit**
```bash
git add packages/blog/tests
git commit -m "test(blog): add feature-level test exercising router → controller → use-case → repo"
```
---
### Task 2.17: Final verification + apps/cms boot smoke test
- [ ] **Step 1: Run all blog tests**
Run: `pnpm test --filter @repo/blog`
Expected: PASS — exact count: entities (7) + di container (2) + use-cases (2 + 2) + payload-articles repo (2) + controller (6) + router (3) + feature (2) = 26 tests.
- [ ] **Step 2: Run repo-wide typecheck**
Run: `pnpm typecheck`
Expected: PASS for all the packages we touched. Pre-existing failures in `@repo/api` and `@repo/ui` remain (will be addressed in Plans 5/6).
- [ ] **Step 3: Boot apps/cms to verify articles collection registers**
Ensure Postgres is running (`docker compose up -d postgres`).
Run: `pnpm dev --filter @repo/cms` (in background; wait ~10s for "Ready").
Run: `curl -sf -o /dev/null -w "%{http_code}\n" http://localhost:3001/admin`
Expected: `200`.
Run: `curl -sf -o /dev/null -w "%{http_code}\n" http://localhost:3001/admin/collections/articles`
Expected: `200`.
Stop the dev server.
> Note: When the dev server first connects, Payload may again offer to push schema changes (drop the OLD `users` and `media` columns since core-cms now only knows about `articles` — those collections come back in Plan 3). Answer N to the prompt or accept — for the smoke test we only care that admin responds 200, so the dev server can stay paused on the prompt.
- [ ] **Step 4: Regenerate types one more time and confirm it includes Articles**
Run: `cd apps/cms && pnpm generate:types`
Run: `grep -c "Article" packages/core-cms/src/generated-types.ts`
Expected: at least 5 occurrences (Articles interface, references in Config, etc.).
- [ ] **Step 5: Commit regenerated types**
```bash
git add packages/core-cms/src/generated-types.ts
git commit -m "feat(core-cms): regenerate types — now includes Articles"
```
---
## Plan 2 Done Criteria
- [ ] All 26 tests in `@repo/blog` pass
- [ ] `@repo/blog`, `@repo/core-cms`, `@repo/core-api` all typecheck
- [ ] `apps/cms` admin UI serves `/admin` and `/admin/collections/articles` with 200
- [ ] `pnpm generate:types` in `apps/cms` produces a `generated-types.ts` containing `Articles`
- [ ] `core-cms/src/payload.config.ts` references `@repo/blog/cms` (not the old `cms-core`'s articles)
- [ ] `core-api/src/root.ts` references `@repo/blog/api` and exposes `blog.*` procedures
- [ ] `tsconfig.base.json` includes the three `@repo/blog*` aliases
- [ ] No deletions yet — `packages/core/src/entities/models/article.ts` and friends still exist (drained in Plan 6)
**Next plan:** Plan 3 — Auth + Media features. Migrates the Users collection (auth feature) and Media collection (media feature). Restores `articles.author` to a `relationship → users` and adds back `featuredImage: upload → media`. Demonstrates that the per-feature DI pattern scales beyond one feature.