diff --git a/docs/superpowers/specs/2026-05-05-lazar-pattern-conformance-design.md b/docs/superpowers/specs/2026-05-05-lazar-pattern-conformance-design.md new file mode 100644 index 0000000..1d5b960 --- /dev/null +++ b/docs/superpowers/specs/2026-05-05-lazar-pattern-conformance-design.md @@ -0,0 +1,470 @@ +# Lazar Nikolov Clean Architecture Conformance — Design Spec + +**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)