Files
agentic-dev/docs/superpowers/refactor-logs/2026-05-05-lazar-pattern-conformance.md
Danijel Martinek 353a41b244 refactor(marketing-pages): factory-style use cases + per-use-case controllers
- Use cases (get-page-by-slug, get-site-settings) → factory functions with I*UseCase aliases
- Controllers split: pages.controller.ts → 2 single-responsibility files
- DI module wires factories with .toDynamicValue()
- tRPC router resolves controllers via container

Refactor log: §2, §3, §4.1, §4.2, §5.1
Spec: §6.3

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 00:14:27 +02:00

23 KiB

Refactor Changelog — Lazar Pattern Conformance

Started: 2026-05-05 Spec: 2026-05-05-lazar-pattern-conformance-design.md Plan: 2026-05-05-plan-8-lazar-conformance.md Branch: feature/lazar-conformance

This document captures every architectural change made during Plan 8 execution, organized by category. After the plan is merged, use the "Doc update checklist" at the bottom to update external docs in a single follow-up pass.


Summary

(Filled in at Task 10 — total files added/modified/deleted/renamed; net test count change; commits.)


1. File renames (before → after)

Task 3: File and class renames

File renames — 27 files (git mv — history preserved):

auth:

  • packages/auth/src/infrastructure/repositories/mock-users.repository.tsusers.repository.mock.ts
  • packages/auth/src/infrastructure/repositories/mock-users.repository.test.tsusers.repository.mock.test.ts
  • packages/auth/src/application/repositories/users-repository.interface.tsusers.repository.interface.ts
  • packages/auth/src/infrastructure/services/mock-authentication.service.tsauthentication.service.mock.ts
  • packages/auth/src/application/services/authentication-service.interface.tsauthentication.service.interface.ts

blog:

  • packages/blog/src/infrastructure/repositories/mock-articles.repository.tsarticles.repository.mock.ts
  • packages/blog/src/infrastructure/repositories/mock-articles.repository.test.tsarticles.repository.mock.test.ts
  • packages/blog/src/infrastructure/repositories/payload-articles.repository.tsarticles.repository.ts
  • packages/blog/src/infrastructure/repositories/payload-articles.repository.test.tsarticles.repository.test.ts
  • packages/blog/src/application/repositories/articles-repository.interface.tsarticles.repository.interface.ts

marketing-pages:

  • packages/marketing-pages/src/infrastructure/repositories/mock-pages.repository.tspages.repository.mock.ts
  • packages/marketing-pages/src/infrastructure/repositories/mock-pages.repository.test.tspages.repository.mock.test.ts
  • packages/marketing-pages/src/infrastructure/repositories/payload-pages.repository.tspages.repository.ts
  • packages/marketing-pages/src/infrastructure/repositories/payload-pages.repository.test.tspages.repository.test.ts
  • packages/marketing-pages/src/application/repositories/pages-repository.interface.tspages.repository.interface.ts
  • packages/marketing-pages/src/infrastructure/repositories/mock-site-settings.repository.tssite-settings.repository.mock.ts
  • packages/marketing-pages/src/infrastructure/repositories/mock-site-settings.repository.test.tssite-settings.repository.mock.test.ts
  • packages/marketing-pages/src/infrastructure/repositories/payload-site-settings.repository.tssite-settings.repository.ts
  • packages/marketing-pages/src/infrastructure/repositories/payload-site-settings.repository.test.tssite-settings.repository.test.ts
  • packages/marketing-pages/src/application/repositories/site-settings-repository.interface.tssite-settings.repository.interface.ts

navigation:

  • packages/navigation/src/infrastructure/repositories/mock-header.repository.tsheader.repository.mock.ts
  • packages/navigation/src/infrastructure/repositories/mock-header.repository.test.tsheader.repository.mock.test.ts
  • packages/navigation/src/infrastructure/repositories/payload-header.repository.tsheader.repository.ts
  • packages/navigation/src/infrastructure/repositories/payload-header.repository.test.tsheader.repository.test.ts
  • packages/navigation/src/application/repositories/header-repository.interface.tsheader.repository.interface.ts

Class renames (4 classes — Payload prefix dropped; Mock prefix unchanged):

  • PayloadArticlesRepositoryArticlesRepository (in articles.repository.ts)
  • PayloadPagesRepositoryPagesRepository (in pages.repository.ts)
  • PayloadSiteSettingsRepositorySiteSettingsRepository (in site-settings.repository.ts)
  • PayloadHeaderRepositoryHeaderRepository (in header.repository.ts)

