website refactor
This commit is contained in:
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.
|
||||
Reference in New Issue
Block a user