website refactor
This commit is contained in:
@@ -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
|
|
||||||
|
return {
|
||||||
|
isSubmitting,
|
||||||
|
block: () => setIsSubmitting(true),
|
||||||
|
release: () => setIsSubmitting(false),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
## 4) Common Blockers
|
// Usage
|
||||||
|
const blocker = useSubmitBlocker();
|
||||||
|
|
||||||
- SubmitBlocker
|
async function handleSubmit() {
|
||||||
- ThrottleBlocker
|
if (blocker.isSubmitting) return;
|
||||||
- NavigationBlocker
|
|
||||||
- FeatureBlocker
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
form.isSubmitting = true;
|
||||||
|
const result = await signupMutation.mutateAsync(form.toCommand());
|
||||||
|
form.isSubmitting = false;
|
||||||
|
}
|
||||||
|
|
||||||
Usage in UI Component
|
return (
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
const form = useFormModel(SignupFormModel);
|
<input value={form.email} onChange={e => form.email = e.target.value} />
|
||||||
|
{/* ... */}
|
||||||
async function onSubmit() {
|
</form>
|
||||||
if (!form.isValid()) return;
|
);
|
||||||
|
|
||||||
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.
|
|
||||||
281
docs/architecture/website/DEPENDENCY_CONSTRUCTION.md
Normal file
281
docs/architecture/website/DEPENDENCY_CONSTRUCTION.md
Normal 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.
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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).
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user