Consumers updated (imports + class references): DI modules, bind-production files, contract suites, use case source files, use case tests, controller tests, router tests, feature tests — approximately 35 files total.

Task 2: Entities split

Entity model moves (git mv — history preserved):

  • packages/auth/src/entities/user.tspackages/auth/src/entities/models/user.ts
  • packages/auth/src/entities/user.test.tspackages/auth/src/entities/models/user.test.ts
  • packages/auth/src/entities/session.tspackages/auth/src/entities/models/session.ts
  • packages/auth/src/entities/session.test.tspackages/auth/src/entities/models/session.test.ts
  • packages/auth/src/entities/cookie.tspackages/auth/src/entities/models/cookie.ts
  • packages/auth/src/entities/errors.test.tspackages/auth/src/entities/errors/errors.test.ts
  • packages/blog/src/entities/article.tspackages/blog/src/entities/models/article.ts
  • packages/blog/src/entities/article.test.tspackages/blog/src/entities/models/article.test.ts
  • packages/blog/src/entities/errors.test.tspackages/blog/src/entities/errors/errors.test.ts
  • packages/marketing-pages/src/entities/page.tspackages/marketing-pages/src/entities/models/page.ts
  • packages/marketing-pages/src/entities/page.test.tspackages/marketing-pages/src/entities/models/page.test.ts
  • packages/marketing-pages/src/entities/site-settings.tspackages/marketing-pages/src/entities/models/site-settings.ts
  • packages/marketing-pages/src/entities/site-settings.test.tspackages/marketing-pages/src/entities/models/site-settings.test.ts
  • packages/marketing-pages/src/entities/errors.test.tspackages/marketing-pages/src/entities/errors/errors.test.ts
  • packages/navigation/src/entities/header.tspackages/navigation/src/entities/models/header.ts
  • packages/navigation/src/entities/header.test.tspackages/navigation/src/entities/models/header.test.ts

2. Files added (with purpose)

Task 4: Real implementations + tests

  • packages/auth/src/infrastructure/repositories/users.repository.ts — real Payload-backed UsersRepository (implements IUsersRepository via getPayload)
  • packages/auth/src/infrastructure/repositories/users.repository.test.ts — contract suite backed by an in-memory Payload stub (mirrors articles.repository.test.ts pattern)
  • packages/auth/src/infrastructure/services/authentication.service.ts — real AuthenticationService using node:crypto for hashing/UUIDs; session methods deferred (see §7)
  • packages/auth/src/infrastructure/services/authentication.service.test.ts — tests for generateUserId, hashPassword/verifyPassword round-trip, and deferred-method error assertions

Task 5: Blog factory refactor — new files

  • packages/blog/src/application/use-cases/get-article-by-slug.use-case.ts — NEW use case factory; throws ArticleNotFoundError when slug is not found (previously the controller hit the repo directly, bypassing use-case error handling)
  • packages/blog/src/application/use-cases/get-article-by-slug.use-case.test.ts — 2 tests (slug found, slug missing → ArticleNotFoundError)
  • packages/blog/src/interface-adapters/controllers/get-articles.controller.ts — factory controller, replaces the getArticlesController function from articles.controller.ts
  • packages/blog/src/interface-adapters/controllers/get-articles.controller.test.ts — 3 tests (valid input, status filter, invalid shape → InputParseError)
  • packages/blog/src/interface-adapters/controllers/create-article.controller.ts — factory controller, replaces createArticleController from articles.controller.ts
  • packages/blog/src/interface-adapters/controllers/create-article.controller.test.ts — 3 tests (valid, missing title, missing authorId)
  • packages/blog/src/interface-adapters/controllers/get-article-by-slug.controller.ts — factory controller; now delegates to getArticleBySlugUseCase (which throws ArticleNotFoundError) instead of calling repo directly
  • packages/blog/src/interface-adapters/controllers/get-article-by-slug.controller.test.ts — 3 tests (found, not found → ArticleNotFoundError, empty slug → InputParseError)

