docs(spec): Lazar Nikolov pattern conformance — design
Brings every feature into structural conformance with the reference
nextjs-clean-architecture repo: factory-function use cases and
controllers, entities/{models,errors}/ split, .mock.ts file suffix,
per-use-case controller files, real PayloadUsersRepository and
PayloadAuthenticationService for auth, full Clean Architecture
scaffold for media. Documents intentional divergences (per-feature DI,
inversify retained, colocated tests). All external doc updates
deferred to a follow-up pass driven by the refactor changelog.
Spec: docs/superpowers/specs/2026-05-05-lazar-pattern-conformance-design.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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/<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.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<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 `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<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
|
||||
|
||||
```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<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`)
|
||||
|
||||
```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<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)
|
||||
|
||||
```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<ISignInController>(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/<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`:
|
||||
|
||||
```typescript
|
||||
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:
|
||||
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/<x>.ts` → `entities/models/<x>.ts`; split `entities/errors.ts` → `entities/errors/<domain>.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 `I<X>UseCase` type alias.
|
||||
- Every controller is a factory function with `I<X>Controller` type 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).
|
||||
- `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)
|
||||
Reference in New Issue
Block a user