website refactor

This commit is contained in:
2026-01-13 02:42:58 +01:00
parent 38b25bafe1
commit b82736b24b
6 changed files with 661 additions and 251 deletions

View File

@@ -2,6 +2,8 @@
This document defines **Blockers** as UX-only prevention mechanisms in the website. This document defines **Blockers** as UX-only prevention mechanisms in the website.
**IMPORTANT**: Blockers are **optional UX helpers**. They are NOT enforced by ESLint rules and do not belong in the strict architecture contract.
Shared contract: [`docs/architecture/shared/BLOCKERS_AND_GUARDS.md`](docs/architecture/shared/BLOCKERS_AND_GUARDS.md:1) Shared contract: [`docs/architecture/shared/BLOCKERS_AND_GUARDS.md`](docs/architecture/shared/BLOCKERS_AND_GUARDS.md:1)
## 1) Definition ## 1) Definition
@@ -10,42 +12,64 @@ A Blocker is a website mechanism that prevents an action from being executed.
Blockers exist solely to improve UX and reduce unnecessary requests. Blockers exist solely to improve UX and reduce unnecessary requests.
Blockers are not security. **Blockers are not security.** They are best-effort helpers that can be bypassed.
## 2) Responsibilities ## 2) Purpose
Blockers MAY: Use Blockers to:
- Prevent multiple form submissions
- Debounce rapid button clicks
- Temporarily disable actions during loading
- Show/hide UI elements based on state
- prevent multiple submissions **Do NOT use Blockers for:**
- disable actions temporarily - Authorization checks
- debounce or throttle interactions - Security enforcement
- hide or disable UI elements - Permanent access control
- prevent navigation under certain conditions
Blockers MUST: ## 3) Placement
- be reversible Since Blockers are UX-only, they belong in:
- be local to the website - `apps/website/components/**` (component-specific blockers)
- be treated as best-effort helpers - `apps/website/hooks/**` (shared blocker hooks)
- `apps/website/utils/**` (blocker utilities)
## 3) Restrictions **NOT in `lib/`** - `lib/` is for business logic and architecture contracts.
Blockers MUST NOT: ## 4) Example
- enforce security ```typescript
- claim authorization // ✅ OK: Component-level blocker
- block access permanently export function useSubmitBlocker() {
- replace API Guards const [isSubmitting, setIsSubmitting] = useState(false);
- make assumptions about backend state
## 4) Common Blockers return {
isSubmitting,
block: () => setIsSubmitting(true),
release: () => setIsSubmitting(false),
};
}
- SubmitBlocker // Usage
- ThrottleBlocker const blocker = useSubmitBlocker();
- NavigationBlocker
- FeatureBlocker
## 5) Canonical placement async function handleSubmit() {
if (blocker.isSubmitting) return;
- `apps/website/lib/blockers/**` blocker.block();
await submitForm();
blocker.release();
}
```
## 5) Key Principle
**Blockers are optional.** The backend must never rely on them.
If a blocker prevents a submission, the backend should still:
- Validate the request
- Return appropriate errors
- Handle duplicate submissions gracefully
This is why Blockers don't need ESLint enforcement - they're just UX sugar.

View File

@@ -1,108 +1,64 @@
Command Models Command Models
This document defines Command Models as a first-class concept in the frontend architecture. This document defines Command Models as a concept for frontend form handling.
Command Models are UX-only write models used to collect, validate, and prepare user input
before it is sent to the backend as a Command DTO.
Command Models are not View Models and not Domain Models. **IMPORTANT**: Command Models are **optional UX helpers**. They are NOT enforced by ESLint rules and do not belong in the strict architecture contract.
## 1) Definition
Purpose Command Models (also called Form Models) are UX-only write models used to:
- Collect user input
- Track form state (dirty, touched, submitting)
- Perform basic UX validation
- Build Command DTOs for submission
A Form Model answers the question: **Command Models are NOT:**
- Domain models
- View models
- Security boundaries
- Required for the architecture
“What does the UI need in order to safely submit user input?” ## 2) Purpose
Command Models exist to: Use Command Models when:
• centralize form state - Forms have complex state management
• reduce logic inside components - Multiple fields need validation
• provide consistent client-side validation - You want to centralize form logic
• build Command DTOs explicitly - Building DTOs is non-trivial
**Don't use Command Models when:**
- Forms are simple (use React state directly)
- You're building a quick prototype
- The form logic is trivial
Core Rules ## 3) Core Rules
Command Models: If you use Command Models:
• exist only in the frontend
• are write-only (never reused for reads)
• are created per form
• are discarded after submission
Command Models MUST NOT: **They MUST:**
• contain business logic - Live in `components/` or `hooks/` (not `lib/`)
• enforce domain rules - Be write-only (never reused for reads)
• reference View Models - Be discarded after submission
• reference Domain Entities or Value Objects - Only perform UX validation
• be sent to the API directly
**They MUST NOT:**
- Contain business logic
- Enforce domain rules
- Reference View Models or Domain Entities
- Be sent to the API directly (use `toCommand()`)
Relationship to Other Models ## 4) Example
API DTO (read) → ViewModel → UI
UI Input → FormModel → Command DTO → API
• View Models are read-only
• Command Models are write-only
• No model is reused across read/write boundaries
Typical Responsibilities
A Form Model MAY:
• store field values
• track dirty / touched state
• perform basic UX validation
• expose isValid, canSubmit
• build a Command DTO
A Form Model MUST NOT:
• decide if an action is allowed
• perform authorization checks
• validate cross-aggregate rules
Validation Guidelines
Client-side validation is UX validation, not business validation.
Allowed validation examples:
• required fields
• min / max length
• email format
• numeric ranges
Forbidden validation examples:
• “user is not allowed”
• “league already exists”
• “quota exceeded”
Server validation is the source of truth.
Example: Simple Form Model (with class-validator)
import { IsEmail, IsNotEmpty, MinLength } from 'class-validator';
```typescript
// In your component file or hooks/
export class SignupFormModel { export class SignupFormModel {
@IsEmail()
email = ''; email = '';
@IsNotEmpty()
@MinLength(8)
password = ''; password = '';
isSubmitting = false; isSubmitting = false;
reset(): void { isValid(): boolean {
this.email = ''; // UX validation only
this.password = ''; return this.email.includes('@') && this.password.length >= 8;
} }
toCommand(): SignupCommandDto { toCommand(): SignupCommandDto {
@@ -113,44 +69,51 @@ export class SignupFormModel {
} }
} }
// Usage
export function SignupForm() {
const [form] = useState(() => new SignupFormModel());
async function handleSubmit() {
if (!form.isValid()) return;
Usage in UI Component form.isSubmitting = true;
const result = await signupMutation.mutateAsync(form.toCommand());
form.isSubmitting = false;
}
const form = useFormModel(SignupFormModel); return (
<form onSubmit={handleSubmit}>
async function onSubmit() { <input value={form.email} onChange={e => form.email = e.target.value} />
if (!form.isValid()) return; {/* ... */}
</form>
form.isSubmitting = true; );
await authService.signup(form.toCommand());
} }
```
The component: ## 5) Key Principle
• binds inputs to the Form Model
• reacts to validation state
• never builds DTOs manually
**Command Models are optional.** The backend must validate everything.
Testing If you don't use Command Models, that's fine! Just:
- Use React state for form data
- Let the backend handle validation
- Return clear errors from mutations
Command Models SHOULD be tested when they contain: ## 6) Comparison
• validation rules
• non-trivial state transitions
• command construction logic
Command Models do NOT need tests if they only hold fields without logic. | Approach | When to Use | Where |
|----------|-------------|-------|
| **React State** | Simple forms, prototypes | Component |
| **Command Model** | Complex forms, multi-step | Component/Hook |
| **View Model** | Read-only UI state | `lib/view-models/` |
| **Service** | Business orchestration | `lib/services/` |
## 7) Summary
Summary Command Models are **optional UX sugar**. They:
• Command Models are UX helpers for writes - Help organize complex forms
• They protect components from complexity - Are NOT required by the architecture
• They never replace backend validation - Don't need ESLint enforcement
• They never leak into read flows - Should stay in `components/` or `hooks/`
Command Models help users. Use them if they make your life easier. Skip them if they don't.
Use Cases protect the system.

View File

@@ -0,0 +1,281 @@
# Dependency Construction Architecture
## The Decision: Manual Construction (Not DI Container)
### Why Not Dependency Injection in RSC?
**Problem**: Next.js RSC pages run on every request, but DI containers are singletons.
**Risks with DI:**
1. **Data Leakage**: Singleton container could share auth tokens between users
2. **Request Context**: Can't inject request-specific data (user ID, auth token)
3. **Scoping Complexity**: Would need request-scoped containers
4. **Overhead**: DI adds complexity without benefit for RSC
**Example of the Problem:**
```typescript
// ❌ DON'T DO THIS - DI in RSC
export default async function DashboardPage() {
const container = ContainerManager.getInstance().getContainer();
const service = container.get<DashboardService>(DASHBOARD_SERVICE_TOKEN);
// Problem: What if DashboardService needs the user's auth token?
// The singleton container doesn't know which user this request is for!
}
```
### The Solution: Manual Construction
**Pattern:**
```
RSC Page
PageQuery (constructs Service)
Service (constructs API Client, Logger, ErrorReporter)
API Client (makes HTTP calls)
```
**Example:**
```typescript
// ✅ CORRECT: Manual construction in RSC
export default async function DashboardPage() {
const query = new DashboardPageQuery();
const result = await query.execute();
// ...
}
// In DashboardPageQuery
export class DashboardPageQuery {
async execute() {
const service = new DashboardService(); // Manual construction
return await service.getDashboardOverview();
}
}
// In DashboardService
export class DashboardService {
private apiClient: DashboardApiClient;
constructor() {
// Service creates its own dependencies
this.apiClient = new DashboardApiClient(
process.env.NEXT_PUBLIC_API_URL || '',
new ConsoleErrorReporter(),
new ConsoleLogger()
);
}
async getDashboardOverview() {
return await this.apiClient.getOverview();
}
}
```
### Benefits of Manual Construction
1. **Explicit**: Dependencies are clear and visible
2. **Simple**: No magic, no container, no configuration
3. **Safe**: No singleton issues, no data leakage
4. **Testable**: Easy to pass mocks in constructor
5. **Flexible**: Can change dependencies without affecting callers
### What About the Existing `lib/di/`?
The project has an Inversify DI system, but it's designed for:
**Client Components:**
```typescript
// ✅ OK in client components
export function UserDashboard() {
const service = useInject(DASHBOARD_SERVICE_TOKEN);
// ...
}
```
**Testing:**
```typescript
// ✅ OK in tests
const mockApiClient = new MockDashboardApiClient();
const service = new DashboardService(mockApiClient);
```
**RSC (PageQueries/Mutations):**
```typescript
// ❌ DON'T use DI container in RSC
// ✅ DO use manual construction
```
### ESLint Enforcement
The `services-no-instantiation` rule is **removed** because it was wrong.
**Correct Rules:**
-`clean-error-handling`: PageQueries must use Services, not API Clients
-`services-implement-contract`: Services must return Result types
-`lib-no-next-imports`: No Next.js imports in lib/ directory
**What the Rules Allow:**
```typescript
// In PageQuery - ALLOWED
const service = new DashboardService();
// In Service - ALLOWED
this.apiClient = new DashboardApiClient(...);
this.logger = new ConsoleLogger();
// In PageQuery - FORBIDDEN
const apiClient = new DashboardApiClient(...); // Use Service instead!
```
### Complete Example: Read Flow
```typescript
// apps/website/app/dashboard/page.tsx
export default async function DashboardPage() {
const query = new DashboardPageQuery();
const result = await query.execute();
if (result.isErr()) {
// Handle presentation errors
return <ErrorDashboard error={result.error} />;
}
return <DashboardTemplate viewData={result.value} />;
}
// apps/website/lib/page-queries/page-queries/DashboardPageQuery.ts
export class DashboardPageQuery implements PageQuery<DashboardViewData, void> {
async execute(): Promise<Result<DashboardViewData, DashboardPageError>> {
const service = new DashboardService();
const result = await service.getDashboardOverview();
if (result.isErr()) {
// Map domain error to presentation error
return Result.err(mapToPresentationError(result.error));
}
const viewData = DashboardViewDataBuilder.build(result.value);
return Result.ok(viewData);
}
}
// apps/website/lib/services/analytics/DashboardService.ts
export class DashboardService {
private apiClient: DashboardApiClient;
constructor() {
this.apiClient = new DashboardApiClient(
process.env.NEXT_PUBLIC_API_URL || '',
new ConsoleErrorReporter(),
new ConsoleLogger()
);
}
async getDashboardOverview(): Promise<Result<DashboardOverviewDTO, DomainError>> {
try {
const data = await this.apiClient.getOverview();
return Result.ok(data);
} catch (error) {
// Convert HTTP errors to domain errors
if (error instanceof HttpNotFoundError) {
return Result.err(new NotFoundError('Dashboard not found'));
}
return Result.err(new UnknownError('Failed to fetch dashboard'));
}
}
}
// apps/website/lib/api/dashboard/DashboardApiClient.ts
export class DashboardApiClient {
constructor(
private baseUrl: string,
private errorReporter: ErrorReporter,
private logger: Logger
) {}
async getOverview(): Promise<DashboardOverviewDTO> {
const response = await fetch(`${this.baseUrl}/dashboard/overview`);
if (!response.ok) {
if (response.status === 404) {
throw new HttpNotFoundError('Dashboard not found');
}
throw new HttpError(`HTTP ${response.status}`);
}
return response.json();
}
}
```
### Complete Example: Write Flow
```typescript
// apps/website/app/actions/userActions.ts
'use server';
export async function updateUserStatus(input: UpdateUserStatusInput) {
const mutation = new UpdateUserStatusMutation();
const result = await mutation.execute(input);
if (result.isErr()) {
return { success: false, error: result.error };
}
revalidatePath('/admin/users');
return { success: true };
}
// apps/website/lib/mutations/UpdateUserStatusMutation.ts
export class UpdateUserStatusMutation implements Mutation<UpdateUserStatusInput, void> {
async execute(input: UpdateUserStatusInput): Promise<Result<void, MutationError>> {
const service = new UserService();
const result = await service.updateUserStatus(input.userId, input.status);
if (result.isErr()) {
return Result.err(mapToMutationError(result.error));
}
return Result.ok(undefined);
}
}
// apps/website/lib/services/user/UserService.ts
export class UserService {
private apiClient: UserApiClient;
constructor() {
this.apiClient = new UserApiClient(
process.env.NEXT_PUBLIC_API_URL || '',
new ConsoleErrorReporter(),
new ConsoleLogger()
);
}
async updateUserStatus(userId: string, status: string): Promise<Result<void, DomainError>> {
try {
await this.apiClient.updateUserStatus(userId, status);
return Result.ok(undefined);
} catch (error) {
if (error instanceof HttpForbiddenError) {
return Result.err(new ForbiddenError('Insufficient permissions'));
}
return Result.err(new UnknownError('Failed to update user'));
}
}
}
```
### Summary
| Aspect | RSC (PageQueries/Mutations) | Client Components |
|--------|----------------------------|-------------------|
| **Construction** | Manual (`new Service()`) | Manual or DI hooks |
| **DI Container** | ❌ Never use | ✅ Can use |
| **Dependencies** | Service creates its own | Injected or manual |
| **Testability** | Pass mocks to constructor | Use DI mocks |
| **Complexity** | Low | Medium |
**Golden Rule**: In RSC, always use manual construction. It's simpler, safer, and more explicit.

View File

@@ -62,11 +62,15 @@ class HttpServerError extends HttpError {}
### Layer 2: Service (Technical → Domain Errors) ### Layer 2: Service (Technical → Domain Errors)
The Service catches HTTP errors and converts them to domain errors: The Service creates its own dependencies and converts HTTP errors to domain errors.
**See**: [DEPENDENCY_CONSTRUCTION.md](./DEPENDENCY_CONSTRUCTION.md) for why Services create their own dependencies.
```typescript ```typescript
// apps/website/lib/services/dashboard/DashboardService.ts // apps/website/lib/services/dashboard/DashboardService.ts
import { DashboardApiClient } from '@/lib/api/dashboard/DashboardApiClient'; import { DashboardApiClient } from '@/lib/api/dashboard/DashboardApiClient';
import { ConsoleErrorReporter } from '@/lib/infrastructure/logging/ConsoleErrorReporter';
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
export type DashboardServiceError = export type DashboardServiceError =
| { type: 'notFound'; message: string } | { type: 'notFound'; message: string }
@@ -76,7 +80,17 @@ export type DashboardServiceError =
| { type: 'unknown'; message: string }; | { type: 'unknown'; message: string };
export class DashboardService { export class DashboardService {
constructor(private apiClient: DashboardApiClient) {} private apiClient: DashboardApiClient;
constructor() {
// Service creates its own dependencies
const baseUrl = process.env.NEXT_PUBLIC_API_URL || '';
this.apiClient = new DashboardApiClient(
baseUrl,
new ConsoleErrorReporter(),
new ConsoleLogger()
);
}
async getDashboardOverview(): Promise<Result<DashboardStats, DashboardServiceError>> { async getDashboardOverview(): Promise<Result<DashboardStats, DashboardServiceError>> {
try { try {
@@ -105,9 +119,17 @@ export class DashboardService {
} }
``` ```
**Key Points:**
- ✅ Service creates its own API Client
- ✅ Service creates its own Logger and ErrorReporter
- ✅ Catches HTTP errors and converts to domain errors
- ✅ Returns Result type
### Layer 3: PageQuery (Domain → Presentation Errors) ### Layer 3: PageQuery (Domain → Presentation Errors)
PageQueries use Services and map domain errors to presentation errors: PageQueries use Services and map domain errors to presentation errors.
**See**: [DEPENDENCY_CONSTRUCTION.md](./DEPENDENCY_CONSTRUCTION.md) for why we use manual construction.
```typescript ```typescript
// apps/website/lib/page-queries/page-queries/DashboardPageQuery.ts // apps/website/lib/page-queries/page-queries/DashboardPageQuery.ts
@@ -118,12 +140,8 @@ type DashboardPageError = 'notFound' | 'redirect' | 'DASHBOARD_FETCH_FAILED' | '
export class DashboardPageQuery implements PageQuery<DashboardViewData, void> { export class DashboardPageQuery implements PageQuery<DashboardViewData, void> {
async execute(): Promise<Result<DashboardViewData, DashboardPageError>> { async execute(): Promise<Result<DashboardViewData, DashboardPageError>> {
// Manual wiring // Manual construction: Service creates its own dependencies
const errorReporter = new ConsoleErrorReporter(); const dashboardService = new DashboardService();
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 // Call service
const serviceResult = await dashboardService.getDashboardOverview(); const serviceResult = await dashboardService.getDashboardOverview();
@@ -154,6 +172,13 @@ export class DashboardPageQuery implements PageQuery<DashboardViewData, void> {
} }
``` ```
**Key Points:**
- ✅ PageQuery constructs only the Service
- ✅ Service handles its own dependencies (API Client, Logger, etc.)
- ❌ No API Client instantiation in PageQuery
- ✅ Map domain errors to presentation errors
- ✅ Transform API DTO to ViewData using Builder
### Layer 4: RSC Page (Presentation → User) ### Layer 4: RSC Page (Presentation → User)
The RSC page handles presentation errors: The RSC page handles presentation errors:
@@ -282,6 +307,10 @@ export class UserApiClient {
```typescript ```typescript
// apps/website/lib/services/user/UserService.ts // apps/website/lib/services/user/UserService.ts
import { UserApiClient } from '@/lib/api/user/UserApiClient';
import { ConsoleErrorReporter } from '@/lib/infrastructure/logging/ConsoleErrorReporter';
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
export type UserServiceError = export type UserServiceError =
| { type: 'notFound'; message: string } | { type: 'notFound'; message: string }
| { type: 'forbidden'; message: string } | { type: 'forbidden'; message: string }
@@ -289,7 +318,17 @@ export type UserServiceError =
| { type: 'serverError'; message: string }; | { type: 'serverError'; message: string };
export class UserService { export class UserService {
constructor(private apiClient: UserApiClient) {} private apiClient: UserApiClient;
constructor() {
// Service creates its own dependencies
const baseUrl = process.env.NEXT_PUBLIC_API_URL || '';
this.apiClient = new UserApiClient(
baseUrl,
new ConsoleErrorReporter(),
new ConsoleLogger()
);
}
async updateUserStatus(userId: string, status: string): Promise<Result<void, UserServiceError>> { async updateUserStatus(userId: string, status: string): Promise<Result<void, UserServiceError>> {
try { try {
@@ -314,6 +353,12 @@ export class UserService {
} }
``` ```
**Key Points:**
- ✅ Service creates its own API Client
- ✅ Service creates its own Logger and ErrorReporter
- ✅ Catches HTTP errors and converts to domain errors
- ✅ Returns Result type
### Layer 3: Mutation (Domain → Presentation Errors) ### Layer 3: Mutation (Domain → Presentation Errors)
```typescript ```typescript
@@ -358,18 +403,11 @@ export class UpdateUserStatusMutation implements Mutation<UpdateUserStatusInput,
'use server'; 'use server';
import { UpdateUserStatusMutation } from '@/lib/mutations/UpdateUserStatusMutation'; 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'; import { revalidatePath } from 'next/cache';
export async function updateUserStatus(input: UpdateUserStatusInput) { export async function updateUserStatus(input: UpdateUserStatusInput) {
// Manual wiring // Manual construction: Mutation creates Service, Service creates dependencies
const errorReporter = new ConsoleErrorReporter(); const mutation = new UpdateUserStatusMutation();
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); const result = await mutation.execute(input);
@@ -384,6 +422,12 @@ export async function updateUserStatus(input: UpdateUserStatusInput) {
} }
``` ```
**Key Points:**
- ✅ Server Action constructs only the Mutation
- ✅ Mutation constructs the Service
- ✅ Service constructs its own dependencies
- ✅ No manual wiring needed
### Layer 5: Client Component (Handles Result) ### Layer 5: Client Component (Handles Result)
```typescript ```typescript

View File

@@ -37,15 +37,38 @@ apps/website/lib/services/
### Service Definition ### Service Definition
Services create their own dependencies:
```typescript ```typescript
import { AdminApiClient } from '@/lib/api/admin/AdminApiClient'; import { AdminApiClient } from '@/lib/api/admin/AdminApiClient';
import type { UserDto } from '@/lib/api/admin/AdminApiClient'; import type { UserDto } from '@/lib/api/admin/AdminApiClient';
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
import { ConsoleErrorReporter } from '@/lib/infrastructure/logging/ConsoleErrorReporter';
export class AdminService { export class AdminService {
constructor(private readonly apiClient: AdminApiClient) {} private apiClient: AdminApiClient;
async updateUserStatus(userId: string, status: string): Promise<UserDto> { constructor() {
return this.apiClient.updateUserStatus(userId, status); // Service creates its own dependencies
const baseUrl = process.env.NEXT_PUBLIC_API_URL || '';
this.apiClient = new AdminApiClient(
baseUrl,
new ConsoleErrorReporter(),
new ConsoleLogger()
);
}
async updateUserStatus(userId: string, status: string): Promise<Result<UserDto, DomainError>> {
try {
const result = await this.apiClient.updateUserStatus(userId, status);
return Result.ok(result);
} catch (error) {
// Convert HTTP errors to domain errors
if (error instanceof HttpForbiddenError) {
return Result.err(new ForbiddenError('Insufficient permissions'));
}
return Result.err(new UnknownError('Failed to update user'));
}
} }
} }
``` ```
@@ -54,101 +77,113 @@ export class AdminService {
```typescript ```typescript
// apps/website/lib/page-queries/AdminDashboardPageQuery.ts // apps/website/lib/page-queries/AdminDashboardPageQuery.ts
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 { AdminService } from '@/lib/services/admin/AdminService';
import { AdminDashboardViewDataBuilder } from '@/lib/builders/view-data/AdminDashboardViewDataBuilder';
export class AdminDashboardPageQuery { export class AdminDashboardPageQuery implements PageQuery<AdminDashboardViewData, void> {
async execute(): Promise<PageQueryResult<AdminDashboardPageDto>> { async execute(): Promise<Result<AdminDashboardViewData, DashboardPageError>> {
// Create infrastructure // Manual construction: Service creates its own dependencies
const logger = new ConsoleLogger(); const service = new AdminService();
const errorReporter = new EnhancedErrorReporter(logger, {...});
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 // Use service
const stats = await service.getDashboardStats(); const result = await service.getDashboardStats();
// Transform to Page DTO if (result.isErr()) {
return { status: 'ok', dto: transformToPageDto(stats) }; return Result.err(mapToPresentationError(result.error));
}
// Transform to ViewData using Builder
const viewData = AdminDashboardViewDataBuilder.build(result.value);
return Result.ok(viewData);
} }
} }
``` ```
### Usage in Server Actions (Writes) ### Usage in Mutations (Writes)
```typescript ```typescript
// apps/website/app/admin/actions.ts // apps/website/lib/mutations/UpdateUserStatusMutation.ts
'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 { AdminService } from '@/lib/services/admin/AdminService';
import { revalidatePath } from 'next/cache';
export async function updateUserStatus(userId: string, status: string): Promise<void> { export class UpdateUserStatusMutation implements Mutation<UpdateUserStatusInput, void> {
try { async execute(input: UpdateUserStatusInput): Promise<Result<void, MutationError>> {
// Create infrastructure // Manual construction: Service creates its own dependencies
const logger = new ConsoleLogger(); const service = new AdminService();
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 result = await service.updateUserStatus(input.userId, input.status);
const apiClient = new AdminApiClient(baseUrl, errorReporter, logger);
const service = new AdminService(apiClient);
// Use service (NOT API client directly) if (result.isErr()) {
await service.updateUserStatus(userId, status); return Result.err(mapToMutationError(result.error));
}
// Revalidate return Result.ok(undefined);
revalidatePath('/admin/users');
} catch (error) {
console.error('updateUserStatus failed:', error);
throw new Error('Failed to update user status');
} }
} }
``` ```
## Infrastructure Concerns ### Usage in Server Actions
**Where should logging/error reporting live?** ```typescript
// app/admin/actions.ts
'use server';
In the current architecture, **server actions and PageQueries create infrastructure**. This is acceptable because: import { UpdateUserStatusMutation } from '@/lib/mutations/UpdateUserStatusMutation';
1. Next.js serverless functions are stateless import { revalidatePath } from 'next/cache';
2. Each request needs fresh infrastructure
3. Manual DI is clearer than magic containers
**Key principle**: Services orchestrate, they don't create infrastructure. export async function updateUserStatus(input: UpdateUserStatusInput) {
// Manual construction: Mutation creates Service
const mutation = new UpdateUserStatusMutation();
const result = await mutation.execute(input);
if (result.isErr()) {
return { success: false, error: result.error };
}
revalidatePath('/admin/users');
return { success: true };
}
```
## Dependency Chain ## Dependency Chain
``` ```
Server Action / PageQuery RSC Page / Server Action
↓ (creates infrastructure) ↓ (manual construction)
PageQuery / Mutation
↓ (manual construction)
Service Service
↓ (orchestrates) ↓ (creates own dependencies)
API Client API Client
↓ (makes HTTP calls) ↓ (makes HTTP calls)
API API
``` ```
**Key Principle**: Each layer manually constructs the next layer. Services create their own infrastructure (API Client, Logger, ErrorReporter).
## Why Manual Construction?
**See**: [DEPENDENCY_CONSTRUCTION.md](./DEPENDENCY_CONSTRUCTION.md)
**Summary**:
- ✅ Explicit and clear
- ✅ No singleton issues
- ✅ No request-scoping problems
- ✅ Easy to test (pass mocks to constructor)
- ✅ Works perfectly with Next.js RSC
- ❌ No DI container needed
## Naming ## Naming
- Service classes: `*Service` - Service classes: `*Service`
- Service methods: Return DTOs (not ViewModels) - Service methods: Return `Result<T, DomainError>`
- Variable names: `apiDto`, `pageDto` (never just `dto`) - Variable names: `apiDto`, `viewData` (never just `dto`)
## Comparison with Other Layers ## Comparison with Other Layers
| Layer | Purpose | Example | | Layer | Purpose | Example |
|-------|---------|---------| |-------|---------|---------|
| **Website Service** | Orchestrate API calls | `AdminService` | | **Website Service** | Orchestrate API calls, handle errors | `AdminService` |
| **API Client** | HTTP infrastructure | `AdminApiClient` | | **API Client** | HTTP infrastructure | `AdminApiClient` |
| **Core Use Case** | Business rules | `CreateLeagueUseCase` | | **Core Use Case** | Business rules | `CreateLeagueUseCase` |
| **Domain Service** | Cross-entity logic | `StrengthOfFieldCalculator` | | **Domain Service** | Cross-entity logic | `StrengthOfFieldCalculator` |
@@ -170,8 +205,13 @@ class AdminService {
```typescript ```typescript
// CORRECT // CORRECT
class AdminService { class AdminService {
async getUser(userId: string): Promise<UserDto> { async getUser(userId: string): Promise<Result<UserDto, DomainError>> {
return this.apiClient.getUser(userId); // ✅ DTOs are fine try {
const dto = await this.apiClient.getUser(userId);
return Result.ok(dto); // ✅ DTOs are fine
} catch (error) {
return Result.err(new NotFoundError('User not found'));
}
} }
} }
``` ```
@@ -191,8 +231,13 @@ class AdminService {
```typescript ```typescript
// CORRECT // CORRECT
class AdminService { class AdminService {
async getUser(userId: string): Promise<UserDto> { async getUser(userId: string): Promise<Result<UserDto, DomainError>> {
return this.apiClient.getUser(userId); try {
const dto = await this.apiClient.getUser(userId);
return Result.ok(dto);
} catch (error) {
return Result.err(new NotFoundError('User not found'));
}
} }
} }
// Business logic in core use case or page query // Business logic in core use case or page query
@@ -208,24 +253,48 @@ export async function updateUserStatus(userId: string, status: string) {
} }
``` ```
**Correct**: Server action uses service **Correct**: Server action uses Mutation
```typescript ```typescript
// CORRECT // CORRECT
'use server'; 'use server';
export async function updateUserStatus(userId: string, status: string) { export async function updateUserStatus(input: UpdateUserStatusInput) {
const apiClient = new AdminApiClient(...); const mutation = new UpdateUserStatusMutation();
const service = new AdminService(apiClient); const result = await mutation.execute(input);
await service.updateUserStatus(userId, status); // ✅ Uses service // ...
}
```
**Wrong**: PageQuery creates API Client
```typescript
// WRONG
export class DashboardPageQuery {
async execute() {
const apiClient = new DashboardApiClient(...); // ❌ Should use Service
return await apiClient.getOverview();
}
}
```
**Correct**: PageQuery uses Service
```typescript
// CORRECT
export class DashboardPageQuery {
async execute() {
const service = new DashboardService(); // ✅ Service creates API Client
return await service.getDashboardOverview();
}
} }
``` ```
## Summary ## Summary
Website services are **thin orchestration wrappers** around API clients. They handle infrastructure concerns so that PageQueries and Server Actions can focus on composition and validation. Website services are **thin orchestration wrappers** that create their own dependencies and handle error conversion.
**Key principles**: **Key principles**:
1. Services orchestrate API calls 1. Services create their own dependencies (API Client, Logger, ErrorReporter)
2. Server actions/PageQueries create infrastructure 2. Services return `Result<T, DomainError>`
3. Services don't create ViewModels 3. Services convert HTTP errors to Domain errors
4. Services don't contain business rules 4. Services don't create ViewModels
5. **Server actions MUST use services, not API clients directly** 5. ❌ Services don't contain business rules
6. ✅ PageQueries/Mutations use Services, not API Clients directly
7. ✅ Manual construction (no DI container in RSC)

View File

@@ -148,10 +148,11 @@ Purpose: eliminate exceptions and provide explicit error paths.
Rules: Rules:
- All PageQueries return `Result<ApiDto, string>` - All PageQueries return `Result<ViewData, PresentationError>`
- All Mutations return `Result<void, string>` - All Mutations return `Result<void, MutationError>`
- Use `ResultFactory.ok(value)` for success - All Services return `Result<ApiDto, DomainError>`
- Use `ResultFactory.error(message)` for errors - Use `Result.ok(value)` for success
- Use `Result.err(error)` for errors
- Never throw exceptions - Never throw exceptions
See [`Result.ts`](apps/website/lib/contracts/Result.ts:1). See [`Result.ts`](apps/website/lib/contracts/Result.ts:1).
@@ -183,13 +184,19 @@ Canonical placement in this repo:
```text ```text
RSC page.tsx RSC page.tsx
PageQuery.execute() PageQuery (manual construction)
API client (infra) Service (creates own API Client, Logger, ErrorReporter)
API Client (makes HTTP calls)
API Transport DTO API Transport DTO
Result<ApiDto, string> Result<ApiDto, DomainError>
PageQuery (maps DomainError → PresentationError)
Result<ViewData, PresentationError>
ViewData Builder (lib/builders/view-data/) ViewData Builder (lib/builders/view-data/)
@@ -198,6 +205,13 @@ ViewData
Template Template
``` ```
**Key Points:**
- PageQuery constructs Service
- Service creates its own dependencies
- Service returns Result<ApiDto, DomainError>
- PageQuery maps errors to presentation layer
- Builder transforms API DTO to ViewData
### Client Components ### Client Components
```text ```text
Client Component Client Component
@@ -241,40 +255,55 @@ Allowed:
import { AdminService } from '@/lib/services/admin/AdminService'; import { AdminService } from '@/lib/services/admin/AdminService';
export async function updateUserStatus(userId: string, status: string) { export async function updateUserStatus(userId: string, status: string) {
const service = new AdminService(...); const service = new AdminService();
await service.updateUserStatus(userId, status); // ❌ Should use mutation await service.updateUserStatus(userId, status); // ❌ Should use mutation
} }
// ✅ CORRECT - Mutation usage // ✅ CORRECT - Mutation usage
'use server'; 'use server';
import { AdminUserMutation } from '@/lib/mutations/admin/AdminUserMutation'; import { UpdateUserStatusMutation } from '@/lib/mutations/UpdateUserStatusMutation';
import { revalidatePath } from 'next/cache'; import { revalidatePath } from 'next/cache';
export async function updateUserStatus(userId: string, status: string) { export async function updateUserStatus(input: UpdateUserStatusInput) {
const mutation = new AdminUserMutation(); const mutation = new UpdateUserStatusMutation();
const result = await mutation.updateUserStatus(userId, status); const result = await mutation.execute(input);
if (result.isErr()) { if (result.isErr()) {
console.error('updateUserStatus failed:', result.getError()); console.error('updateUserStatus failed:', result.error);
throw new Error('Failed to update user status'); return { success: false, error: result.error };
} }
revalidatePath('/admin/users'); revalidatePath('/admin/users');
return { success: true };
} }
``` ```
**Pattern**: **Pattern**:
1. Server Action (thin wrapper) - handles framework concerns (revalidation) 1. **Server Action** (thin wrapper) - handles framework concerns (revalidation, returns to client)
2. Mutation (framework-agnostic) - creates infrastructure, calls service 2. **Mutation** (framework-agnostic) - constructs Service, calls service methods
3. Service (business logic) - orchestrates API calls 3. **Service** - constructs own dependencies (API Client, Logger), returns Result
4. API Client (infrastructure) - makes HTTP requests 4. **API Client** (infrastructure) - makes HTTP requests, throws HTTP errors
5. Result - type-safe error handling 5. **Result** - type-safe error handling at every layer
**Dependency Flow**:
```
Server Action
↓ (constructs)
Mutation
↓ (constructs)
Service (creates API Client, Logger, ErrorReporter)
↓ (calls)
API Client
↓ (HTTP)
Backend API
```
**Rationale**: **Rationale**:
- Mutations are framework-agnostic (can be tested without Next.js) - Mutations are framework-agnostic (can be tested without Next.js)
- Consistent pattern with PageQueries - Consistent pattern with PageQueries
- Type-safe error handling with Result - Type-safe error handling with Result
- Makes infrastructure explicit and testable - Makes infrastructure explicit and testable
- Manual construction (no DI container issues)
See [`MUTATIONS.md`](docs/architecture/website/MUTATIONS.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).