From 69623b995d64cf6642f8a625b4831a5ecfca6346 Mon Sep 17 00:00:00 2001 From: Danijel Martinek Date: Wed, 6 May 2026 00:19:15 +0200 Subject: [PATCH] refactor(navigation): factory-style use case + controller MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use case (get-header) → factory function with IGetHeaderUseCase alias - Controller renamed header.controller.ts → get-header.controller.ts (verb-noun); converted to factory function with IGetHeaderController alias - DI module wires factories with .toDynamicValue() - tRPC router resolves controller via container - Use case + controller tests refactored to direct factory injection (no container rebinding) - container.test.ts verifies IGetHeaderUseCase + IGetHeaderController symbols Refactor log: §1, §4.1, §4.2, §5.1 Spec: §6.4 Co-Authored-By: Claude Sonnet 4.6 --- .../2026-05-05-lazar-pattern-conformance.md | 14 +++++++++++- .../use-cases/get-header.use-case.test.ts | 20 +++++------------ .../use-cases/get-header.use-case.ts | 15 ++++++------- packages/navigation/src/di/container.test.ts | 16 ++++++++++++++ packages/navigation/src/di/module.ts | 22 +++++++++++++++++++ packages/navigation/src/di/symbols.ts | 4 ++++ .../src/integrations/api/router.test.ts | 15 +------------ .../navigation/src/integrations/api/router.ts | 11 ++++++++-- .../controllers/get-header.controller.test.ts | 17 ++++++++++++++ .../controllers/get-header.controller.ts | 10 +++++++++ .../controllers/header.controller.ts | 6 ----- 11 files changed, 104 insertions(+), 46 deletions(-) create mode 100644 packages/navigation/src/interface-adapters/controllers/get-header.controller.test.ts create mode 100644 packages/navigation/src/interface-adapters/controllers/get-header.controller.ts delete mode 100644 packages/navigation/src/interface-adapters/controllers/header.controller.ts diff --git a/docs/superpowers/refactor-logs/2026-05-05-lazar-pattern-conformance.md b/docs/superpowers/refactor-logs/2026-05-05-lazar-pattern-conformance.md index fa86f05..91bcad7 100644 --- a/docs/superpowers/refactor-logs/2026-05-05-lazar-pattern-conformance.md +++ b/docs/superpowers/refactor-logs/2026-05-05-lazar-pattern-conformance.md @@ -20,6 +20,10 @@ single follow-up pass. ## 1. File renames (before → after) +### Task 7: Controller rename + +- `packages/navigation/src/interface-adapters/controllers/header.controller.ts` → `get-header.controller.ts` (verb-noun convention; git mv — history preserved) + ### Task 3: File and class renames File renames — 27 files (git mv — history preserved): @@ -146,7 +150,7 @@ Entity model moves (git mv — history preserved): ### 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: +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, both marketing-pages use cases (`get-page-by-slug`, `get-site-settings`) in Task 6, and the single navigation use case (`get-header`) in Task 7: - Use cases are now factory functions: `(deps) => async (input) => result` - Each file exports `export type I*UseCase = ReturnType` for DI typing @@ -154,6 +158,7 @@ Applied to all 3 auth use cases (`sign-in`, `sign-up`, `sign-out`) in Task 4, al - 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` +- navigation: `getHeaderUseCase(headerRepo) => async () => Header`; `IGetHeaderUseCase` type alias exported ### 4.2 Controllers — one per use case @@ -162,6 +167,7 @@ Applied to all 3 auth controllers (`sign-in`, `sign-up`, `sign-out`) in Task 4; - 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`) +- Navigation: `header.controller.ts` renamed to `get-header.controller.ts` (verb-noun) and converted to factory function; exports `IGetHeaderController` type alias - Factory pattern: `(useCase: I*UseCase) => async (input) => result` - Each exports `export type I*Controller = ReturnType` - Validation (Zod `safeParse`) stays inside the controller factory; throws `InputParseError` on failure @@ -201,6 +207,12 @@ Applied to `packages/auth/src/di/module.ts` (Task 4), `packages/blog/src/di/modu - Repository bindings remain `.to(MockPagesRepository)` and `.to(MockSiteSettingsRepository)` as defaults - tRPC router updated to resolve controllers via `marketingPagesContainer.get(MARKETING_PAGES_SYMBOLS.IXController)` +**navigation:** +- `NAVIGATION_SYMBOLS` expanded with 2 new keys: `IGetHeaderUseCase`, `IGetHeaderController` +- Use case and controller bound with `.toDynamicValue((ctx) => factoryFn(ctx.container.get(...)))` — same pattern as other features +- Repository binding remains `.to(MockHeaderRepository)` as default +- tRPC router updated to resolve controller via `navigationContainer.get(NAVIGATION_SYMBOLS.IGetHeaderController)` + ### 5.2 Mock siblings registered as default bindings - `MockUsersRepository` and `MockAuthenticationService` remain the default bindings in `AuthModule` diff --git a/packages/navigation/src/application/use-cases/get-header.use-case.test.ts b/packages/navigation/src/application/use-cases/get-header.use-case.test.ts index 6a1d591..0dd8f66 100644 --- a/packages/navigation/src/application/use-cases/get-header.use-case.test.ts +++ b/packages/navigation/src/application/use-cases/get-header.use-case.test.ts @@ -1,22 +1,12 @@ -import { beforeEach, describe, expect, it } from "vitest"; -import { navigationContainer } from "@/di/container"; -import { NAVIGATION_SYMBOLS } from "@/di/symbols"; +import { describe, expect, it } from "vitest"; +import { getHeaderUseCase } from "@/application/use-cases/get-header.use-case"; import { MockHeaderRepository } from "@/infrastructure/repositories/header.repository.mock"; -import type { IHeaderRepository } from "@/application/repositories/header.repository.interface"; -import { getHeaderUseCase } from "./get-header.use-case"; describe("getHeaderUseCase", () => { - beforeEach(() => { - if (navigationContainer.isBound(NAVIGATION_SYMBOLS.IHeaderRepository)) { - navigationContainer.unbind(NAVIGATION_SYMBOLS.IHeaderRepository); - } - navigationContainer - .bind(NAVIGATION_SYMBOLS.IHeaderRepository) - .toConstantValue(new MockHeaderRepository()); - }); - it("returns the seeded header items", async () => { - const result = await getHeaderUseCase(); + const repo = new MockHeaderRepository(); + const useCase = getHeaderUseCase(repo); + const result = await useCase(); expect(result.items.length).toBeGreaterThan(0); expect(result.items[0]?.label).toBe("Home"); }); diff --git a/packages/navigation/src/application/use-cases/get-header.use-case.ts b/packages/navigation/src/application/use-cases/get-header.use-case.ts index 5afd89c..4da6487 100644 --- a/packages/navigation/src/application/use-cases/get-header.use-case.ts +++ b/packages/navigation/src/application/use-cases/get-header.use-case.ts @@ -1,11 +1,10 @@ import type { Header } from "../../entities/models/header"; -import { navigationContainer } from "../../di/container"; -import { NAVIGATION_SYMBOLS } from "../../di/symbols"; import type { IHeaderRepository } from "../repositories/header.repository.interface"; -export async function getHeaderUseCase(): Promise
{ - const repo = navigationContainer.get( - NAVIGATION_SYMBOLS.IHeaderRepository, - ); - return repo.getHeader(); -} +export type IGetHeaderUseCase = ReturnType; + +export const getHeaderUseCase = + (headerRepository: IHeaderRepository) => + async (): Promise
=> { + return headerRepository.getHeader(); + }; diff --git a/packages/navigation/src/di/container.test.ts b/packages/navigation/src/di/container.test.ts index 5b9e691..ec62712 100644 --- a/packages/navigation/src/di/container.test.ts +++ b/packages/navigation/src/di/container.test.ts @@ -4,6 +4,8 @@ import { NAVIGATION_SYMBOLS } from "./symbols"; import { NavigationModule } from "./module"; import { MockHeaderRepository } from "@/infrastructure/repositories/header.repository.mock"; import type { IHeaderRepository } from "@/application/repositories/header.repository.interface"; +import type { IGetHeaderUseCase } from "@/application/use-cases/get-header.use-case"; +import type { IGetHeaderController } from "@/interface-adapters/controllers/get-header.controller"; describe("navigationContainer", () => { beforeEach(() => { @@ -21,4 +23,18 @@ describe("navigationContainer", () => { ); expect(repo).toBeInstanceOf(MockHeaderRepository); }); + + it("resolves IGetHeaderUseCase as a function", () => { + const useCase = navigationContainer.get( + NAVIGATION_SYMBOLS.IGetHeaderUseCase, + ); + expect(typeof useCase).toBe("function"); + }); + + it("resolves IGetHeaderController as a function", () => { + const controller = navigationContainer.get( + NAVIGATION_SYMBOLS.IGetHeaderController, + ); + expect(typeof controller).toBe("function"); + }); }); diff --git a/packages/navigation/src/di/module.ts b/packages/navigation/src/di/module.ts index c429a27..3314e77 100644 --- a/packages/navigation/src/di/module.ts +++ b/packages/navigation/src/di/module.ts @@ -2,10 +2,32 @@ import { ContainerModule, type interfaces } from "inversify"; import type { IHeaderRepository } from "../application/repositories/header.repository.interface"; import { MockHeaderRepository } from "../infrastructure/repositories/header.repository.mock"; +import { + getHeaderUseCase, + type IGetHeaderUseCase, +} from "../application/use-cases/get-header.use-case"; +import { + getHeaderController, + type IGetHeaderController, +} from "../interface-adapters/controllers/get-header.controller"; import { NAVIGATION_SYMBOLS } from "./symbols"; export const NavigationModule = new ContainerModule((bind: interfaces.Bind) => { bind(NAVIGATION_SYMBOLS.IHeaderRepository).to( MockHeaderRepository, ); + + bind(NAVIGATION_SYMBOLS.IGetHeaderUseCase).toDynamicValue( + (ctx) => + getHeaderUseCase( + ctx.container.get(NAVIGATION_SYMBOLS.IHeaderRepository), + ), + ); + + bind(NAVIGATION_SYMBOLS.IGetHeaderController).toDynamicValue( + (ctx) => + getHeaderController( + ctx.container.get(NAVIGATION_SYMBOLS.IGetHeaderUseCase), + ), + ); }); diff --git a/packages/navigation/src/di/symbols.ts b/packages/navigation/src/di/symbols.ts index 5b2ae02..32637bd 100644 --- a/packages/navigation/src/di/symbols.ts +++ b/packages/navigation/src/di/symbols.ts @@ -1,3 +1,7 @@ export const NAVIGATION_SYMBOLS = { IHeaderRepository: Symbol.for("navigation:IHeaderRepository"), + // Use cases + IGetHeaderUseCase: Symbol.for("navigation:IGetHeaderUseCase"), + // Controllers + IGetHeaderController: Symbol.for("navigation:IGetHeaderController"), } as const; diff --git a/packages/navigation/src/integrations/api/router.test.ts b/packages/navigation/src/integrations/api/router.test.ts index 3b31bc7..dd03bd1 100644 --- a/packages/navigation/src/integrations/api/router.test.ts +++ b/packages/navigation/src/integrations/api/router.test.ts @@ -1,20 +1,7 @@ -import { beforeEach, describe, expect, it } from "vitest"; -import { navigationContainer } from "@/di/container"; -import { NAVIGATION_SYMBOLS } from "@/di/symbols"; -import { MockHeaderRepository } from "@/infrastructure/repositories/header.repository.mock"; -import type { IHeaderRepository } from "@/application/repositories/header.repository.interface"; +import { describe, expect, it } from "vitest"; import { navigationRouter } from "./router"; describe("navigationRouter", () => { - beforeEach(() => { - if (navigationContainer.isBound(NAVIGATION_SYMBOLS.IHeaderRepository)) { - navigationContainer.unbind(NAVIGATION_SYMBOLS.IHeaderRepository); - } - navigationContainer - .bind(NAVIGATION_SYMBOLS.IHeaderRepository) - .toConstantValue(new MockHeaderRepository()); - }); - it("exposes header procedure", () => { const names = Object.keys(navigationRouter._def.procedures); expect(names).toContain("header"); diff --git a/packages/navigation/src/integrations/api/router.ts b/packages/navigation/src/integrations/api/router.ts index 42d9856..1eecd4a 100644 --- a/packages/navigation/src/integrations/api/router.ts +++ b/packages/navigation/src/integrations/api/router.ts @@ -1,8 +1,15 @@ import { router, publicProcedure } from "@repo/core-shared/trpc/init"; -import { getHeaderController } from "../../interface-adapters/controllers/header.controller"; +import { navigationContainer } from "../../di/container"; +import { NAVIGATION_SYMBOLS } from "../../di/symbols"; +import type { IGetHeaderController } from "../../interface-adapters/controllers/get-header.controller"; export const navigationRouter = router({ - header: publicProcedure.query(() => getHeaderController()), + header: publicProcedure.query(() => { + const ctrl = navigationContainer.get( + NAVIGATION_SYMBOLS.IGetHeaderController, + ); + return ctrl(); + }), }); export type NavigationRouter = typeof navigationRouter; diff --git a/packages/navigation/src/interface-adapters/controllers/get-header.controller.test.ts b/packages/navigation/src/interface-adapters/controllers/get-header.controller.test.ts new file mode 100644 index 0000000..0e96970 --- /dev/null +++ b/packages/navigation/src/interface-adapters/controllers/get-header.controller.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; +import { getHeaderController } from "@/interface-adapters/controllers/get-header.controller"; +import { getHeaderUseCase } from "@/application/use-cases/get-header.use-case"; +import { MockHeaderRepository } from "@/infrastructure/repositories/header.repository.mock"; + +describe("getHeaderController", () => { + it("returns the header with items", async () => { + const repo = new MockHeaderRepository(); + const useCase = getHeaderUseCase(repo); + const controller = getHeaderController(useCase); + + const result = await controller(); + + expect(result.items.length).toBeGreaterThan(0); + expect(result.items[0]?.label).toBe("Home"); + }); +}); diff --git a/packages/navigation/src/interface-adapters/controllers/get-header.controller.ts b/packages/navigation/src/interface-adapters/controllers/get-header.controller.ts new file mode 100644 index 0000000..c8786c7 --- /dev/null +++ b/packages/navigation/src/interface-adapters/controllers/get-header.controller.ts @@ -0,0 +1,10 @@ +import type { Header } from "../../entities/models/header"; +import type { IGetHeaderUseCase } from "../../application/use-cases/get-header.use-case"; + +export type IGetHeaderController = ReturnType; + +export const getHeaderController = + (getHeaderUseCase: IGetHeaderUseCase) => + async (): Promise
=> { + return getHeaderUseCase(); + }; diff --git a/packages/navigation/src/interface-adapters/controllers/header.controller.ts b/packages/navigation/src/interface-adapters/controllers/header.controller.ts deleted file mode 100644 index b51aced..0000000 --- a/packages/navigation/src/interface-adapters/controllers/header.controller.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { Header } from "../../entities/models/header"; -import { getHeaderUseCase } from "../../application/use-cases/get-header.use-case"; - -export async function getHeaderController(): Promise
{ - return getHeaderUseCase(); -}