Task 6: Marketing-pages factory refactor — new files

  • packages/marketing-pages/src/interface-adapters/controllers/get-page-by-slug.controller.ts — factory controller; replaces getPageBySlugController function from pages.controller.ts; exports IGetPageBySlugController type alias
  • packages/marketing-pages/src/interface-adapters/controllers/get-page-by-slug.controller.test.ts — 3 tests (slug found, slug not found → undefined, missing slug → InputParseError)
  • packages/marketing-pages/src/interface-adapters/controllers/get-site-settings.controller.ts — factory controller; replaces getSiteSettingsController function from pages.controller.ts; exports IGetSiteSettingsController type alias
  • packages/marketing-pages/src/interface-adapters/controllers/get-site-settings.controller.test.ts — 1 test (returns site settings)

Task 2: Entities split — new error files

  • packages/auth/src/entities/errors/auth.ts — AuthenticationError, UnauthenticatedError, UnauthorizedError (split from errors.ts)
  • packages/auth/src/entities/errors/common.ts — InputParseError (auth copy)
  • packages/blog/src/entities/errors/article.ts — ArticleNotFoundError (split from errors.ts)
  • packages/blog/src/entities/errors/common.ts — InputParseError (blog copy)
  • packages/marketing-pages/src/entities/errors/page.ts — PageNotFoundError (split from errors.ts)
  • packages/marketing-pages/src/entities/errors/common.ts — InputParseError (marketing-pages copy)
  • packages/navigation/src/entities/errors/header.ts — HeaderNotFoundError (new; navigation had no errors.ts)
  • packages/navigation/src/entities/errors/common.ts — InputParseError (navigation copy)

3. Files deleted (with reason)

Task 5: Blog factory refactor — deleted files

  • packages/blog/src/interface-adapters/controllers/articles.controller.ts — multi-method controller replaced by 3 single-responsibility factory files (get-articles.controller.ts, create-article.controller.ts, get-article-by-slug.controller.ts)
  • packages/blog/src/interface-adapters/controllers/articles.controller.test.ts — deleted with the controller; tests rewritten in the per-controller test files

Task 6: Marketing-pages factory refactor — deleted files

  • packages/marketing-pages/src/interface-adapters/controllers/pages.controller.ts — multi-method controller replaced by 2 single-responsibility factory files (get-page-by-slug.controller.ts, get-site-settings.controller.ts)
  • packages/marketing-pages/src/interface-adapters/controllers/pages.controller.test.ts — deleted with the controller; tests rewritten in the per-controller test files

Task 2: Entities split — old errors.ts files removed

  • packages/auth/src/entities/errors.ts — replaced by errors/auth.ts + errors/common.ts
  • packages/blog/src/entities/errors.ts — replaced by errors/article.ts + errors/common.ts
  • packages/marketing-pages/src/entities/errors.ts — replaced by errors/page.ts + errors/common.ts
  • (navigation had no errors.ts to delete)

4. Pattern changes (code-level)

4.1 Use cases — factory function pattern

Applied to all 3 auth use cases (sign-in, sign-up, sign-out) in Task 4, all 3 blog use cases (get-articles, create-article, get-article-by-slug NEW) in Task 5, and both marketing-pages use cases (get-page-by-slug, get-site-settings) in Task 6:

  • Use cases are now factory functions: (deps) => async (input) => result
  • Each file exports export type I*UseCase = ReturnType<typeof *UseCase> for DI typing
  • Use cases NO LONGER call *Container.get() inside their bodies — all dependencies are passed as factory arguments
  • Tests construct mocks directly: const useCase = getArticlesUseCase(repo); await useCase({ status: "draft" });
  • NEW getArticleBySlugUseCase: previously the slug lookup bypassed the use case layer (controller called repo directly); now the use case owns the ArticleNotFoundError throw
  • marketing-pages: getPageBySlugUseCase(pagesRepo) => async ({ slug }) => Page | undefined and getSiteSettingsUseCase(siteSettingsRepo) => async () => SiteSettings

4.2 Controllers — one per use case

Applied to all 3 auth controllers (sign-in, sign-up, sign-out) in Task 4; blog controllers split in Task 5; marketing-pages controllers split in Task 6:

  • Controllers were already split for auth (one file per use case) — Task 4 refactors them to factory functions
  • Blog: the multi-method articles.controller.ts is deleted and replaced by 3 single-responsibility files
  • Marketing-pages: the multi-method pages.controller.ts is deleted and replaced by 2 single-responsibility files (get-page-by-slug.controller.ts, get-site-settings.controller.ts)
  • Factory pattern: (useCase: I*UseCase) => async (input) => result
  • Each exports export type I*Controller = ReturnType<typeof *Controller>
  • Validation (Zod safeParse) stays inside the controller factory; throws InputParseError on failure

