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.
**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)
## 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 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
- disable actions temporarily
- debounce or throttle interactions
- hide or disable UI elements
- prevent navigation under certain conditions
**Do NOT use Blockers for:**
- Authorization checks
- Security enforcement
- Permanent access control
Blockers MUST:
## 3) Placement
- be reversible
- be local to the website
- be treated as best-effort helpers
Since Blockers are UX-only, they belong in:
- `apps/website/components/**` (component-specific blockers)
- `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
- claim authorization
- block access permanently
- replace API Guards
- make assumptions about backend state
```typescript
// ✅ OK: Component-level blocker
export function useSubmitBlocker() {
const [isSubmitting, setIsSubmitting] = useState(false);
return {
isSubmitting,
block: () => setIsSubmitting(true),
release: () => setIsSubmitting(false),
};
}
## 4) Common Blockers
// Usage
const blocker = useSubmitBlocker();
- SubmitBlocker
- ThrottleBlocker
- NavigationBlocker
- FeatureBlocker
async function handleSubmit() {
if (blocker.isSubmitting) return;
blocker.block();
await submitForm();
blocker.release();
}
```
## 5) Canonical placement
## 5) Key Principle
- `apps/website/lib/blockers/**`
**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
This document defines Command Models as a first-class concept in the frontend architecture.
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.
This document defines Command Models as a concept for frontend form handling.
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:
• centralize form state
• reduce logic inside components
• provide consistent client-side validation
• build Command DTOs explicitly
Use Command Models when:
- Forms have complex state management
- Multiple fields need validation
- You want to centralize form logic
- 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:
• exist only in the frontend
• are write-only (never reused for reads)
• are created per form
• are discarded after submission
If you use Command Models:
Command Models MUST NOT:
• contain business logic
• enforce domain rules
• reference View Models
• reference Domain Entities or Value Objects
• be sent to the API directly
**They MUST:**
- Live in `components/` or `hooks/` (not `lib/`)
- Be write-only (never reused for reads)
- Be discarded after submission
- Only perform UX validation
**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
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';
## 4) Example
```typescript
// In your component file or hooks/
export class SignupFormModel {
@IsEmail()
email = '';
@IsNotEmpty()
@MinLength(8)
password = '';
isSubmitting = false;
reset(): void {
this.email = '';
this.password = '';
isValid(): boolean {
// UX validation only
return this.email.includes('@') && this.password.length >= 8;
}
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;
form.isSubmitting = true;
const result = await signupMutation.mutateAsync(form.toCommand());
form.isSubmitting = false;
}
Usage in UI Component
const form = useFormModel(SignupFormModel);
async function onSubmit() {
if (!form.isValid()) return;
form.isSubmitting = true;
await authService.signup(form.toCommand());
return (
<form onSubmit={handleSubmit}>
<input value={form.email} onChange={e => form.email = e.target.value} />
{/* ... */}
</form>
);
}
```
The component:
• binds inputs to the Form Model
• reacts to validation state
• never builds DTOs manually
## 5) Key Principle
**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:
• validation rules
• non-trivial state transitions
• command construction logic
## 6) Comparison
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 UX helpers for writes
• They protect components from complexity
• They never replace backend validation
• They never leak into read flows
Command Models are **optional UX sugar**. They:
- Help organize complex forms
- Are NOT required by the architecture
- Don't need ESLint enforcement
- Should stay in `components/` or `hooks/`
Command Models help users.
Use Cases protect the system.
Use them if they make your life easier. Skip them if they don't.

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)
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
// apps/website/lib/services/dashboard/DashboardService.ts
import { DashboardApiClient } from '@/lib/api/dashboard/DashboardApiClient';
import { ConsoleErrorReporter } from '@/lib/infrastructure/logging/ConsoleErrorReporter';
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
export type DashboardServiceError =
| { type: 'notFound'; message: string }
@@ -76,7 +80,17 @@ export type DashboardServiceError =
| { type: 'unknown'; message: string };
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>> {
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)
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
// 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> {
async execute(): Promise<Result<DashboardViewData, DashboardPageError>> {
// 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);
// Manual construction: Service creates its own dependencies
const dashboardService = new DashboardService();
// Call service
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)
The RSC page handles presentation errors:
@@ -282,6 +307,10 @@ export class UserApiClient {
```typescript
// 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 =
| { type: 'notFound'; message: string }
| { type: 'forbidden'; message: string }
@@ -289,7 +318,17 @@ export type UserServiceError =
| { type: 'serverError'; message: string };
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>> {
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)
```typescript
@@ -358,18 +403,11 @@ export class UpdateUserStatusMutation implements Mutation<UpdateUserStatusInput,
'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);
// Manual construction: Mutation creates Service, Service creates dependencies
const mutation = new UpdateUserStatusMutation();
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)
```typescript

