First slice of the combined Plan 8 + Plan 9 doc-update pass: - CLAUDE.md Key Conventions: append schema-in-use-case, presenter, controller unknown input, feature-scoped tRPC error mapping, public surface split (./ui) - packages/core-shared/AGENTS.md: document defineErrorMiddleware export + t re-export from trpc/init - docs/superpowers/plans/2026-05-05-plan-8-*.md and matching spec: one-line note that some controller/router patterns shifted in Plan 9; link to the Plan 9 refactor log - docs/architecture/overview.md: data-flow box now shows xProcedure + xInputSchema + xOutputSchema.parse + presenter + middleware lanes; three explanatory paragraphs added (schemas, presenter, error mapping) - docs/architecture/dependency-flow.md: app-side ./ui subpath note, allowed/disallowed examples updated for Plan 9 paths Remaining doc-pass items (root AGENTS.md, per-feature AGENTS.md ×5, core-testing AGENTS.md, adding-a-feature.md, tdd-workflow.md, testing-strategy.md, vertical-feature-spec.md) follow in subsequent commits — to be dispatched in parallel.
27 KiB
Lazar Nikolov Clean Architecture Conformance — Design Spec
Note (post-Plan-9, 2026-05-06): §7 of this spec shows the controller's input schema as a local
const inputSchema = z.object(...). Plan 9 moved schemas to the use-case file (one source of truth, imported by controllers and tRPC routers) and addedfunction presenterco-located with each non-void controller, plus per-featureprocedures.tsfor domain-error →TRPCErrortranslation. Seedocs/superpowers/refactor-logs/2026-05-06-input-output-unification.mdand ADR-013 for the post-Plan-9 layout.
Date: 2026-05-05
Status: Approved (autonomous execution authorized)
Author: Claude Opus 4.7 (1M context)
Reviewer: Danijel
Reference: nikolovlazar/nextjs-clean-architecture
Related: Extends 2026-04-21-vertical-monorepo-refactor-design.md; supersedes nothing.
1. Goal
Bring every feature in the monorepo (auth, blog, media, marketing-pages, navigation) into structural conformance with Lazar Nikolov's Clean Architecture pattern as implemented in the reference repo. Where the reference's pattern conflicts with our intentional vertical-feature monorepo design (e.g., per-feature DI containers vs. one app-wide container), we keep our design and document the divergence.
The success criterion is operational: a developer familiar with Lazar's blog post and reference repo should be able to navigate any feature in this monorepo and find the same file shapes, naming conventions, dependency-injection style, and layer boundaries.
2. Non-goals
- Migrating from
inversifyto@evyweb/ioctopus. Our existinginversifyintegration works; the cost of swapping DI libraries exceeds the benefit. We adapt inversify usage to express the same patterns. - Moving tests from colocated to
tests/unit/mirror layout. Colocated tests are an explicit decision (spec §13B) and produce clearer per-file ownership. - Moving from per-feature DI containers to one global app container. Vertical-feature DI is a load-bearing design choice (each feature is a self-contained slice).
- Adding Sentry/instrumentation services. The reference uses these for observability wrapping; we may add later but it's out of scope here.
- Restructuring the
integrations/{api,cms}/andui/folders. These are our extensions for the monorepo's CMS and tRPC integration; they don't exist in the reference. Keep as-is.
3. Reference pattern (what we're conforming to)
Per layer, the reference uses:
| Layer | File shape |
|---|---|
| Entities (data) | src/entities/models/<name>.ts — Zod schema + z.infer type |
| Entities (errors) | src/entities/errors/<group>.ts — domain-grouped error classes |
| Repository interfaces | src/application/repositories/<name>.repository.interface.ts |
| Service interfaces | src/application/services/<name>.service.interface.ts |
| Use cases | src/application/use-cases/<name>.use-case.ts — factory function: (deps) => async (input) => result, with export type IXUseCase = ReturnType<typeof xUseCase> |
| Repository impls | src/infrastructure/repositories/<name>.repository.ts (real) + src/infrastructure/repositories/<name>.repository.mock.ts (mock) |
| Service impls | src/infrastructure/services/<name>.service.ts + <name>.service.mock.ts |
| Controllers | src/interface-adapters/controllers/<name>.controller.ts — factory function: (deps) => async (input) => result, one per use case |
Key style choices:
- Factory function DI: use cases and controllers take their dependencies as arguments and return the callable. The container constructs them.
- Mock siblings: every repository and service has a
.mock.tsfile next to its real implementation. - Per-use-case controllers: no multi-method controller files.
- Errors by domain:
entities/errors/auth.tsnotentities/errors.ts. I*UseCasetype aliases:export type ISignInUseCase = ReturnType<typeof signInUseCase>so consumers (controllers) can depend on the type without depending on the impl.
4. Our adaptations (intentional divergences from reference)
| Aspect | Reference | Ours | Rationale |
|---|---|---|---|
| DI library | @evyweb/ioctopus |
inversify |
Already in place; same expressive power. Use .toDynamicValue(({ container }) => factoryFn(container.get(...))) for factory-function bindings. |
| DI scope | One global ApplicationContainer |
One per feature (authContainer, blogContainer, …) |
Vertical-feature isolation; each feature is a slice. |
| Production binding | NODE_ENV check inside module | bindProduction*() called at app boot |
Avoids workspace-cycle problem (feature can't import core-cms). Constructor-injected SanitizedConfig from app. |
| Test placement | tests/unit/... mirror |
Colocated *.test.{ts,tsx} |
Established by Plan 7; clearer per-file ownership. |
| Folder grouping in controllers/use-cases | controllers/auth/sign-in.controller.ts (domain subfolder) |
controllers/sign-in.controller.ts (no subfolder) |
We're already vertical-feature-packaged; the subfolder is redundant. |
| Errors location | One app-wide errors/ |
Per-feature entities/errors/<domain>.ts |
Feature ownership; each feature owns its errors. |
InputParseError |
Defined once in entities/errors/common.ts |
Duplicated per feature in entities/errors/common.ts |
Feature independence; DRY at the cost of 5 × 6-line classes is acceptable. |
| Test factories + contracts | Not used | __factories__/ + __contracts__/ per feature |
Established by Plan 7; orthogonal to Lazar pattern. |
integrations/{api,cms} + ui/ folders |
N/A | Per feature | Monorepo-specific (Payload + tRPC + UI components live in feature). |
5. Per-feature target structure
After this refactor, every feature follows this template:
packages/<feature>/
├── package.json
├── tsconfig.json
├── vitest.config.ts
├── eslint.config.js
├── turbo.json
├── AGENTS.md
├── src/
│ ├── index.ts
│ ├── config.ts # constants (e.g., SESSION_COOKIE)
│ ├── entities/
│ │ ├── models/
│ │ │ ├── <entity>.ts # Zod schema + type
│ │ │ └── ... (one per entity)
│ │ └── errors/
│ │ ├── <domain>.ts # domain errors
│ │ └── common.ts # InputParseError, NotFoundError
│ ├── application/
│ │ ├── repositories/
│ │ │ └── <name>.repository.interface.ts
│ │ ├── services/ # only if the feature has services
│ │ │ └── <name>.service.interface.ts
│ │ └── use-cases/
│ │ └── <verb-noun>.use-case.ts # factory function
│ ├── infrastructure/
│ │ ├── repositories/
│ │ │ ├── <name>.repository.ts # real (Payload-backed) impl
│ │ │ └── <name>.repository.mock.ts # mock impl (sibling)
│ │ └── services/
│ │ ├── <name>.service.ts
│ │ └── <name>.service.mock.ts
│ ├── interface-adapters/
│ │ └── controllers/
│ │ └── <verb-noun>.controller.ts # one per use case, factory function
│ ├── di/
│ │ ├── symbols.ts # DI keys (Symbols)
│ │ ├── module.ts # ContainerModule wiring
│ │ ├── container.ts # constructed Container with default (mock) bindings
│ │ └── bind-production.ts # swap mocks for real impls at app boot
│ ├── integrations/ # monorepo-specific extensions
│ │ ├── api/
│ │ │ ├── router.ts # tRPC procedures wrapping controllers
│ │ │ └── index.ts
│ │ └── cms/ # Payload collections / globals
│ │ ├── collections/ # OR globals/
│ │ └── index.ts
│ ├── ui/ # React Query option builders + components
│ │ └── query.ts
│ ├── __factories__/ # test data factories (Plan 7)
│ │ └── <entity>.factory.ts
│ └── __contracts__/ # repo interface contract suites (Plan 7)
│ └── <name>-repository.contract.ts
└── tests/ # feature-level integration tests (Plan 7)
└── <name>.feature.test.ts
6. Per-feature work breakdown
6.1 auth
Current state:
- ✅ Has services layer (
IAuthenticationService+MockAuthenticationService) - ❌ No real
PayloadUsersRepositoryorPayloadAuthenticationService - ❌ Use cases call
authContainer.get()directly instead of being factory functions - ❌ Controllers call
signInUseCase()directly without DI - ❌ Entities flat (
entities/user.ts,errors.ts) - ❌ Mock files use
mock-prefix
Target:
entities/models/user.ts,models/session.ts,models/cookie.tsentities/errors/auth.ts(AuthenticationError, UnauthenticatedError, UnauthorizedError),errors/common.ts(InputParseError)application/services/authentication.service.interface.ts(rename fromauthentication-service.interface.ts)application/repositories/users.repository.interface.ts(rename fromusers-repository.interface.ts)application/use-cases/sign-in.use-case.ts(factory:(usersRepo, authService) => async (input) => {...})- Same for
sign-up.use-case.ts,sign-out.use-case.ts infrastructure/repositories/users.repository.mock.ts(rename frommock-users.repository.ts)infrastructure/repositories/users.repository.ts(NEW — Payload-backed)infrastructure/services/authentication.service.mock.ts(rename)infrastructure/services/authentication.service.ts(NEW — Payload-backed)interface-adapters/controllers/sign-in.controller.ts(factory:(signInUseCase) => async (input) => {...})- DI module bindings updated to use
.toDynamicValue()for factory functions
6.2 blog
Current state:
- ❌ Use cases call
blogContainer.get()directly - ❌ Controllers in single
articles.controller.tswith multiple methods - ❌
getArticleBySlugskips the use case layer (controller calls repo directly) - ❌ Mock file uses
mock-prefix - ❌ Entities flat
Target:
entities/models/article.tsentities/errors/article.ts(ArticleNotFoundError),errors/common.ts(InputParseError)application/repositories/articles.repository.interface.ts(rename)application/use-cases/create-article.use-case.ts,get-articles.use-case.ts,get-article-by-slug.use-case.ts(NEW), all factory styleinfrastructure/repositories/articles.repository.ts(rename frompayload-articles.repository.ts)infrastructure/repositories/articles.repository.mock.ts(rename frommock-articles.repository.ts)interface-adapters/controllers/create-article.controller.ts,get-articles.controller.ts,get-article-by-slug.controller.ts— one per use case, all factory style
6.3 marketing-pages
Current state:
- ❌ Use cases call
marketingContainer.get()directly - ❌ Controllers in single
pages.controller.tswith multiple methods - ❌ Mock files use
mock-prefix - ❌ Entities flat
Target:
entities/models/page.ts,models/site-settings.tsentities/errors/page.ts(PageNotFoundError),errors/common.ts(InputParseError)application/repositories/pages.repository.interface.ts,site-settings.repository.interface.ts(rename)application/use-cases/get-page-by-slug.use-case.ts,get-site-settings.use-case.ts(factory style)infrastructure/repositories/pages.repository.ts,pages.repository.mock.ts,site-settings.repository.ts,site-settings.repository.mock.ts(rename)interface-adapters/controllers/get-page-by-slug.controller.ts,get-site-settings.controller.ts(split + factory)
6.4 navigation
Current state:
- ❌ Use case calls
navigationContainer.get()directly - ❌ Controller
header.controller.ts(single method but in multi-method-style file) - ❌ Mock file uses
mock-prefix - ❌ Entities flat
Target:
entities/models/header.ts,models/header-item.tsentities/errors/header.ts,errors/common.tsapplication/repositories/header.repository.interface.ts(rename)application/use-cases/get-header.use-case.ts(factory style)infrastructure/repositories/header.repository.ts,header.repository.mock.ts(rename)interface-adapters/controllers/get-header.controller.ts(factory style)
6.5 media — full Clean Architecture scaffold
Current state: only integrations/cms/collections/media.ts exists. No entities, no use cases, no controllers, no DI, no API router.
Target (full template):
entities/models/media.ts— Zod schema (id, alt, url, filename, mimeType, filesize, width, height)entities/errors/media.ts(MediaNotFoundError),errors/common.tsapplication/repositories/media.repository.interface.ts— IMediaRepository (getMedia, getMediaById, listMedia, deleteMedia)application/use-cases/get-media.use-case.ts,list-media.use-case.ts,delete-media.use-case.ts(factory style)infrastructure/repositories/media.repository.ts(Payload-backed),media.repository.mock.tsinterface-adapters/controllers/get-media.controller.ts,list-media.controller.ts,delete-media.controller.tsdi/symbols.ts,module.ts,container.ts,bind-production.tsintegrations/api/router.ts—mediaRouterwith procedures__factories__/media.factory.ts(already exists from Plan 7 — adapt to new entity location)__contracts__/media-repository.contract.ts(NEW)- Wire into
core-api, app boot
7. Factory function pattern (canonical)
Use case factory
// application/use-cases/sign-in.use-case.ts
import { AuthenticationError } from "../../entities/errors/auth";
import type { Cookie } from "../../entities/models/cookie";
import type { Session } from "../../entities/models/session";
import type { IUsersRepository } from "../repositories/users.repository.interface";
import type { IAuthenticationService } from "../services/authentication.service.interface";
export type ISignInUseCase = ReturnType<typeof signInUseCase>;
export const signInUseCase =
(usersRepository: IUsersRepository, authenticationService: IAuthenticationService) =>
async (input: { username: string; password: string }): Promise<{ session: Session; cookie: Cookie }> => {
const existingUser = await usersRepository.getUserByUsername(input.username);
if (!existingUser) {
throw new AuthenticationError("User does not exist");
}
const validPassword = await authenticationService.verifyPassword(
existingUser.passwordHash,
input.password,
);
if (!validPassword) {
throw new AuthenticationError("Incorrect username or password");
}
return await authenticationService.createSession(existingUser);
};
Controller factory
// interface-adapters/controllers/sign-in.controller.ts
import { z } from "zod";
import { InputParseError } from "../../entities/errors/common";
import type { Cookie } from "../../entities/models/cookie";
import type { ISignInUseCase } from "../../application/use-cases/sign-in.use-case";
const inputSchema = z.object({
username: z.string().min(3).max(31),
password: z.string().min(6).max(255),
});
export type ISignInController = ReturnType<typeof signInController>;
export const signInController =
(signInUseCase: ISignInUseCase) =>
async (input: Partial<z.infer<typeof inputSchema>>): Promise<Cookie> => {
const parsed = inputSchema.safeParse(input);
if (!parsed.success) {
throw new InputParseError("Invalid sign-in input", { cause: parsed.error });
}
const { cookie } = await signInUseCase(parsed.data);
return cookie;
};
DI binding (inversify with .toDynamicValue)
// di/module.ts
import { ContainerModule } from "inversify";
import { AUTH_SYMBOLS } from "./symbols";
import { MockUsersRepository } from "../infrastructure/repositories/users.repository.mock";
import { MockAuthenticationService } from "../infrastructure/services/authentication.service.mock";
import { signInUseCase } from "../application/use-cases/sign-in.use-case";
import { signInController } from "../interface-adapters/controllers/sign-in.controller";
import type { IUsersRepository } from "../application/repositories/users.repository.interface";
import type { IAuthenticationService } from "../application/services/authentication.service.interface";
import type { ISignInUseCase } from "../application/use-cases/sign-in.use-case";
import type { ISignInController } from "../interface-adapters/controllers/sign-in.controller";
export const authModule = new ContainerModule((bind) => {
bind<IUsersRepository>(AUTH_SYMBOLS.IUsersRepository).to(MockUsersRepository);
bind<IAuthenticationService>(AUTH_SYMBOLS.IAuthenticationService).to(MockAuthenticationService);
bind<ISignInUseCase>(AUTH_SYMBOLS.ISignInUseCase).toDynamicValue((ctx) =>
signInUseCase(
ctx.container.get<IUsersRepository>(AUTH_SYMBOLS.IUsersRepository),
ctx.container.get<IAuthenticationService>(AUTH_SYMBOLS.IAuthenticationService),
),
);
bind<ISignInController>(AUTH_SYMBOLS.ISignInController).toDynamicValue((ctx) =>
signInController(ctx.container.get<ISignInUseCase>(AUTH_SYMBOLS.ISignInUseCase)),
);
});
Consumer (tRPC router)
// integrations/api/router.ts
import { authContainer } from "../../di/container";
import { AUTH_SYMBOLS } from "../../di/symbols";
import type { ISignInController } from "../../interface-adapters/controllers/sign-in.controller";
export const authRouter = router({
signIn: publicProcedure.input(signInInputSchema).mutation(async ({ input }) => {
const controller = authContainer.get<ISignInController>(AUTH_SYMBOLS.ISignInController);
return controller(input);
}),
});
Test (direct injection — no container needed)
// application/use-cases/sign-in.use-case.test.ts
import { signInUseCase } from "./sign-in.use-case";
import { MockUsersRepository } from "../../infrastructure/repositories/users.repository.mock";
import { MockAuthenticationService } from "../../infrastructure/services/authentication.service.mock";
describe("signInUseCase", () => {
it("creates a session for valid credentials", async () => {
const users = new MockUsersRepository();
const auth = new MockAuthenticationService(users);
const useCase = signInUseCase(users, auth);
// ... seed user, call useCase(input), assert
});
});
This is a major testability improvement: no container rebinding, no global state, just inject mocks directly.
8. DI binding strategy — bindProduction*() retained
We keep the bindProduction*(config) pattern (see 2026-04-21-vertical-monorepo-refactor-design.md §10) because:
- Avoids workspace cycle (feature can't import core-cms).
- Explicit at app boot — clear what gets swapped.
- Idempotent already (
bound = trueflag).
After this refactor, bindProduction*() swaps:
- Mock repository → real (Payload-backed) repository
- Mock service → real service (where applicable)
- Use case and controller bindings stay the same — they resolve to the real impls automatically because the container resolves dependencies at injection time.
9. Shared concerns
9.1 Naming
| Element | Convention | Example |
|---|---|---|
| Entity files | entities/models/<noun>.ts |
models/article.ts |
| Error files | entities/errors/<domain>.ts |
errors/auth.ts, errors/common.ts |
| Repository interface | application/repositories/<noun>.repository.interface.ts |
articles.repository.interface.ts |
| Service interface | application/services/<noun>.service.interface.ts |
authentication.service.interface.ts |
| Use case | application/use-cases/<verb-noun>.use-case.ts |
sign-in.use-case.ts, get-article-by-slug.use-case.ts |
| Controller | interface-adapters/controllers/<verb-noun>.controller.ts |
sign-in.controller.ts |
| Real repo impl | infrastructure/repositories/<noun>.repository.ts |
articles.repository.ts |
| Mock repo impl | infrastructure/repositories/<noun>.repository.mock.ts |
articles.repository.mock.ts |
| Real service impl | infrastructure/services/<noun>.service.ts |
authentication.service.ts |
| Mock service impl | infrastructure/services/<noun>.service.mock.ts |
authentication.service.mock.ts |
Note: hyphens between words within a name part (articles-repository becomes articles.repository). The .repository and .service are dot-separated qualifiers, not part of the entity name.
9.2 Type alias convention
Every use case and controller exports I<PascalCase>UseCase / I<PascalCase>Controller:
export type ISignInUseCase = ReturnType<typeof signInUseCase>;
export type ISignInController = ReturnType<typeof signInController>;
These aliases:
- Decouple consumers from the impl
- Make DI symbol declarations readable (
AUTH_SYMBOLS.ISignInUseCase) - Give factory return types names that survive across files
9.3 Error files per feature
Every feature gets:
entities/errors/<domain>.ts— domain-specific (e.g.,auth.ts,article.ts)entities/errors/common.ts—InputParseError(always);NotFoundError,DatabaseOperationError(where applicable)
Errors are duplicated per feature (~6 lines each); features remain independent.
10. Refactor changelog
Throughout execution, every architectural change is captured in:
docs/superpowers/refactor-logs/2026-05-05-lazar-pattern-conformance.md
Sections:
- Files renamed (before → after)
- Files added (with purpose)
- Files deleted (with reason)
- Pattern changes (code-level: e.g., "use cases now factory functions")
- DI changes (binding patterns)
- Test refactor patterns
- Open issues / deferred decisions
- Doc update checklist — each external doc that needs updating once the refactor is merged (CLAUDE.md, AGENTS.md, adding-a-feature.md, tdd-workflow.md, ADR-012, vertical-feature-spec.md, etc.)
The user explicitly requested this changelog so docs can be updated in a follow-up pass rather than during the refactor — keeping doc changes out of the diff churn.
11. Plan structure (preview)
Plan 8 — Lazar Pattern Conformance, with these tasks:
- Refactor changelog scaffold — create the changelog doc, populate the Doc-update-checklist with all docs that will need updating
- Foundation: entities split — move
entities/<x>.ts→entities/models/<x>.ts; splitentities/errors.ts→entities/errors/<domain>.ts+errors/common.ts. All 5 features in one task. Update all imports. - Foundation: file renames — rename mock files (
mock-X.repository.ts→X.repository.mock.ts) and rename Payload impls (payload-X.repository.ts→X.repository.ts); rename interface files (X-repository.interface.ts→X.repository.interface.ts). - Refactor
auth: factory-style use cases + per-use-case controllers +.toDynamicValueDI bindings + new realUsersRepository+ new realAuthenticationService+ tests updated to direct injection. - Refactor
blog: factory-style use cases + per-use-case controllers + addgetArticleBySluguse case + DI bindings + tests. - Refactor
marketing-pages: factory-style + split controllers + DI + tests. - Refactor
navigation: factory-style + DI + tests. - Scaffold
mediaas a full Clean Architecture feature (entities, application, infrastructure, interface-adapters, DI, integrations/api, factories, contract, tests). - Update tRPC routers (
integrations/api/router.ts) per feature to resolve controllers via DI symbol lookups (replacing direct use-case calls). - Update factories + contracts to point at new entity model paths.
- Final refactor changelog pass + verify every external doc reference still points at correct paths.
Each task TDD'd, two-stage reviewed, single-commit scoped.
Estimated change volume: ~120 files modified, ~50 files created, ~30 files deleted/renamed (net ~0 deletions because renames preserve content).
12. Acceptance criteria
- Every feature follows the §5 template structure exactly.
- Every use case is a factory function with
I<X>UseCasetype alias. - Every controller is a factory function with
I<X>Controllertype alias, one per use case. - Every entity is at
entities/models/<x>.ts. - Every error class is at
entities/errors/<domain>.ts. - Every mock impl is
<x>.<repository|service>.mock.ts(sibling to real impl). - Every interface uses
<x>.<repository|service>.interface.ts(dot-separated qualifier). authhas both realUsersRepositoryand realAuthenticationService.mediais a complete Clean Architecture feature (entities + application + infrastructure + interface-adapters + DI + integrations).blog.getArticleBySlugis a use case (no longer skipped).- All 244 existing tests pass after refactor (some adapted; total may grow with new media tests).
- Refactor changelog at
docs/superpowers/refactor-logs/2026-05-05-lazar-pattern-conformance.mdis complete with the doc-update checklist.
13. Tradeoffs
| Decision | Pro | Con |
|---|---|---|
| Factory-function use cases | Pure functions; trivially testable without container | Consumers must pass deps; DI bindings get more verbose |
| One controller per file | Clearer single-responsibility; per-controller tests | More files (≈ +15 across features) |
| Entities split into models/errors | Matches reference; clearer responsibility | Two more directories per feature; deeper imports |
| Real PayloadUsersRepository + AuthService | Production-correct; required for real auth | Larger code surface to maintain |
media full scaffold |
Architectural symmetry; easier to extend | Code that no consumer needs today (could be YAGNI) — but plan calls for symmetry |
.mock.ts suffix |
Reference-conformant; sortable next to real impl | Touches every test import path |
| Keep inversify | No DI library swap | One more deviation from reference |
| Refactor changelog (separate doc) | Clean diff; doc updates batched later | Two-pass merge: refactor → docs |
14. Out of scope (explicit)
- Migrating to
@evyweb/ioctopus(Lazar's DI lib) - Adding instrumentation/crash-reporter services (Sentry wrapping)
- Moving tests from colocated to
tests/unit/mirror - Moving from per-feature to single-app DI container
- Updating CLAUDE.md, AGENTS.md, adding-a-feature.md, tdd-workflow.md (deferred to a follow-up doc-update pass driven by the refactor changelog)
- Adding ADR-012 for this refactor (also deferred to doc pass)