From e981ebd9e9c5e71d077b628518fbf2caf6c91c08 Mon Sep 17 00:00:00 2001 From: Marc Mintel Date: Tue, 13 Jan 2026 01:36:27 +0100 Subject: [PATCH] website refactor --- docs/architecture/website/DISPLAY_OBJECTS.md | 51 +- docs/architecture/website/MUTATIONS.md | 49 +- .../website/PAGE_QUERY_ERROR_HANDLING.md | 481 ++++++++++++++++++ docs/architecture/website/WEBSITE_CONTRACT.md | 110 ++-- .../website/WEBSITE_CONTRACT_WRITE_FLOW.md | 28 +- 5 files changed, 623 insertions(+), 96 deletions(-) create mode 100644 docs/architecture/website/PAGE_QUERY_ERROR_HANDLING.md diff --git a/docs/architecture/website/DISPLAY_OBJECTS.md b/docs/architecture/website/DISPLAY_OBJECTS.md index e470c43d1..0fd4ccfed 100644 --- a/docs/architecture/website/DISPLAY_OBJECTS.md +++ b/docs/architecture/website/DISPLAY_OBJECTS.md @@ -1,10 +1,10 @@ -# Display Objects +# Displays ## Definition -A **Display Object** encapsulates **reusable, UI-only display logic**. +A **Display** encapsulates **reusable, UI-only display logic**. -In this codebase, a Display Object is a **Frontend Value Object**: +In this codebase, a Display is a **Frontend Value Object**: - class-based - immutable @@ -15,14 +15,20 @@ It answers the question: > “How should this specific piece of information be shown?” -Display Objects are **not screen-specific**. +Displays are **not screen-specific**. They exist to avoid duplicating presentation logic across View Models. +**Naming Convention:** +- Displays MUST end with `Display` suffix +- Displays MUST be reusable across multiple screens +- Valid examples: `PriceDisplay`, `EmailDisplay`, `RatingDisplay` +- Invalid examples: `DashboardRatingDisplay`, `UserProfileDisplay` + --- ## Responsibilities -A Display Object MAY: +A Display MAY: - format values (money, dates, durations) - handle localization only when localization inputs are deterministic (for example: mapping stable codes to stable labels) @@ -30,18 +36,18 @@ A Display Object MAY: - encapsulate UI display conventions - be reused across multiple View Models -In addition, a Display Object MAY: +In addition, a Display MAY: - normalize presentation inputs (for example trimming/casing) - expose multiple explicit display variants (for example `shortLabel`, `longLabel`) -A Display Object MUST: +A Display MUST: - be deterministic - be side-effect free - operate only on presentation data -A Display Object MUST: +A Display MUST: - be implemented as a **class** with a small, explicit API - accept only primitives/plain data in its constructor (or static factory) @@ -51,7 +57,7 @@ A Display Object MUST: ## Restrictions -A Display Object MUST NOT: +A Display MUST NOT: - contain business logic - enforce domain invariants @@ -60,7 +66,7 @@ A Display Object MUST NOT: - be sent back to the server - depend on backend or infrastructure concerns -In this repository, a Display Object MUST NOT: +In this repository, a Display MUST NOT: - call `Intl.*` - call `Date.toLocaleString()` / `Date.toLocaleDateString()` / `Date.toLocaleTimeString()` @@ -82,41 +88,42 @@ Forbidden approaches: - any usage of `toLocale*` If a rule affects system correctness or persistence, -it does not belong in a Display Object. +it does not belong in a Display. --- ## Ownership & Placement -- Display Objects belong to the **presentation layer** +- Displays belong to the **presentation layer** - They are frontend-only - They are not shared with the backend or core Placement rule (strict): -- Display Objects live under `apps/website/lib/display-objects/*`. +- Displays live under `apps/website/lib/display-objects/*`. +- Filenames MUST match the class name with `.tsx` extension (e.g., `RatingDisplay.tsx` contains `class RatingDisplay`) --- ## Relationship to View Models -- View Models MAY use Display Objects -- Display Objects MUST NOT depend on View Models -- Display Objects represent **parts** +- View Models MAY use Displays +- Displays MUST NOT depend on View Models +- Displays represent **parts** - View Models represent **screens** Additional strict rules: -- View Models SHOULD compose Display Objects. -- Display Objects MUST NOT be serialized or passed across boundaries. +- View Models SHOULD compose Displays. +- Displays MUST NOT be serialized or passed across boundaries. - They must not appear in server-to-client DTOs. - - Templates should receive primitive display outputs, not Display Object instances. + - Templates should receive primitive display outputs, not Display instances. --- ## Testing -Display Objects SHOULD be tested because they often contain: +Displays SHOULD be tested because they often contain: - locale-specific behavior - formatting rules @@ -131,9 +138,9 @@ Additionally: ## Summary -- Display Objects encapsulate **how something looks** +- Displays encapsulate **how something looks** - View Models encapsulate **what a screen needs** - Both are presentation concerns - Neither contains business truth -In one sentence: Display Objects are **Value Objects for UI display**, not utility functions. +In one sentence: Displays are **Value Objects for UI display**, not utility functions. \ No newline at end of file diff --git a/docs/architecture/website/MUTATIONS.md b/docs/architecture/website/MUTATIONS.md index 700610689..c6f6d8145 100644 --- a/docs/architecture/website/MUTATIONS.md +++ b/docs/architecture/website/MUTATIONS.md @@ -21,15 +21,16 @@ Mutations are the write equivalent of PageQueries. import { AdminUserMutation } from '@/lib/mutations/admin/AdminUserMutation'; import { revalidatePath } from 'next/cache'; -export async function updateUserStatus(userId: string, status: string): Promise { - try { - const mutation = new AdminUserMutation(); - await mutation.updateUserStatus(userId, status); - revalidatePath('/admin/users'); - } catch (error) { - console.error('updateUserStatus failed:', error); +export async function updateUserStatus(userId: string, status: string) { + const mutation = new AdminUserMutation(); + const result = await mutation.updateUserStatus(userId, status); + + if (result.isErr()) { + console.error('updateUserStatus failed:', result.getError()); throw new Error('Failed to update user status'); } + + revalidatePath('/admin/users'); } ``` @@ -37,6 +38,8 @@ export async function updateUserStatus(userId: string, status: string): Promise< ```typescript // lib/mutations/admin/AdminUserMutation.ts +import { Result, ResultFactory } from '@/lib/contracts/Result'; + export class AdminUserMutation { private service: AdminService; @@ -53,12 +56,22 @@ export class AdminUserMutation { this.service = new AdminService(apiClient); } - async updateUserStatus(userId: string, status: string): Promise { - await this.service.updateUserStatus(userId, status); + async updateUserStatus(userId: string, status: string): Promise> { + try { + await this.service.updateUserStatus(userId, status); + return ResultFactory.ok(undefined); + } catch (error) { + return ResultFactory.error('UPDATE_USER_STATUS_FAILED'); + } } - async deleteUser(userId: string): Promise { - await this.service.deleteUser(userId); + async deleteUser(userId: string): Promise> { + try { + await this.service.deleteUser(userId); + return ResultFactory.ok(undefined); + } catch (error) { + return ResultFactory.error('DELETE_USER_FAILED'); + } } } ``` @@ -108,12 +121,12 @@ lib/ | Location | `lib/page-queries/` | `lib/mutations/` | | Framework | Called from RSC | Called from Server Actions | | Infrastructure | Manual DI | Manual DI | -| Returns | Page DTO | void or result | +| Returns | `Result` | `Result` | | Revalidation | N/A | Server Action handles it | ## 9) Example Flow -**Read:** +**Read (RSC):** ``` RSC page.tsx ↓ @@ -123,10 +136,14 @@ Service ↓ API Client ↓ -Page DTO +Result + ↓ +ViewDataBuilder + ↓ +Template ``` -**Write:** +**Write (Server Action):** ``` Client Component ↓ @@ -138,6 +155,8 @@ Service ↓ API Client ↓ +Result + ↓ Revalidation ``` diff --git a/docs/architecture/website/PAGE_QUERY_ERROR_HANDLING.md b/docs/architecture/website/PAGE_QUERY_ERROR_HANDLING.md new file mode 100644 index 000000000..24cf83179 --- /dev/null +++ b/docs/architecture/website/PAGE_QUERY_ERROR_HANDLING.md @@ -0,0 +1,481 @@ +# PageQuery Error Handling Design (Option 1: Clean Architecture) + +## The Problem + +The current `DashboardPageQuery` mixes HTTP concerns with domain logic: + +```typescript +// ❌ WRONG - Mixes HTTP status codes with PageQuery logic +interface ErrorWithStatusCode extends Error { + statusCode?: number; +} + +if (errorWithStatus.statusCode === 404) { + return Result.err('notFound'); +} +``` + +This violates Clean Architecture because: +- PageQueries should not know about HTTP status codes +- PageQueries should use Services, not API Clients directly +- Error classification should happen at the Service layer +- PageQueries should only deal with domain error IDs + +## The Solution: Layered Error Handling + +### Layer 1: API Client (HTTP → Technical Errors) + +The API client handles HTTP communication and catches HTTP errors: + +```typescript +// apps/website/lib/api/dashboard/DashboardApiClient.ts +export class DashboardApiClient { + async getDashboardOverview(): Promise { + const response = await fetch(`${this.baseUrl}/dashboard/overview`, { + headers: this.getHeaders(), + }); + + if (!response.ok) { + // Throw HTTP-specific errors (will be caught by Service) + if (response.status === 404) { + throw new HttpNotFoundError('Dashboard not found'); + } + if (response.status === 401 || response.status === 403) { + throw new HttpUnauthorizedError('Access denied'); + } + if (response.status >= 500) { + throw new HttpServerError('Server error'); + } + throw new HttpError(`HTTP ${response.status}`); + } + + return response.json(); + } +} + +// HTTP error classes +class HttpError extends Error {} +class HttpNotFoundError extends HttpError {} +class HttpUnauthorizedError extends HttpError {} +class HttpServerError extends HttpError {} +``` + +### Layer 2: Service (Technical → Domain Errors) + +The Service catches HTTP errors and converts them to domain errors: + +```typescript +// apps/website/lib/services/dashboard/DashboardService.ts +import { DashboardApiClient } from '@/lib/api/dashboard/DashboardApiClient'; + +export type DashboardServiceError = + | { type: 'notFound'; message: string } + | { type: 'unauthorized'; message: string } + | { type: 'serverError'; message: string } + | { type: 'networkError'; message: string } + | { type: 'unknown'; message: string }; + +export class DashboardService { + constructor(private apiClient: DashboardApiClient) {} + + async getDashboardOverview(): Promise> { + try { + const data = await this.apiClient.getDashboardOverview(); + return Result.ok(data); + } catch (error) { + // Convert HTTP errors to domain errors + if (error instanceof HttpNotFoundError) { + return Result.err({ type: 'notFound', message: error.message }); + } + if (error instanceof HttpUnauthorizedError) { + return Result.err({ type: 'unauthorized', message: error.message }); + } + if (error instanceof HttpServerError) { + return Result.err({ type: 'serverError', message: error.message }); + } + if (error instanceof HttpError) { + return Result.err({ type: 'unknown', message: error.message }); + } + if (error instanceof Error) { + return Result.err({ type: 'networkError', message: error.message }); + } + return Result.err({ type: 'unknown', message: 'Unknown error' }); + } + } +} +``` + +### Layer 3: PageQuery (Domain → Presentation Errors) + +PageQueries use Services and map domain errors to presentation errors: + +```typescript +// apps/website/lib/page-queries/page-queries/DashboardPageQuery.ts +import { DashboardService, type DashboardServiceError } from '@/lib/services/dashboard/DashboardService'; + +// Presentation error IDs (what the template/redirect logic understands) +type DashboardPageError = 'notFound' | 'redirect' | 'DASHBOARD_FETCH_FAILED' | 'UNKNOWN_ERROR'; + +export class DashboardPageQuery implements PageQuery { + async execute(): Promise> { + // Manual wiring + const errorReporter = new ConsoleErrorReporter(); + const logger = new ConsoleLogger(); + const baseUrl = process.env.NEXT_PUBLIC_API_URL || ''; + const apiClient = new DashboardApiClient(baseUrl, errorReporter, logger); + const dashboardService = new DashboardService(apiClient); + + // Call service + const serviceResult = await dashboardService.getDashboardOverview(); + + if (serviceResult.isErr()) { + const serviceError = serviceResult.error; + + // Map service errors to presentation errors + switch (serviceError.type) { + case 'notFound': + return Result.err('notFound'); + case 'unauthorized': + return Result.err('redirect'); // Redirect to login + case 'serverError': + case 'networkError': + case 'unknown': + return Result.err('DASHBOARD_FETCH_FAILED'); + default: + return Result.err('UNKNOWN_ERROR'); + } + } + + // Success - transform to ViewData + const apiDto = serviceResult.value; + const viewData = DashboardViewDataBuilder.build(apiDto); + return Result.ok(viewData); + } +} +``` + +### Layer 4: RSC Page (Presentation → User) + +The RSC page handles presentation errors: + +```typescript +// apps/website/app/dashboard/page.tsx +import { DashboardPageQuery } from '@/lib/page-queries/page-queries/DashboardPageQuery'; +import { DashboardTemplate } from '@/templates/DashboardTemplate'; + +export default async function DashboardPage() { + const query = new DashboardPageQuery(); + const result = await query.execute(); + + if (result.isErr()) { + switch (result.error) { + case 'notFound': + notFound(); + case 'redirect': + redirect('/login'); + case 'DASHBOARD_FETCH_FAILED': + case 'UNKNOWN_ERROR': + return ; + } + } + + return ; +} +``` + +## Architecture Flow + +``` +HTTP Response (HTTP status codes) + ↓ +API Client (catches HTTP errors, throws HttpError) + ↓ +Service (catches HttpError, returns Result) + ↓ +PageQuery (uses Service, returns Result) + ↓ +RSC Page (handles PresentationError, renders template or error) +``` + +## Key Principles + +1. **PageQueries use Services**: Never call API Clients directly +2. **Services return Result**: All service methods return `Result` +3. **Services handle HTTP errors**: Convert HttpError → DomainError +4. **PageQueries handle presentation errors**: Convert DomainError → PresentationError +5. **No HTTP knowledge in PageQueries**: They don't know about status codes + +## Benefits + +1. **Clean Architecture**: Each layer has clear boundaries +2. **Type Safety**: Errors are typed at each boundary +3. **Testability**: Can mock services in PageQueries +4. **Flexibility**: Can swap HTTP implementations +5. **Framework Agnostic**: PageQueries remain pure + +## Complete Architecture: Read + Write Flows + +### Read Flow (PageQueries) + +``` +HTTP Response (HTTP status codes) + ↓ +API Client (catches HTTP errors, throws HttpError) + ↓ +Service (catches HttpError, returns Result) + ↓ +PageQuery (uses Service, returns Result) + ↓ +RSC Page (handles PresentationError, renders template or error) +``` + +### Write Flow (Mutations) + +``` +User Action (form submission, button click) + ↓ +Server Action (validates input, creates Command Model) + ↓ +Mutation (uses Service, returns Result) + ↓ +Service (catches HttpError, returns Result) + ↓ +API Client (throws HttpError on failure) + ↓ +Server Action (handles Result, revalidates data, returns to client) + ↓ +Client Component (shows success message or error) +``` + +## Mutation Error Handling + +### Layer 1: API Client (HTTP → Technical Errors) + +```typescript +// apps/website/lib/api/user/UserApiClient.ts +export class UserApiClient { + async updateUserStatus(userId: string, status: string): Promise { + const response = await fetch(`${this.baseUrl}/users/${userId}/status`, { + method: 'PUT', + body: JSON.stringify({ status }), + headers: this.getHeaders(), + }); + + if (!response.ok) { + if (response.status === 404) { + throw new HttpNotFoundError('User not found'); + } + if (response.status === 403) { + throw new HttpForbiddenError('Insufficient permissions'); + } + if (response.status === 400) { + const error = await response.json(); + throw new HttpValidationError(error.message); + } + throw new HttpError(`HTTP ${response.status}`); + } + } +} +``` + +### Layer 2: Service (Technical → Domain Errors) + +```typescript +// apps/website/lib/services/user/UserService.ts +export type UserServiceError = + | { type: 'notFound'; message: string } + | { type: 'forbidden'; message: string } + | { type: 'validation'; message: string } + | { type: 'serverError'; message: string }; + +export class UserService { + constructor(private apiClient: UserApiClient) {} + + async updateUserStatus(userId: string, status: string): Promise> { + try { + await this.apiClient.updateUserStatus(userId, status); + return Result.ok(undefined); + } catch (error) { + if (error instanceof HttpNotFoundError) { + return Result.err({ type: 'notFound', message: error.message }); + } + if (error instanceof HttpForbiddenError) { + return Result.err({ type: 'forbidden', message: error.message }); + } + if (error instanceof HttpValidationError) { + return Result.err({ type: 'validation', message: error.message }); + } + if (error instanceof HttpError) { + return Result.err({ type: 'serverError', message: error.message }); + } + return Result.err({ type: 'serverError', message: 'Unknown error' }); + } + } +} +``` + +### Layer 3: Mutation (Domain → Presentation Errors) + +```typescript +// apps/website/lib/mutations/UpdateUserStatusMutation.ts +import { UserService, type UserServiceError } from '@/lib/services/user/UserService'; + +// Presentation error IDs +type UpdateUserStatusError = 'userNotFound' | 'noPermission' | 'invalidData' | 'updateFailed'; + +export class UpdateUserStatusMutation implements Mutation { + constructor(private userService: UserService) {} + + async execute(input: UpdateUserStatusInput): Promise> { + const serviceResult = await this.userService.updateUserStatus(input.userId, input.status); + + if (serviceResult.isErr()) { + const error = serviceResult.error; + + switch (error.type) { + case 'notFound': + return Result.err('userNotFound'); + case 'forbidden': + return Result.err('noPermission'); + case 'validation': + return Result.err('invalidData'); + case 'serverError': + return Result.err('updateFailed'); + default: + return Result.err('updateFailed'); + } + } + + return Result.ok(undefined); + } +} +``` + +### Layer 4: Server Action (Presentation → User) + +```typescript +// apps/website/app/actions/userActions.ts +'use server'; + +import { UpdateUserStatusMutation } from '@/lib/mutations/UpdateUserStatusMutation'; +import { UserService } from '@/lib/services/user/UserService'; +import { UserApiClient } from '@/lib/api/user/UserApiClient'; +import { revalidatePath } from 'next/cache'; + +export async function updateUserStatus(input: UpdateUserStatusInput) { + // Manual wiring + const errorReporter = new ConsoleErrorReporter(); + const logger = new ConsoleLogger(); + const baseUrl = process.env.NEXT_PUBLIC_API_URL || ''; + const apiClient = new UserApiClient(baseUrl, errorReporter, logger); + const userService = new UserService(apiClient); + const mutation = new UpdateUserStatusMutation(userService); + + const result = await mutation.execute(input); + + if (result.isErr()) { + // Return error to client + return { success: false, error: result.error }; + } + + // Success - revalidate and return + revalidatePath('/admin/users'); + return { success: true }; +} +``` + +### Layer 5: Client Component (Handles Result) + +```typescript +// apps/website/components/admin/UserStatusForm.tsx +'use client'; + +import { updateUserStatus } from '@/app/actions/userActions'; +import { useState } from 'react'; + +export function UserStatusForm({ userId }: { userId: string }) { + const [status, setStatus] = useState('active'); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + setSuccess(false); + + const result = await updateUserStatus({ userId, status }); + + if (result.success) { + setSuccess(true); + } else { + // Map mutation error to user-friendly message + switch (result.error) { + case 'userNotFound': + setError('User not found'); + break; + case 'noPermission': + setError('You do not have permission to update this user'); + break; + case 'invalidData': + setError('Invalid status selected'); + break; + case 'updateFailed': + setError('Failed to update user status'); + break; + } + } + }; + + return ( +
+ + + {error &&
{error}
} + {success &&
User updated!
} +
+ ); +} +``` + +## Complete Error Flow Comparison + +### Read Flow (PageQuery) +``` +HTTP 404 → HttpNotFoundError → Service Error {type: 'notFound'} → PageQuery Error 'notFound' → notFound() +``` + +### Write Flow (Mutation) +``` +HTTP 404 → HttpNotFoundError → Service Error {type: 'notFound'} → Mutation Error 'userNotFound' → Client shows "User not found" +``` + +## Key Differences + +| Aspect | PageQuery | Mutation | +|--------|-----------|----------| +| **Purpose** | Fetch data for rendering | Perform write operations | +| **Return Type** | `Result` | `Result` | +| **Error Handling** | RSC handles (notFound, redirect) | Client handles (show message) | +| **Side Effects** | None (pure) | Revalidation, cache invalidation | +| **User Feedback** | Error page or redirect | Toast message or inline error | + +## Implementation Steps + +1. **Update Services**: Return `Result` for both read and write +2. **Update PageQueries**: Use Services, map DomainError → PresentationError +3. **Update Mutations**: Use Services, map DomainError → MutationError +4. **Remove ErrorWithStatusCode**: Delete from all PageQueries +5. **Add ESLint Rules**: Prevent status codes and direct API usage + +## ESLint Rules Needed + +1. **No status codes in PageQueries**: Prevent `statusCode` checks +2. **No status codes in Mutations**: Prevent `statusCode` checks +3. **PageQueries must use Services**: Prevent direct API client usage +4. **Mutations must use Services**: Prevent direct API client usage +5. **Services must return Result**: Enforce Result pattern + +This complete architecture handles both read and write flows with clean separation of concerns. diff --git a/docs/architecture/website/WEBSITE_CONTRACT.md b/docs/architecture/website/WEBSITE_CONTRACT.md index 60afe91a8..b8ea18f7a 100644 --- a/docs/architecture/website/WEBSITE_CONTRACT.md +++ b/docs/architecture/website/WEBSITE_CONTRACT.md @@ -110,7 +110,7 @@ Rules: - MUST be side-effect free. - MUST NOT call HTTP. - MUST NOT call the API. -- Input: API Transport DTO +- Input: `Result` or `ApiDto` - Output: ViewModel See [`BUILDERS.md`](docs/architecture/website/BUILDERS.md:1). @@ -131,7 +131,7 @@ Rules: - MUST be side-effect free. - MUST NOT call HTTP. - MUST NOT call the API. -- Input: API Transport DTO +- Input: `Result` or `ApiDto` - Output: ViewData See [`BUILDERS.md`](docs/architecture/website/BUILDERS.md:1). @@ -140,6 +140,26 @@ Canonical placement in this repo: - `apps/website/lib/builders/view-data/**` +### 4.3 Result Type + +Definition: Type-safe error handling for all operations. + +Purpose: eliminate exceptions and provide explicit error paths. + +Rules: + +- All PageQueries return `Result` +- All Mutations return `Result` +- Use `ResultFactory.ok(value)` for success +- Use `ResultFactory.error(message)` for errors +- Never throw exceptions + +See [`Result.ts`](apps/website/lib/contracts/Result.ts:1). + +Canonical placement in this repo: + +- `apps/website/lib/contracts/Result.ts` + ### 4.3 Display Object Definition: deterministic, reusable, UI-only formatting/mapping logic. @@ -163,12 +183,14 @@ Canonical placement in this repo: ```text RSC page.tsx ↓ -PageQuery +PageQuery.execute() ↓ API client (infra) ↓ API Transport DTO ↓ +Result + ↓ ViewData Builder (lib/builders/view-data/) ↓ ViewData @@ -184,6 +206,8 @@ API client (useEffect) ↓ API Transport DTO ↓ +Result + ↓ ViewModel Builder (lib/builders/view-models/) ↓ ViewModel (lib/view-models/) @@ -206,68 +230,53 @@ Allowed: - client submits intent (FormData, button action) - server action performs UX validation -- **server action calls a service** (not API clients directly) -- service orchestrates API calls and business logic +- **server action calls a mutation** (not services directly) +- mutation orchestrates services for writes -**Server Actions must use Services:** +**Server Actions must use Mutations:** ```typescript -// ❌ WRONG - Direct API client usage +// ❌ WRONG - Direct service usage 'use server'; -import { AdminApiClient } from '@/lib/api/admin/AdminApiClient'; +import { AdminService } from '@/lib/services/admin/AdminService'; export async function updateUserStatus(userId: string, status: string) { - const apiClient = new AdminApiClient(...); - await apiClient.updateUserStatus(userId, status); // ❌ Should use service + const service = new AdminService(...); + await service.updateUserStatus(userId, status); // ❌ Should use mutation } -// ✅ CORRECT - Service usage +// ✅ CORRECT - Mutation usage 'use server'; -import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger'; -import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter'; -import { AdminApiClient } from '@/lib/api/admin/AdminApiClient'; -import { AdminService } from '@/lib/services/admin/AdminService'; +import { AdminUserMutation } from '@/lib/mutations/admin/AdminUserMutation'; import { revalidatePath } from 'next/cache'; export async function updateUserStatus(userId: string, status: string) { - try { - // Create infrastructure - const logger = new ConsoleLogger(); - const errorReporter = new EnhancedErrorReporter(logger, { - showUserNotifications: true, - logToConsole: true, - reportToExternal: process.env.NODE_ENV === 'production', - }); - - const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001'; - const apiClient = new AdminApiClient(baseUrl, errorReporter, logger); - const service = new AdminService(apiClient); - - // Use service - await service.updateUserStatus(userId, status); - - // Revalidate - revalidatePath('/admin/users'); - } catch (error) { - console.error('updateUserStatus failed:', error); + const mutation = new AdminUserMutation(); + const result = await mutation.updateUserStatus(userId, status); + + if (result.isErr()) { + console.error('updateUserStatus failed:', result.getError()); throw new Error('Failed to update user status'); } + + revalidatePath('/admin/users'); } ``` **Pattern**: -1. Server action creates infrastructure (logger, errorReporter, apiClient) -2. Server action creates service with infrastructure -3. Server action calls service method -4. Server action handles revalidation and returns +1. Server Action (thin wrapper) - handles framework concerns (revalidation) +2. Mutation (framework-agnostic) - creates infrastructure, calls service +3. Service (business logic) - orchestrates API calls +4. API Client (infrastructure) - makes HTTP requests +5. Result - type-safe error handling **Rationale**: -- Services orchestrate API calls (can grow to multiple calls) -- Keeps server actions consistent with PageQueries +- Mutations are framework-agnostic (can be tested without Next.js) +- Consistent pattern with PageQueries +- Type-safe error handling with Result - Makes infrastructure explicit and testable -- Services can add caching, retries, transformations -See [`FORM_SUBMISSION.md`](docs/architecture/website/FORM_SUBMISSION.md:1) and [`SERVICES.md`](docs/architecture/website/SERVICES.md:1). +See [`MUTATIONS.md`](docs/architecture/website/MUTATIONS.md:1) and [`SERVICES.md`](docs/architecture/website/SERVICES.md:1). ## 7) Authorization (strict) @@ -333,10 +342,11 @@ See [`WEBSITE_DI_RULES.md`](docs/architecture/website/WEBSITE_DI_RULES.md:1). 3. API Transport DTOs never reach Templates. 4. Templates accept ViewData only. 5. Page Queries do not format; they only compose. -6. ViewData Builders transform API DTO → ViewData (RSC). -7. ViewModel Builders transform API DTO → ViewModel (Client). -8. Builders are pure and deterministic. -9. Server Actions are the only write entry point. -10. Server Actions must use Mutations (not Services directly). -11. Mutations orchestrate Services for writes. -12. Authorization always belongs to the API. \ No newline at end of file +6. All operations return `Result` for type-safe error handling. +7. ViewData Builders transform API DTO → ViewData (RSC). +8. ViewModel Builders transform API DTO → ViewModel (Client). +9. Builders are pure and deterministic. +10. Server Actions are the only write entry point. +11. Server Actions must use Mutations (not Services directly). +12. Mutations orchestrate Services for writes. +13. Authorization always belongs to the API. \ No newline at end of file diff --git a/docs/architecture/website/WEBSITE_CONTRACT_WRITE_FLOW.md b/docs/architecture/website/WEBSITE_CONTRACT_WRITE_FLOW.md index 4866efd0e..fe9700596 100644 --- a/docs/architecture/website/WEBSITE_CONTRACT_WRITE_FLOW.md +++ b/docs/architecture/website/WEBSITE_CONTRACT_WRITE_FLOW.md @@ -39,14 +39,15 @@ import { AdminUserMutation } from '@/lib/mutations/admin/AdminUserMutation'; import { revalidatePath } from 'next/cache'; export async function updateUserStatus(userId: string, status: string) { - try { - const mutation = new AdminUserMutation(); - await mutation.updateUserStatus(userId, status); - revalidatePath('/admin/users'); - } catch (error) { - console.error('updateUserStatus failed:', error); + const mutation = new AdminUserMutation(); + const result = await mutation.updateUserStatus(userId, status); + + if (result.isErr()) { + console.error('updateUserStatus failed:', result.getError()); throw new Error('Failed to update user status'); } + + revalidatePath('/admin/users'); } ``` @@ -54,6 +55,8 @@ export async function updateUserStatus(userId: string, status: string) { ```typescript // lib/mutations/admin/AdminUserMutation.ts +import { Result, ResultFactory } from '@/lib/contracts/Result'; + export class AdminUserMutation { private service: AdminService; @@ -70,8 +73,13 @@ export class AdminUserMutation { this.service = new AdminService(apiClient); } - async updateUserStatus(userId: string, status: string): Promise { - await this.service.updateUserStatus(userId, status); + async updateUserStatus(userId: string, status: string): Promise> { + try { + await this.service.updateUserStatus(userId, status); + return ResultFactory.ok(undefined); + } catch (error) { + return ResultFactory.error('UPDATE_USER_STATUS_FAILED'); + } } } ``` @@ -82,11 +90,13 @@ export class AdminUserMutation { 2. **Mutation** (framework-agnostic) - creates infrastructure, calls service 3. **Service** (business logic) - orchestrates API calls 4. **API Client** (infrastructure) - makes HTTP requests +5. **Result** - type-safe error handling ### Rationale - **Framework independence**: Mutations can be tested without Next.js - **Consistency**: Mirrors PageQuery pattern for reads/writes +- **Type-safe errors**: Result pattern eliminates exceptions - **Migration ease**: Can switch frameworks without rewriting business logic - **Testability**: Can unit test mutations in isolation - **Reusability**: Can be called from other contexts (cron jobs, etc.) @@ -99,7 +109,7 @@ export class AdminUserMutation { | Location | `lib/page-queries/` | `lib/mutations/` | | Framework | Called from RSC | Called from Server Actions | | Infrastructure | Manual DI | Manual DI | -| Returns | Page DTO | void or result | +| Returns | `Result` | `Result` | | Revalidation | N/A | Server Action handles it | ### See Also