View File

@@ -37,15 +37,38 @@ apps/website/lib/services/
### Service Definition
Services create their own dependencies:
```typescript
import { AdminApiClient } 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 {
constructor(private readonly apiClient: AdminApiClient) {}
private apiClient: AdminApiClient;
async updateUserStatus(userId: string, status: string): Promise<UserDto> {
return this.apiClient.updateUserStatus(userId, status);
constructor() {
// 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
// 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 { AdminDashboardViewDataBuilder } from '@/lib/builders/view-data/AdminDashboardViewDataBuilder';
export class AdminDashboardPageQuery {
async execute(): Promise<PageQueryResult<AdminDashboardPageDto>> {
// Create infrastructure
const logger = new ConsoleLogger();
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);
export class AdminDashboardPageQuery implements PageQuery<AdminDashboardViewData, void> {
async execute(): Promise<Result<AdminDashboardViewData, DashboardPageError>> {
// Manual construction: Service creates its own dependencies
const service = new AdminService();
// Use service
const stats = await service.getDashboardStats();
const result = await service.getDashboardStats();
// Transform to Page DTO
return { status: 'ok', dto: transformToPageDto(stats) };
if (result.isErr()) {
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
// apps/website/app/admin/actions.ts
'use server';
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter';
import { AdminApiClient } from '@/lib/api/admin/AdminApiClient';
// apps/website/lib/mutations/UpdateUserStatusMutation.ts
import { AdminService } from '@/lib/services/admin/AdminService';
import { revalidatePath } from 'next/cache';
export async function updateUserStatus(userId: string, status: string): Promise<void> {
try {
// Create infrastructure
const logger = new ConsoleLogger();
const errorReporter = new EnhancedErrorReporter(logger, {
showUserNotifications: true,
logToConsole: true,
reportToExternal: process.env.NODE_ENV === 'production',
});
export class UpdateUserStatusMutation implements Mutation<UpdateUserStatusInput, void> {
async execute(input: UpdateUserStatusInput): Promise<Result<void, MutationError>> {
// Manual construction: Service creates its own dependencies
const service = new AdminService();
const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001';
const apiClient = new AdminApiClient(baseUrl, errorReporter, logger);
const service = new AdminService(apiClient);
const result = await service.updateUserStatus(input.userId, input.status);
// Use service (NOT API client directly)
await service.updateUserStatus(userId, status);
if (result.isErr()) {
return Result.err(mapToMutationError(result.error));
}
// Revalidate
revalidatePath('/admin/users');
} catch (error) {
console.error('updateUserStatus failed:', error);
throw new Error('Failed to update user status');
return Result.ok(undefined);
}
}
```
## 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:
1. Next.js serverless functions are stateless
2. Each request needs fresh infrastructure
3. Manual DI is clearer than magic containers
import { UpdateUserStatusMutation } from '@/lib/mutations/UpdateUserStatusMutation';
import { revalidatePath } from 'next/cache';
**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
```
Server Action / PageQuery
↓ (creates infrastructure)
RSC Page / Server Action
↓ (manual construction)
PageQuery / Mutation
↓ (manual construction)
Service
↓ (orchestrates)
↓ (creates own dependencies)
API Client
↓ (makes HTTP calls)
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
- Service classes: `*Service`
- Service methods: Return DTOs (not ViewModels)
- Variable names: `apiDto`, `pageDto` (never just `dto`)
- Service methods: Return `Result<T, DomainError>`
- Variable names: `apiDto`, `viewData` (never just `dto`)
## Comparison with Other Layers
| Layer | Purpose | Example |
|-------|---------|---------|
| **Website Service** | Orchestrate API calls | `AdminService` |
| **Website Service** | Orchestrate API calls, handle errors | `AdminService` |
| **API Client** | HTTP infrastructure | `AdminApiClient` |
| **Core Use Case** | Business rules | `CreateLeagueUseCase` |
| **Domain Service** | Cross-entity logic | `StrengthOfFieldCalculator` |
@@ -170,8 +205,13 @@ class AdminService {
```typescript
// CORRECT
class AdminService {
async getUser(userId: string): Promise<UserDto> {
return this.apiClient.getUser(userId); // ✅ DTOs are fine
async getUser(userId: string): Promise<Result<UserDto, DomainError>> {
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
// CORRECT
class AdminService {
async getUser(userId: string): Promise<UserDto> {
return this.apiClient.getUser(userId);
async getUser(userId: string): Promise<Result<UserDto, DomainError>> {
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
@@ -208,24 +253,48 @@ export async function updateUserStatus(userId: string, status: string) {
}
```
**Correct**: Server action uses service
**Correct**: Server action uses Mutation
```typescript
// CORRECT
'use server';
export async function updateUserStatus(userId: string, status: string) {
const apiClient = new AdminApiClient(...);
const service = new AdminService(apiClient);
await service.updateUserStatus(userId, status); // ✅ Uses service
export async function updateUserStatus(input: UpdateUserStatusInput) {
const mutation = new UpdateUserStatusMutation();
const result = await mutation.execute(input);
// ...
}
```
**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
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**:
1. Services orchestrate API calls
2. Server actions/PageQueries create infrastructure
3. Services don't create ViewModels
4. Services don't contain business rules
5. **Server actions MUST use services, not API clients directly**
1. Services create their own dependencies (API Client, Logger, ErrorReporter)
2. Services return `Result<T, DomainError>`
3. Services convert HTTP errors to Domain errors
4. Services don't create ViewModels
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:
- All PageQueries return `Result<ApiDto, string>`
- All Mutations return `Result<void, string>`
- Use `ResultFactory.ok(value)` for success
- Use `ResultFactory.error(message)` for errors
- All PageQueries return `Result<ViewData, PresentationError>`
- All Mutations return `Result<void, MutationError>`
- All Services return `Result<ApiDto, DomainError>`
- Use `Result.ok(value)` for success
- Use `Result.err(error)` for errors
- Never throw exceptions
See [`Result.ts`](apps/website/lib/contracts/Result.ts:1).
@@ -183,13 +184,19 @@ Canonical placement in this repo:
```text
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
Result<ApiDto, string>
Result<ApiDto, DomainError>
PageQuery (maps DomainError → PresentationError)
Result<ViewData, PresentationError>
ViewData Builder (lib/builders/view-data/)
@@ -198,6 +205,13 @@ ViewData
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
```text
Client Component
@@ -241,40 +255,55 @@ Allowed:
import { AdminService } from '@/lib/services/admin/AdminService';
export async function updateUserStatus(userId: string, status: string) {
const service = new AdminService(...);
const service = new AdminService();
await service.updateUserStatus(userId, status); // ❌ Should use mutation
}
// ✅ CORRECT - Mutation usage
'use server';
import { AdminUserMutation } from '@/lib/mutations/admin/AdminUserMutation';
import { UpdateUserStatusMutation } from '@/lib/mutations/UpdateUserStatusMutation';
import { revalidatePath } from 'next/cache';
export async function updateUserStatus(userId: string, status: string) {
const mutation = new AdminUserMutation();
const result = await mutation.updateUserStatus(userId, status);
export async function updateUserStatus(input: UpdateUserStatusInput) {
const mutation = new UpdateUserStatusMutation();
const result = await mutation.execute(input);
if (result.isErr()) {
console.error('updateUserStatus failed:', result.getError());
throw new Error('Failed to update user status');
console.error('updateUserStatus failed:', result.error);
return { success: false, error: result.error };
}
revalidatePath('/admin/users');
return { success: true };
}
```
**Pattern**:
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
1. **Server Action** (thin wrapper) - handles framework concerns (revalidation, returns to client)
2. **Mutation** (framework-agnostic) - constructs Service, calls service methods
3. **Service** - constructs own dependencies (API Client, Logger), returns Result
4. **API Client** (infrastructure) - makes HTTP requests, throws HTTP errors
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**:
- 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
- Manual construction (no DI container issues)
See [`MUTATIONS.md`](docs/architecture/website/MUTATIONS.md:1) and [`SERVICES.md`](docs/architecture/website/SERVICES.md:1).