This commit is contained in:
2025-12-21 17:05:36 +01:00
parent 08b0d59e45
commit f2d8a23583
66 changed files with 1131 additions and 1342 deletions

View File

@@ -0,0 +1,245 @@
Logging & Correlation ID Design Guide (Clean Architecture)
This document defines a clean, strict, and production-ready logging architecture with correlation IDs.
It removes ambiguity around:
• where logging belongs
• how context is attached
• how logs stay machine-readable
• how Clean Architecture boundaries are preserved
The rules below are non-negotiable.
Core Principles
Logs are data, not text.
Context is injected, never constructed in the Core.
Logging must:
• be machine-readable
• be framework-agnostic in the Core
• support correlation across requests
• be safe for parallel execution
Architectural Responsibilities
Core
• describes intent
• never knows about correlation IDs
• never knows about log destinations
App Layer (API)
• defines runtime context (request, user)
• binds correlation IDs
Adapters
• implement concrete loggers (console, file, structured)
• decide formatting and transport
1. Logger Port (Core)
Purpose
Defines what logging means, without defining how logging works.
Rules
• exactly one logging interface
• no framework imports
• no correlation or runtime context
Location
core/shared/application/LoggerPort.ts
Contract
• debug(message, meta?)
• info(message, meta?)
• warn(message, meta?)
• error(message, meta?)
Messages are semantic.
Metadata is optional and structured.
2. Request Context (App Layer)
Purpose
Represents runtime execution context.
Contains
• correlationId
• optional userId
Rules
• never visible to Core
• created per request
Location
apps/api/context/RequestContext.ts
3. Logger Implementations (Adapters)
Purpose
Provide concrete logging behavior.
Rules
• implement LoggerPort
• accept context via constructor
• produce structured logs
• no business logic
Examples
• ConsoleLogger
• FileLogger
• PinoLogger
• LokiLogger
Location
adapters/logging/
4. Logger Factory
Purpose
Creates context-bound logger instances.
Rules
• factory is injected
• logger instances are short-lived
• component name is bound here
Location
adapters/logging/LoggerFactory.ts
adapters/logging/LoggerFactoryImpl.ts
5. Correlation ID Handling
Where it lives
• API middleware
• message envelopes
• background job contexts
Rules
• generated once per request
• propagated across async boundaries
• never generated in the Core
6. Usage Rules by Layer
Layer Logging Allowed Notes
Domain ❌ No Throw domain errors instead
Use Cases ⚠️ Minimal Business milestones only
API Services ✅ Yes Main logging location
Adapters ✅ Yes IO, integration, failures
Frontend ⚠️ Limited Errors + analytics only
7. Forbidden Patterns
❌ Manual string prefixes ([ServiceName])
❌ Global/singleton loggers with mutable state
❌ any in logger abstractions
❌ Correlation IDs in Core
❌ Logging inside domain entities
8. File Structure (Final)
core/
└── shared/
└── application/
└── LoggerPort.ts # * required
apps/api/
├── context/
│ └── RequestContext.ts # * required
├── middleware/
│ └── CorrelationMiddleware.ts
└── modules/
└── */
└── *Service.ts
adapters/
└── logging/
├── LoggerFactory.ts # * required
├── LoggerFactoryImpl.ts # * required
├── ConsoleLogger.ts # optional
├── FileLogger.ts # optional
└── PinoLogger.ts # optional
Mental Model (Final)
The Core describes events.
The App provides context.
Adapters deliver telemetry.
If any layer violates this, the architecture is broken.
Summary
• one LoggerPort in the Core
• context bound outside the Core
• adapters implement logging destinations
• correlation IDs are runtime concerns
• logs are structured, searchable, and safe
This setup is:
• Clean Architecture compliant
• production-ready
• scalable
• refactor-safe

View File

@@ -168,6 +168,90 @@ export class SponsorsController {
}
Payments Example
Application Layer
Use Case
@Injectable()
export class CreatePaymentUseCase {
constructor(
private readonly paymentRepository: IPaymentRepository,
private readonly output: UseCaseOutputPort<CreatePaymentResult>,
) {}
async execute(input: CreatePaymentInput): Promise<Result<void, ApplicationErrorCode<CreatePaymentErrorCode>>> {
// business logic
const payment = await this.paymentRepository.create(payment);
this.output.present({ payment });
return Result.ok(undefined);
}
}
Result Model
type CreatePaymentResult = {
payment: Payment;
};
API Layer
Presenter
@Injectable()
export class CreatePaymentPresenter
implements UseCaseOutputPort<CreatePaymentResult>
{
private viewModel: CreatePaymentViewModel | null = null;
present(result: CreatePaymentResult): void {
this.viewModel = {
payment: this.mapPaymentToDto(result.payment),
};
}
getViewModel(): CreatePaymentViewModel | null {
return this.viewModel;
}
reset(): void {
this.viewModel = null;
}
private mapPaymentToDto(payment: Payment): PaymentDto {
return {
id: payment.id,
// ... other fields
};
}
}
Controller
@Controller('/payments')
export class PaymentsController {
constructor(
private readonly useCase: CreatePaymentUseCase,
private readonly presenter: CreatePaymentPresenter,
) {}
@Post()
async createPayment(@Body() input: CreatePaymentInput) {
const result = await this.useCase.execute(input);
if (result.isErr()) {
throw mapApplicationError(result.unwrapErr());
}
return this.presenter.getViewModel();
}
}
4. Module Wiring (Composition Root)