Files
agentic-dev/docs/superpowers/plans/2026-05-04-plan-2-blog-feature.md
Danijel Martinek 81fe4aa3b2 docs(plan-2): document constructor-injection pattern for payload repos
Revises Task 2.8's note: spec example used `import config from
'@repo/core-cms'` which creates a workspace dependency cycle (blog deps
on core-cms; core-cms deps on blog/cms). Constructor injection breaks the
cycle at the package graph. Apply to all payload-backed feature repos.

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

59 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 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

{
  "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
{
  "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 feature-local (resolves to packages/blog/src/*). This is the same pattern packages/core/tsconfig.json already uses.

  • Step 3: Create turbo.json
{
  "extends": ["//"],
  "tags": ["feature"]
}
  • Step 4: Create vitest.config.ts
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
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: 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
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: 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
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.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)
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 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
// 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 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:

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
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: 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
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: 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
// 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/blog dependency)

  • 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-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:

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: 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
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 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
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.