# 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 added `function presenter` co-located with each non-void controller, plus per-feature `procedures.ts` for domain-error → `TRPCError` translation. See `docs/superpowers/refactor-logs/2026-05-06-input-output-unification.md` and 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](https://github.com/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 `inversify` to `@evyweb/ioctopus`. Our existing `inversify` integration 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}/` and `ui/` 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/.ts` — Zod schema + `z.infer` type | | Entities (errors) | `src/entities/errors/.ts` — domain-grouped error classes | | Repository interfaces | `src/application/repositories/.repository.interface.ts` | | Service interfaces | `src/application/services/.service.interface.ts` | | Use cases | `src/application/use-cases/.use-case.ts` — **factory function**: `(deps) => async (input) => result`, with `export type IXUseCase = ReturnType` | | Repository impls | `src/infrastructure/repositories/.repository.ts` (real) + `src/infrastructure/repositories/.repository.mock.ts` (mock) | | Service impls | `src/infrastructure/services/.service.ts` + `.service.mock.ts` | | Controllers | `src/interface-adapters/controllers/.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.ts` file next to its real implementation. - **Per-use-case controllers**: no multi-method controller files. - **Errors by domain**: `entities/errors/auth.ts` not `entities/errors.ts`. - **`I*UseCase` type aliases**: `export type ISignInUseCase = ReturnType` 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/.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// ├── 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/ │ │ │ ├── .ts # Zod schema + type │ │ │ └── ... (one per entity) │ │ └── errors/ │ │ ├── .ts # domain errors │ │ └── common.ts # InputParseError, NotFoundError │ ├── application/ │ │ ├── repositories/ │ │ │ └── .repository.interface.ts │ │ ├── services/ # only if the feature has services │ │ │ └── .service.interface.ts │ │ └── use-cases/ │ │ └── .use-case.ts # factory function │ ├── infrastructure/ │ │ ├── repositories/ │ │ │ ├── .repository.ts # real (Payload-backed) impl │ │ │ └── .repository.mock.ts # mock impl (sibling) │ │ └── services/ │ │ ├── .service.ts │ │ └── .service.mock.ts │ ├── interface-adapters/ │ │ └── controllers/ │ │ └── .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) │ │ └── .factory.ts │ └── __contracts__/ # repo interface contract suites (Plan 7) │ └── -repository.contract.ts └── tests/ # feature-level integration tests (Plan 7) └── .feature.test.ts ``` ## 6. Per-feature work breakdown ### 6.1 `auth` Current state: - ✅ Has services layer (`IAuthenticationService` + `MockAuthenticationService`) - ❌ No real `PayloadUsersRepository` or `PayloadAuthenticationService` - ❌ 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.ts` - `entities/errors/auth.ts` (AuthenticationError, UnauthenticatedError, UnauthorizedError), `errors/common.ts` (InputParseError) - `application/services/authentication.service.interface.ts` (rename from `authentication-service.interface.ts`) - `application/repositories/users.repository.interface.ts` (rename from `users-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 from `mock-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.ts` with multiple methods - ❌ `getArticleBySlug` skips the use case layer (controller calls repo directly) - ❌ Mock file uses `mock-` prefix - ❌ Entities flat Target: - `entities/models/article.ts` - `entities/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 style - `infrastructure/repositories/articles.repository.ts` (rename from `payload-articles.repository.ts`) - `infrastructure/repositories/articles.repository.mock.ts` (rename from `mock-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.ts` with multiple methods - ❌ Mock files use `mock-` prefix - ❌ Entities flat Target: - `entities/models/page.ts`, `models/site-settings.ts` - `entities/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.ts` - `entities/errors/header.ts`, `errors/common.ts` - `application/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.ts` - `application/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.ts` - `interface-adapters/controllers/get-media.controller.ts`, `list-media.controller.ts`, `delete-media.controller.ts` - `di/symbols.ts`, `module.ts`, `container.ts`, `bind-production.ts` - `integrations/api/router.ts` — `mediaRouter` with 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 ```typescript // 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; 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 ```typescript // 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; export const signInController = (signInUseCase: ISignInUseCase) => async (input: Partial>): Promise => { 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`) ```typescript // 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(AUTH_SYMBOLS.IUsersRepository).to(MockUsersRepository); bind(AUTH_SYMBOLS.IAuthenticationService).to(MockAuthenticationService); bind(AUTH_SYMBOLS.ISignInUseCase).toDynamicValue((ctx) => signInUseCase( ctx.container.get(AUTH_SYMBOLS.IUsersRepository), ctx.container.get(AUTH_SYMBOLS.IAuthenticationService), ), ); bind(AUTH_SYMBOLS.ISignInController).toDynamicValue((ctx) => signInController(ctx.container.get(AUTH_SYMBOLS.ISignInUseCase)), ); }); ``` ### Consumer (tRPC router) ```typescript // 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(AUTH_SYMBOLS.ISignInController); return controller(input); }), }); ``` ### Test (direct injection — no container needed) ```typescript // 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: 1. Avoids workspace cycle (feature can't import core-cms). 2. Explicit at app boot — clear what gets swapped. 3. Idempotent already (`bound = true` flag). 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/.ts` | `models/article.ts` | | Error files | `entities/errors/.ts` | `errors/auth.ts`, `errors/common.ts` | | Repository interface | `application/repositories/.repository.interface.ts` | `articles.repository.interface.ts` | | Service interface | `application/services/.service.interface.ts` | `authentication.service.interface.ts` | | Use case | `application/use-cases/.use-case.ts` | `sign-in.use-case.ts`, `get-article-by-slug.use-case.ts` | | Controller | `interface-adapters/controllers/.controller.ts` | `sign-in.controller.ts` | | Real repo impl | `infrastructure/repositories/.repository.ts` | `articles.repository.ts` | | Mock repo impl | `infrastructure/repositories/.repository.mock.ts` | `articles.repository.mock.ts` | | Real service impl | `infrastructure/services/.service.ts` | `authentication.service.ts` | | Mock service impl | `infrastructure/services/.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 `IUseCase` / `IController`: ```typescript export type ISignInUseCase = ReturnType; export type ISignInController = ReturnType; ``` 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/.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: 1. Files renamed (before → after) 2. Files added (with purpose) 3. Files deleted (with reason) 4. Pattern changes (code-level: e.g., "use cases now factory functions") 5. DI changes (binding patterns) 6. Test refactor patterns 7. Open issues / deferred decisions 8. **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: 1. **Refactor changelog scaffold** — create the changelog doc, populate the Doc-update-checklist with all docs that will need updating 2. **Foundation: entities split** — move `entities/.ts` → `entities/models/.ts`; split `entities/errors.ts` → `entities/errors/.ts` + `errors/common.ts`. All 5 features in one task. Update all imports. 3. **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`). 4. **Refactor `auth`**: factory-style use cases + per-use-case controllers + `.toDynamicValue` DI bindings + new real `UsersRepository` + new real `AuthenticationService` + tests updated to direct injection. 5. **Refactor `blog`**: factory-style use cases + per-use-case controllers + add `getArticleBySlug` use case + DI bindings + tests. 6. **Refactor `marketing-pages`**: factory-style + split controllers + DI + tests. 7. **Refactor `navigation`**: factory-style + DI + tests. 8. **Scaffold `media`** as a full Clean Architecture feature (entities, application, infrastructure, interface-adapters, DI, integrations/api, factories, contract, tests). 9. **Update tRPC routers** (`integrations/api/router.ts`) per feature to resolve controllers via DI symbol lookups (replacing direct use-case calls). 10. **Update factories + contracts** to point at new entity model paths. 11. **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 `IUseCase` type alias. - Every controller is a factory function with `IController` type alias, one per use case. - Every entity is at `entities/models/.ts`. - Every error class is at `entities/errors/.ts`. - Every mock impl is `..mock.ts` (sibling to real impl). - Every interface uses `..interface.ts` (dot-separated qualifier). - `auth` has both real `UsersRepository` and real `AuthenticationService`. - `media` is a complete Clean Architecture feature (entities + application + infrastructure + interface-adapters + DI + integrations). - `blog.getArticleBySlug` is 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.md` is 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)