4.3 Entities split — models/ + errors/ subdirs

Pattern now in place across auth, blog, marketing-pages, navigation (media skipped — no entities yet):

  • Entity Zod schemas + types live at entities/models/<x>.ts
  • Domain errors live at entities/errors/<domain>.ts (e.g. errors/auth.ts, errors/article.ts)
  • Shared InputParseError lives at entities/errors/common.ts (one copy per feature — ~6 lines each)
  • Colocated entity test files moved alongside their respective sources (models/*.test.ts, errors/errors.test.ts)
  • All imports across factories, contracts, repositories, use cases, controllers, tests, and src/index.ts updated to new paths
  • navigation divergence: no errors.ts existed pre-refactor; errors/header.ts (HeaderNotFoundError) and errors/common.ts added as new forward-looking stubs

5. DI changes

5.1 Inversify .toDynamicValue bindings

Applied to packages/auth/src/di/module.ts (Task 4), packages/blog/src/di/module.ts (Task 5), and packages/marketing-pages/src/di/module.ts (Task 6):

auth:

  • AUTH_SYMBOLS expanded with 6 new keys: ISignInUseCase, ISignUpUseCase, ISignOutUseCase, ISignInController, ISignUpController, ISignOutController
  • Use cases bound with .toDynamicValue((ctx) => factoryFn(ctx.container.get(...))) — dependencies resolved from the container at call time
  • Controllers bound identically, taking the corresponding use case symbol from the container
  • Repository and service bindings remain .to(Mock*) as the default

blog:

  • BLOG_SYMBOLS expanded with 6 new keys: IGetArticlesUseCase, ICreateArticleUseCase, IGetArticleBySlugUseCase, IGetArticlesController, ICreateArticleController, IGetArticleBySlugController
  • All use cases and controllers bound with .toDynamicValue() — same pattern as auth
  • Repository binding remains .to(MockArticlesRepository) as the default
  • tRPC router (integrations/api/router.ts) updated to resolve controllers via blogContainer.get<IXController>(BLOG_SYMBOLS.IXController) instead of importing controllers directly

marketing-pages:

  • MARKETING_PAGES_SYMBOLS expanded with 4 new keys: IGetPageBySlugUseCase, IGetSiteSettingsUseCase, IGetPageBySlugController, IGetSiteSettingsController
  • Both use cases and both controllers bound with .toDynamicValue() — each use case receives its own repository from the container; each controller receives its use case
  • Repository bindings remain .to(MockPagesRepository) and .to(MockSiteSettingsRepository) as defaults
  • tRPC router updated to resolve controllers via marketingPagesContainer.get<IXController>(MARKETING_PAGES_SYMBOLS.IXController)

5.2 Mock siblings registered as default bindings

  • MockUsersRepository and MockAuthenticationService remain the default bindings in AuthModule
  • bindProductionAuth(config: SanitizedConfig) now swaps both to UsersRepository and AuthenticationService (real Payload-backed implementations)
  • Previously bindProductionAuth was a no-op; it now rebinds both symbols using .toConstantValue(new RealImpl(config))

6. Test refactor patterns

6.1 Direct injection (no container rebinding)

Applied to all auth use-case tests, controller tests, the router test, and the feature integration test:

  • Before: beforeEach unbinds and rebinds symbols on authContainer
  • After: each test (or it) constructs its own MockUsersRepository + MockAuthenticationService, then calls the factory directly:
    const users = new MockUsersRepository([]);
    const auth = new MockAuthenticationService(users);
    const useCase = signInUseCase(users, auth);
    const result = await useCase({ username: "alice", password: "testpassword" });
    
  • No authContainer.unbind() / authContainer.bind() calls remain in test files
  • The authRouter test and container.test.ts still reference authContainer (unavoidable — the router resolves controllers via the container, and the container test verifies the DI wiring), but they use the default bindings without rebinding
  • The feature test (tests/sign-in-flow.feature.test.ts) fully constructs the chain via direct injection rather than calling authRouter.createCaller({})

7. Open issues / deferred decisions

Task 4: AuthenticationService — deferred session methods

Three methods on AuthenticationService (in packages/auth/src/infrastructure/services/authentication.service.ts) are deferred because Payload's auth API does not map cleanly to the generic IAuthenticationService session interface:

Method Why deferred
createSession(user) Payload creates sessions via its REST /api/users/login endpoint and returns a JWT token. Mapping this to a generic { session: Session; cookie: Cookie } shape requires knowing Payload's JWT payload structure, the session expiry, and the exact cookie name/attributes Payload uses — which vary by collection config.
validateSession(sessionId) Payload validates sessions by verifying a JWT. This requires calling payload.auth() or payload.find() with the token, and is tightly coupled to Payload's internal token format.
invalidateSession(sessionId) Payload's default auth strategy is stateless JWT — there is no server-side session store to clear. Invalidation is done client-side by expiring the cookie. A proper implementation would require either a token blocklist or switching to Payload's API keys feature.

All three throw new NotImplementedError("methodName") with a clear message ("NotImplemented: AuthenticationService.<method> — see refactor log §7"). The mock (MockAuthenticationService) handles all test paths.

TODO: Revisit once the session cookie strategy is finalized. Consider:

  • Using Payload's local API payload.auth() for validateSession
  • Implementing a Redis-backed token blocklist for invalidateSession
  • Or replacing the IAuthenticationService interface with Payload-specific abstractions

Doc update checklist (deferred — run after merge)

After Plan 8 is merged to main, work through this list in a single batched doc-update pass. Each item points at an external doc that references the pre-Plan-8 layout or pattern and needs to be brought in line.

  • CLAUDE.md — Key Conventions section: update file path examples to use entities/models/<x>.ts; mention factory-function use cases; show I*UseCase type aliases
  • AGENTS.md (root) — Per-Package Conventions: update naming examples (<x>.repository.{ts,mock.ts,interface.ts}); add note about I*UseCase / I*Controller type aliases; document factory-style DI
  • docs/guides/adding-a-feature.md — restructure to use factory-function pattern in every step; update file paths to new layout; show .toDynamicValue() bindings
  • docs/guides/tdd-workflow.md — update "When to mock" decision tree to show direct factory injection (signInUseCase(mocks)(input)) instead of container rebinding; update factory usage examples to reference entities/models/* paths
  • docs/guides/testing-strategy.md — Mocking section: remove DI-rebinding pattern as the default; show direct factory injection
  • docs/architecture/vertical-feature-spec.md — update §10 (file shape examples) to new template; update §13 (testing) to reflect factory pattern + direct injection
  • docs/architecture/overview.md — layer descriptions: mention factory-function use cases; add note that controllers are one-per-use-case
  • docs/architecture/dependency-flow.md — verify dep flow still accurate with new DI bindings; update examples if any reference old paths
  • docs/decisions/adr-012-lazar-conformance.md — NEW ADR documenting the conformance decision and the four intentional divergences (per-feature DI, inversify retained, colocated tests, no Sentry/instrumentation services)
  • Per-feature AGENTS.md (auth/blog/media/marketing-pages/navigation) — update file path references; document factory pattern; update Tests section
  • packages/core-testing/AGENTS.md — update factory examples to reference entities/models/<x>.ts paths
  • packages/auth/AGENTS.md — document the new real UsersRepository and AuthenticationService (Payload-backed)
  • packages/media/AGENTS.md — full rewrite — media now has all Clean Architecture layers (entities, application, infrastructure, interface-adapters, DI, integrations/api)
  • Plan 7 plan/spec docs — add a one-line note at the top noting that paths reference the pre-Plan-8 layout; link to refactor changelog

Notes for the doc-update pass author

When updating external docs, apply these substitutions globally:

Old reference New reference
entities/<x>.ts entities/models/<x>.ts
entities/errors.ts entities/errors/<domain>.ts (or entities/errors/common.ts)
mock-<x>.repository.ts <x>.repository.mock.ts
payload-<x>.repository.ts <x>.repository.ts (real impl is now the canonical name)
<x>-repository.interface.ts <x>.repository.interface.ts
class PayloadXRepository class XRepository
Use case calls container.get(...) inside its body Use case is a factory function: (deps) => async (input) => result
Controller bundles multiple methods (articles.controller.ts) One controller per use case (get-articles.controller.ts, create-article.controller.ts)
Test does container.unbind(...).bind(...) in beforeEach Test constructs mocks and injects: const useCase = signInUseCase(mockUsers, mockAuth);
DI binds use case with .to(...) DI binds use case with .toDynamicValue((ctx) => factoryFn(ctx.container.get(...)))
Use case + controller imports inject via container Imports take dependencies as constructor/factory args

Add I*UseCase and I*Controller type alias examples wherever use cases / controllers are explained.