Aligns tooling packages with the core-* naming convention used by all other foundation packages (core-shared, core-cms, core-api, core-trpc, core-ui). Updates ~50 files: package.json names, devDependencies, tsconfig extends, eslint.config imports, vitest.config imports, AGENTS.md references, and the boundaries plugin patterns to match the new paths. The tooling-specific patterns in boundaries/elements are now ordered BEFORE the broader core-* pattern to ensure correct first-match-wins behavior. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
60 KiB
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
authorrelationship andfeaturedImage - 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:
authorbecomes atextfield (string).featuredImageis 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 exportpackages/blog/src/entities/article.ts+article.test.tspackages/blog/src/entities/errors.tspackages/blog/src/application/repositories/articles-repository.interface.tspackages/blog/src/application/use-cases/get-articles.use-case.ts+get-articles.use-case.test.tspackages/blog/src/application/use-cases/create-article.use-case.ts+create-article.use-case.test.tspackages/blog/src/infrastructure/repositories/mock-articles.repository.tspackages/blog/src/infrastructure/repositories/payload-articles.repository.ts+payload-articles.repository.test.tspackages/blog/src/interface-adapters/controllers/articles.controller.ts+articles.controller.test.tspackages/blog/src/di/symbols.tspackages/blog/src/di/module.tspackages/blog/src/di/container.ts+container.test.tspackages/blog/src/integrations/cms/collections/articles.tspackages/blog/src/integrations/cms/index.tspackages/blog/src/integrations/api/router.tspackages/blog/src/ui/query.tspackages/blog/tests/articles.feature.test.ts
Modify:
packages/core-cms/src/payload.config.ts— import + registerarticlesfrom@repo/blog/cmspackages/core-api/src/root.ts— import + registerblogRouterfrom@repo/blog/apitsconfig.base.json— add@repo/blog,@repo/blog/cms,@repo/blog/apialiasesapps/cms/package.json— add@repo/blogdependency (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 deletescms-coreentirely. Apps/cms reads fromcore-cmswhich 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
{
"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/core-eslint": "workspace:*",
"@repo/core-typescript": "workspace:*",
"@types/node": "^22.0.0",
"vitest": "^3.1.0"
}
}
- Step 2: Create tsconfig.json
{
"extends": "@repo/core-typescript/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 insidesrc/should use relative imports (../../entities/articleetc.). Reason: when a downstream package like@repo/core-apitypechecks 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 intests/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
{
"extends": ["//"],
"tags": ["feature"]
}
- Step 4: Create vitest.config.ts
import path from "node:path";
import { baseVitestConfig } from "@repo/core-typescript/vitest.base";
export default {
...baseVitestConfig,
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
};
Note: Vitest does NOT automatically read tsconfig
paths. The@/alias used insrc/**/*.tssource/test files must be declared explicitly in vitest.config.ts viaresolve.alias. Same pattern repeats for every feature package's vitest.config.ts.
- Step 5: Create empty index.ts
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):
"@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
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
// 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
// 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)
// 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:
InputParseErroris 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
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)
// 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:
getArticleBySlugis added beyond the originalpackages/coreinterface — needed byarticleBySlugtRPC 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
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
// 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
// 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.repositorydon'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)
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
// 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
// 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)
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)
// 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
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
// 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
// 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
// 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 perbeforeEach. This keeps the package usable in isolation (e.g., for unit tests, Storybook dev) without requiring a database.
- Step 5: Implement container.ts
// 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
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
// 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
// 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 nevercasts on the Payloaddataarguments are needed because Payload's generated types (which we don't have for the empty-collection core-cms config) would normally constrain these. With emptycollections: [], the generated types don't include'articles', so we cast throughnever. After Plan 3 wires articles into core-cms.collections ANDpnpm generate:typesruns, 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
SanitizedConfigvia its constructor instead ofimport 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:import config from '@repo/core-cms' blogContainer.bind(BLOG_SYMBOLS.IArticlesRepository) .toDynamicValue(() => new PayloadArticlesRepository(config))The blog
package.jsondoes NOT declare@repo/core-cmsas 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
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
// 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
// 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:
getArticleBySlugControllercalls the repo directly rather than going through a separategetArticleBySlug.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
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)
// 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:
slugifyIfMissingfrom@repo/core-shared/payloadreplaces the inlineautoGenerateSlughook frompackages/cms-core/src/collections/articles/hooks/before-change.ts. Same behavior, generic helper.
- Step 2: Implement integrations/cms barrel
// 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
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/blogdependency) -
Step 1: Add @repo/blog to core-cms dependencies
Add inside packages/core-cms/package.json's dependencies block:
"@repo/blog": "workspace:*",
Note: This is the spec §4.2 composition exception —
core-cmsmay import feature/cmsexports.
- Step 2: Update payload.config.ts to register articles
Read current packages/core-cms/src/payload.config.ts. Replace it with:
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
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
// 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
// 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
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:
"@repo/blog": "workspace:*",
- Step 2: Update root.ts
Replace packages/core-api/src/root.ts with:
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
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)
// 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:
TrpcClientis locally typed structurally because@repo/core-trpcdoesn't ship the React-aware client until Plan 5. The structural type is sufficient: when Plan 5 lands and apps callarticleBySlugQuery(trpc, slug)with the real typed client, structural compatibility holds. Plan 5 may revise this to import from@repo/core-trpconce that's available.
- Step 2: Verify it compiles
Run: cd packages/blog && pnpm typecheck
Expected: PASS.
- Step 3: Commit
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
// 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
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
// 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
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
usersandmediacolumns since core-cms now only knows aboutarticles— 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
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/blogpass @repo/blog,@repo/core-cms,@repo/core-apiall typecheckapps/cmsadmin UI serves/adminand/admin/collections/articleswith 200pnpm generate:typesinapps/cmsproduces agenerated-types.tscontainingArticlescore-cms/src/payload.config.tsreferences@repo/blog/cms(not the oldcms-core's articles)core-api/src/root.tsreferences@repo/blog/apiand exposesblog.*procedurestsconfig.base.jsonincludes the three@repo/blog*aliases- No deletions yet —
packages/core/src/entities/models/article.tsand 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.