refactor(navigation): factory-style use case + controller
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -20,6 +20,10 @@ single follow-up pass.
|
|||||||
|
|
||||||
## 1. File renames (before → after)
|
## 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
|
### Task 3: File and class renames
|
||||||
|
|
||||||
File renames — 27 files (git mv — history preserved):
|
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
|
### 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`
|
- Use cases are now factory functions: `(deps) => async (input) => result`
|
||||||
- Each file exports `export type I*UseCase = ReturnType<typeof *UseCase>` for DI typing
|
- Each file exports `export type I*UseCase = ReturnType<typeof *UseCase>` 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" });`
|
- 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
|
- 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`
|
- 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
|
### 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
|
- 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
|
- 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`)
|
- 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`
|
- Factory pattern: `(useCase: I*UseCase) => async (input) => result`
|
||||||
- Each exports `export type I*Controller = ReturnType<typeof *Controller>`
|
- Each exports `export type I*Controller = ReturnType<typeof *Controller>`
|
||||||
- Validation (Zod `safeParse`) stays inside the controller factory; throws `InputParseError` on failure
|
- 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
|
- Repository bindings remain `.to(MockPagesRepository)` and `.to(MockSiteSettingsRepository)` as defaults
|
||||||
- tRPC router updated to resolve controllers via `marketingPagesContainer.get<IXController>(MARKETING_PAGES_SYMBOLS.IXController)`
|
- tRPC router updated to resolve controllers via `marketingPagesContainer.get<IXController>(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<IGetHeaderController>(NAVIGATION_SYMBOLS.IGetHeaderController)`
|
||||||
|
|
||||||
### 5.2 Mock siblings registered as default bindings
|
### 5.2 Mock siblings registered as default bindings
|
||||||
|
|
||||||
- `MockUsersRepository` and `MockAuthenticationService` remain the default bindings in `AuthModule`
|
- `MockUsersRepository` and `MockAuthenticationService` remain the default bindings in `AuthModule`
|
||||||
|
|||||||
@@ -1,22 +1,12 @@
|
|||||||
import { beforeEach, describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { navigationContainer } from "@/di/container";
|
import { getHeaderUseCase } from "@/application/use-cases/get-header.use-case";
|
||||||
import { NAVIGATION_SYMBOLS } from "@/di/symbols";
|
|
||||||
import { MockHeaderRepository } from "@/infrastructure/repositories/header.repository.mock";
|
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", () => {
|
describe("getHeaderUseCase", () => {
|
||||||
beforeEach(() => {
|
|
||||||
if (navigationContainer.isBound(NAVIGATION_SYMBOLS.IHeaderRepository)) {
|
|
||||||
navigationContainer.unbind(NAVIGATION_SYMBOLS.IHeaderRepository);
|
|
||||||
}
|
|
||||||
navigationContainer
|
|
||||||
.bind<IHeaderRepository>(NAVIGATION_SYMBOLS.IHeaderRepository)
|
|
||||||
.toConstantValue(new MockHeaderRepository());
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns the seeded header items", async () => {
|
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.length).toBeGreaterThan(0);
|
||||||
expect(result.items[0]?.label).toBe("Home");
|
expect(result.items[0]?.label).toBe("Home");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import type { Header } from "../../entities/models/header";
|
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";
|
import type { IHeaderRepository } from "../repositories/header.repository.interface";
|
||||||
|
|
||||||
export async function getHeaderUseCase(): Promise<Header> {
|
export type IGetHeaderUseCase = ReturnType<typeof getHeaderUseCase>;
|
||||||
const repo = navigationContainer.get<IHeaderRepository>(
|
|
||||||
NAVIGATION_SYMBOLS.IHeaderRepository,
|
export const getHeaderUseCase =
|
||||||
);
|
(headerRepository: IHeaderRepository) =>
|
||||||
return repo.getHeader();
|
async (): Promise<Header> => {
|
||||||
}
|
return headerRepository.getHeader();
|
||||||
|
};
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { NAVIGATION_SYMBOLS } from "./symbols";
|
|||||||
import { NavigationModule } from "./module";
|
import { NavigationModule } from "./module";
|
||||||
import { MockHeaderRepository } from "@/infrastructure/repositories/header.repository.mock";
|
import { MockHeaderRepository } from "@/infrastructure/repositories/header.repository.mock";
|
||||||
import type { IHeaderRepository } from "@/application/repositories/header.repository.interface";
|
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", () => {
|
describe("navigationContainer", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -21,4 +23,18 @@ describe("navigationContainer", () => {
|
|||||||
);
|
);
|
||||||
expect(repo).toBeInstanceOf(MockHeaderRepository);
|
expect(repo).toBeInstanceOf(MockHeaderRepository);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("resolves IGetHeaderUseCase as a function", () => {
|
||||||
|
const useCase = navigationContainer.get<IGetHeaderUseCase>(
|
||||||
|
NAVIGATION_SYMBOLS.IGetHeaderUseCase,
|
||||||
|
);
|
||||||
|
expect(typeof useCase).toBe("function");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves IGetHeaderController as a function", () => {
|
||||||
|
const controller = navigationContainer.get<IGetHeaderController>(
|
||||||
|
NAVIGATION_SYMBOLS.IGetHeaderController,
|
||||||
|
);
|
||||||
|
expect(typeof controller).toBe("function");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,10 +2,32 @@ import { ContainerModule, type interfaces } from "inversify";
|
|||||||
|
|
||||||
import type { IHeaderRepository } from "../application/repositories/header.repository.interface";
|
import type { IHeaderRepository } from "../application/repositories/header.repository.interface";
|
||||||
import { MockHeaderRepository } from "../infrastructure/repositories/header.repository.mock";
|
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";
|
import { NAVIGATION_SYMBOLS } from "./symbols";
|
||||||
|
|
||||||
export const NavigationModule = new ContainerModule((bind: interfaces.Bind) => {
|
export const NavigationModule = new ContainerModule((bind: interfaces.Bind) => {
|
||||||
bind<IHeaderRepository>(NAVIGATION_SYMBOLS.IHeaderRepository).to(
|
bind<IHeaderRepository>(NAVIGATION_SYMBOLS.IHeaderRepository).to(
|
||||||
MockHeaderRepository,
|
MockHeaderRepository,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
bind<IGetHeaderUseCase>(NAVIGATION_SYMBOLS.IGetHeaderUseCase).toDynamicValue(
|
||||||
|
(ctx) =>
|
||||||
|
getHeaderUseCase(
|
||||||
|
ctx.container.get<IHeaderRepository>(NAVIGATION_SYMBOLS.IHeaderRepository),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
bind<IGetHeaderController>(NAVIGATION_SYMBOLS.IGetHeaderController).toDynamicValue(
|
||||||
|
(ctx) =>
|
||||||
|
getHeaderController(
|
||||||
|
ctx.container.get<IGetHeaderUseCase>(NAVIGATION_SYMBOLS.IGetHeaderUseCase),
|
||||||
|
),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
export const NAVIGATION_SYMBOLS = {
|
export const NAVIGATION_SYMBOLS = {
|
||||||
IHeaderRepository: Symbol.for("navigation:IHeaderRepository"),
|
IHeaderRepository: Symbol.for("navigation:IHeaderRepository"),
|
||||||
|
// Use cases
|
||||||
|
IGetHeaderUseCase: Symbol.for("navigation:IGetHeaderUseCase"),
|
||||||
|
// Controllers
|
||||||
|
IGetHeaderController: Symbol.for("navigation:IGetHeaderController"),
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -1,20 +1,7 @@
|
|||||||
import { beforeEach, describe, expect, it } from "vitest";
|
import { 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 { navigationRouter } from "./router";
|
import { navigationRouter } from "./router";
|
||||||
|
|
||||||
describe("navigationRouter", () => {
|
describe("navigationRouter", () => {
|
||||||
beforeEach(() => {
|
|
||||||
if (navigationContainer.isBound(NAVIGATION_SYMBOLS.IHeaderRepository)) {
|
|
||||||
navigationContainer.unbind(NAVIGATION_SYMBOLS.IHeaderRepository);
|
|
||||||
}
|
|
||||||
navigationContainer
|
|
||||||
.bind<IHeaderRepository>(NAVIGATION_SYMBOLS.IHeaderRepository)
|
|
||||||
.toConstantValue(new MockHeaderRepository());
|
|
||||||
});
|
|
||||||
|
|
||||||
it("exposes header procedure", () => {
|
it("exposes header procedure", () => {
|
||||||
const names = Object.keys(navigationRouter._def.procedures);
|
const names = Object.keys(navigationRouter._def.procedures);
|
||||||
expect(names).toContain("header");
|
expect(names).toContain("header");
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
import { router, publicProcedure } from "@repo/core-shared/trpc/init";
|
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({
|
export const navigationRouter = router({
|
||||||
header: publicProcedure.query(() => getHeaderController()),
|
header: publicProcedure.query(() => {
|
||||||
|
const ctrl = navigationContainer.get<IGetHeaderController>(
|
||||||
|
NAVIGATION_SYMBOLS.IGetHeaderController,
|
||||||
|
);
|
||||||
|
return ctrl();
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type NavigationRouter = typeof navigationRouter;
|
export type NavigationRouter = typeof navigationRouter;
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<typeof getHeaderController>;
|
||||||
|
|
||||||
|
export const getHeaderController =
|
||||||
|
(getHeaderUseCase: IGetHeaderUseCase) =>
|
||||||
|
async (): Promise<Header> => {
|
||||||
|
return getHeaderUseCase();
|
||||||
|
};
|
||||||
@@ -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<Header> {
|
|
||||||
return getHeaderUseCase();
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user