# Onion Lasagna Architecture Documentation # Philosophy > Why this architecture and when to use it ## Core Principles ### 1. Business Logic is Framework-Free Domain code never knows about: - HTTP frameworks (Express, Fastify, etc.) - Database clients (Drizzle, Prisma, etc.) - Cloud providers - Environment variables **Why?** Frameworks change. Your business rules shouldn't. ### 2. Dependencies Flow Inward ``` Presentation → Infrastructure → Orchestrations → Bounded Contexts ``` Inner layers never import from outer layers. ### 3. Contracts are the Source of Truth - **Read Models** define shared data shapes - **Request/Response DTOs** define API contracts - Frontend and backend share the same schemas --- ## When to Use ### Good Fit ✅ - Medium to large applications - Multiple bounded contexts - Complex business rules - Long-term maintainability matters ### Not a Good Fit ❌ - Simple CRUD apps - Prototypes / MVPs - Minimal business logic --- ## Tradeoffs ### What You Gain - **Testability** — Pure domain logic, easy mocking - **Flexibility** — Swap databases, frameworks - **Clarity** — Each layer has one job - **Scalability** — BCs can become services ### What You Pay - **More files** — Ports, DTOs, mappers - **Learning curve** — New developers need onboarding - **Indirection** — Request flows through layers --- # Glossary > Terms and definitions for Onion Lasagna Architecture ## Layers **Bounded Context (BC)** : A logical boundary with its own domain model, language, and rules. BCs don't import from each other directly. **Orchestrations** : Coordinates operations across multiple BCs. Three types: Compositions, Workflows, Projections. **Infrastructure** : Implements outbound ports. Contains persistence (databases) and external (API clients). **Presentation** : HTTP handlers, controllers, access guards, and mappers. --- ## Orchestration Types **Composition** : Combines multiple use cases from different BCs into a single operation. Runs synchronously within a single request. **Workflow** : Long-running process that coordinates multiple steps across BCs. May span multiple requests and handle failures. **Projection** : Read-only view that aggregates data from multiple BCs. Optimized for querying across boundaries. --- ## Ports **Inbound Port** : Interface that defines how external actors interact with the BC. Implemented by use cases. **Outbound Port** : Interface that defines how the BC interacts with external systems. Implemented by infrastructure. --- ## Domain **Aggregate** : Cluster of domain objects treated as a single unit. Enforces business invariants. **Entity** : Domain object with unique identity that persists over time. **Value Object (VO)** : Immutable object defined by its attributes, not identity. Used for type safety. --- ## Data **DTO (Data Transfer Object)** : Object that carries data between layers. Immutable, validated at creation. **Read Model** : DTO optimized for display. Returned by Query Repositories. --- ## Repositories **Query Repository** : Returns Read Models. Optimized for display. **Read Repository** : Returns Aggregates. Used before modifications. **Write Repository** : Persists Aggregates. Returns void or ID. --- ## Presentation **Access Control** : Authorization checks performed before executing use cases. Throw `AccessDeniedError` to deny access. **Mapper** : Pure function that transforms data between HTTP and use case shapes. --- # Overview > Layer structure, dependencies, and request flow ## Layer Diagram ``` ┌─────────────────────────────────────────────────────────────┐ │ PRESENTATION │ │ HTTP handlers, controllers, access guards, mappers │ │ CAN import: All inner layers │ ├─────────────────────────────────────────────────────────────┤ │ INFRASTRUCTURE │ │ Implementations, Persistence, External │ │ CAN import: BC ports, domain (VOs, aggregates), shared │ ├─────────────────────────────────────────────────────────────┤ │ ORCHESTRATIONS │ │ Compositions, Workflows, Projections │ │ CAN import: BC ports + use cases │ ├─────────────────────────────────────────────────────────────┤ │ BOUNDED CONTEXTS │ │ Domain + Use Cases + Ports │ │ CAN import: Shared only (NO frameworks, NO env vars) │ ├─────────────────────────────────────────────────────────────┤ │ SHARED │ │ Read Models, Enums, Generated Clients │ └─────────────────────────────────────────────────────────────┘ Dependencies flow INWARD → ``` --- ## Dependency Rules | Layer | Can Import | |-------|------------| | **Presentation** | Infrastructure, Orchestrations, Bounded Contexts, Shared | | **Infrastructure** | BC ports, domain (VOs, aggregates), Shared | | **Orchestrations** | BC ports + use cases, Shared | | **Bounded Contexts** | Shared only | | **Shared** | Nothing (leaf layer) | --- ## Request Flow ``` HTTP Request │ ▼ Handler ─────────────────► Extracts event, generates ExecutionContext │ ▼ Controller ──────────────► Validates, authorizes, maps │ ▼ Use Case / Orchestration ► Business logic │ ▼ Outbound Port Implementation │ ▼ Persistence / External ──► Database or API call │ ▼ Response bubbles back up ``` --- ## Folder Structure ### Module-Based ``` packages/backend/ ├── modules/{module}/ │ ├── bounded-contexts/{bc}/ │ │ ├── app/ │ │ │ ├── ports/inbound/ │ │ │ │ ├── queries/ │ │ │ │ └── commands/ │ │ │ ├── ports/outbound/ │ │ │ └── use-cases/ │ │ │ ├── queries/ │ │ │ └── commands/ │ │ ├── domain/ │ │ │ ├── aggregates/ │ │ │ ├── events/ │ │ │ └── services/ │ │ ├── infra/ │ │ │ ├── outbound-adapters/ │ │ │ ├── persistence/ │ │ │ ├── external-systems/ │ │ │ ├── schemas/ │ │ │ └── config/ │ │ └── presentation/ │ │ ├── bootstrap/ │ │ └── http/ │ ├── orchestrations/{orchestration}/ │ │ ├── app/ │ │ ├── infra/ │ │ └── presentation/ │ └── shared/ │ ├── infra/ │ ├── app/ │ └── domain/ └── shared/ ├── infra/ ├── app/ └── domain/ ``` ### Simple ``` packages/backend/ ├── bounded-contexts/{bc}/ │ ├── app/ │ ├── domain/ │ ├── infra/ │ └── presentation/ ├── orchestrations/{orchestration}/ │ ├── app/ │ ├── infra/ │ └── presentation/ └── shared/ ├── infra/ ├── app/ └── domain/ ``` --- # Presentation > Unified route system for HTTP handling with type-safe routes, handlers, and framework adapters The presentation layer provides a unified route system that powers: - Type-safe route definitions with schema validation - Server-side handlers with request/response mapping - Framework adapters (Hono, Fastify, Elysia, NestJS) - OpenAPI specification generation - Type-safe client generation --- ## Structure ``` presentation/ └── http/ ├── routes/ │ ├── users.routes.ts ← Route definitions with schemas │ └── projects.routes.ts ├── handlers/ │ ├── users.handlers.ts ← Handler implementations │ └── projects.handlers.ts └── router.ts ← Combined router definition ``` --- ## Route Definitions Define routes using `defineRoute()` with full type safety: ```typescript method: 'POST', path: '/api/users', request: { body: { schema: zodSchema( z.object({ email: z.string().email(), name: z.string().min(1).max(100), }), ), }, context: { schema: zodSchema( z.object({ userId: z.string(), }), ), }, }, responses: { 201: { description: 'User created', schema: zodSchema( z.object({ userId: z.string().uuid(), }), ), }, 400: { description: 'Validation error' }, }, docs: { summary: 'Create a new user', tags: ['Users'], operationId: 'createUser', }, }); ``` ### Route Definition Options | Property | Description | |----------|-------------| | `method` | HTTP method: GET, POST, PUT, PATCH, DELETE | | `path` | URL path with `:param` syntax for path parameters | | `request.body` | Request body schema | | `request.query` | Query parameter schema | | `request.params` | Path parameter schema | | `request.context` | Context schema (from middleware) | | `responses` | Response schemas by status code | | `docs` | OpenAPI documentation | --- ## Router Definition Group routes into a router using `defineRouter()`: ```typescript users: { create: createUserRoute, list: listUsersRoute, get: getUserRoute, }, }); ``` Routers can be nested for complex APIs: ```typescript projects: { create: createProjectRoute, list: listProjectsRoute, get: getProjectRoute, tasks: { add: addTaskRoute, list: listTasksRoute, update: updateTaskRoute, }, }, }); ``` --- ## Server Routes (Handlers) Create handlers using the `serverRoutes()` builder: ```typescript return serverRoutes(userRouter) .handle('users.create', { requestMapper: (req, ctx) => ({ email: req.body.email, name: req.body.name, createdBy: ctx.userId, }), useCase: useCases.createUserUseCase, responseMapper: (output) => ({ status: 201 as const, body: { userId: output.userId }, }), }) .handle('users.list', { requestMapper: (req) => ({ page: req.query?.page ?? 1, pageSize: req.query?.pageSize ?? 20, }), useCase: useCases.listUsersUseCase, responseMapper: (output) => ({ status: 200 as const, body: output, }), }) .build(); } ``` ### Handler Configuration Each handler has three functions: | Function | Purpose | |----------|---------| | `requestMapper` | Transform validated HTTP request to use case input | | `useCase` | The use case to execute | | `responseMapper` | Transform use case output to HTTP response | The `req` parameter in `requestMapper` is fully typed based on your route definition: - `req.body` - Validated request body - `req.query` - Validated query parameters - `req.pathParams` - Validated path parameters The `ctx` parameter contains context from middleware (e.g., authenticated user). --- ## Framework Integration Register routes with your framework of choice: ### Hono ```typescript const app = new Hono(); app.onError((err, c) => onionErrorHandler(err, c)); registerHonoRoutes(app, routes, { middlewares: [authMiddleware], contextExtractor: (c) => ({ userId: c.get('jwtPayload')?.sub, }), }); ``` ### Fastify ```typescript const app = Fastify(); app.setErrorHandler(onionErrorHandler); registerFastifyRoutes(app, routes, { middlewares: [authMiddleware], contextExtractor: (request) => ({ userId: request.user?.userId, }), }); ``` ### Elysia ```typescript const app = new Elysia() .onError(({ error }) => onionErrorHandler({ error })); registerElysiaRoutes(app, routes, { middlewares: [authMiddleware], contextExtractor: (ctx) => ({ userId: ctx.store['userId'], }), }); ``` ### NestJS NestJS uses a decorator-based approach: ```typescript @Controller('api/users') @UseGuards(JwtAuthGuard) @UseFilters(OnionExceptionFilter) constructor(private readonly routeHandlers: Map) {} @Post() async create( @OnionRequest(extractAuthContext) request: ContextualRawHttpRequest, ) { const route = this.routeHandlers.get('POST:/api/users'); return route.handler(request, request.context); } } ``` --- ## Context Extraction Context extractors provide request context (e.g., authenticated user) to handlers: ```typescript // Define context type interface AuthContext { userId: string; roles: string[]; } // Create extractor const contextExtractor = (c: Context): AuthContext => ({ userId: c.get('jwtPayload')?.sub ?? '', roles: c.get('jwtPayload')?.roles ?? [], }); // Use in handler .handle('projects.create', { requestMapper: (req, ctx) => ({ name: req.body.name, createdBy: ctx.userId, // From context }), // ... }) ``` --- ## Error Handling The unified route system provides automatic error handling: | Error Type | HTTP Status | Masked | |------------|-------------|--------| | `ObjectValidationError` | 400 | No | | `InvalidRequestError` | 400 | No | | `UseCaseError` | 400 | No | | `AccessDeniedError` | 403 | No | | `NotFoundError` | 404 | No | | `ConflictError` | 409 | No | | `UnprocessableError` | 422 | No | | `DomainError` | 500 | Yes | | `InfraError` | 500 | Yes | | `ControllerError` | 500 | Yes | 500-level errors are **masked** for security - only a generic message is returned to clients. Use the framework-specific error handler: ```typescript // Hono app.onError(onionErrorHandler); // Fastify app.setErrorHandler(onionErrorHandler); // Elysia app.onError(onionErrorHandler); // NestJS @UseFilters(OnionExceptionFilter) ``` --- ## OpenAPI Generation Generate OpenAPI specifications from your router: ```typescript const spec = generateOpenAPI(userRouter, { info: { title: 'User API', version: '1.0.0', description: 'API for user management', }, servers: [ { url: 'https://api.example.com', description: 'Production' }, { url: 'http://localhost:3000', description: 'Development' }, ], tags: [ { name: 'Users', description: 'User operations' }, ], }); ``` --- ## Type-Safe Client Generate a type-safe client from your router: ```typescript const client = createClient(userRouter, { baseUrl: 'https://api.example.com', headers: { Authorization: `Bearer ${token}` }, }); // Fully typed API calls const user = await client.users.get({ pathParams: { userId: '123' }, }); const newUser = await client.users.create({ body: { email: 'john@example.com', name: 'John' }, }); ``` --- ## Partial Builds For large routers, use `buildPartial()` to build handlers incrementally: ```typescript // users.handlers.ts return serverRoutes(apiRouter) .handle('users.create', { ... }) .handle('users.list', { ... }) .buildPartial(); // Returns partial routes } // projects.handlers.ts return serverRoutes(apiRouter) .handle('projects.create', { ... }) .handle('projects.list', { ... }) .buildPartial(); } // index.ts - Combine all handlers const routes = [ ...createUserHandlers(useCases), ...createProjectHandlers(useCases), ]; ``` --- # Infrastructure > Implement outbound ports with persistence and external services Implements outbound ports. Three-tier structure. --- ## Structure ``` infra/ ├── outbound-adapters/ ← Outbound port implementations (uses `implements`) │ └── {resource}/ │ └── {resource}.repository.ts ├── persistence/ ← Raw database access (NO `implements`) │ └── drizzle/ │ └── {resource}/ ├── external-systems/ ← Raw API adapters (NO `implements`) │ └── {service-name}/ ├── schemas/ ← Validation schemas │ ├── use-cases/ │ │ ├── queries/{query}/ │ │ └── commands/{command}/ │ └── http/ │ └── {resource}/{endpoint}/ └── config/ ← Configuration and environment ``` --- ## Key Rule **Only `outbound-adapters/` uses the `implements` keyword.** Persistence and external-systems are plain classes that the adapters orchestrate. --- ## Example ### Persistence (Raw Database) ```typescript:persistence/drizzle/user/user.persistence.ts class UserPersistence { async findById(id: string): Promise { return db.select().from(users).where(eq(users.id, id)).limit(1)[0]; } async insert(data: UserRow): Promise { await db.insert(users).values(data); } } ``` ### Outbound Adapter (Port Implementation) ```typescript:outbound-adapters/user/user.repository.ts class UserRepository extends BaseOutboundAdapter implements UserRepositoryOutboundPort { constructor(private readonly persistence: UserPersistence) { super(); } async findById(id: UserId): Promise { const row = await this.persistence.findById(id.value); if (!row) return null; return UserAggregate.reconstitute(row); // Hydrates aggregate } async save(user: UserAggregate): Promise { await this.persistence.insert({ id: user.id.value, email: user.email, name: user.name, }); } } ``` **Note:** Extending `BaseOutboundAdapter` automatically wraps all methods with error handling, converting any thrown errors to `InfraError`. --- ## Shared Infrastructure Scoping | Scope | Location | Use When | |-------|----------|----------| | Global | `/packages/backend/shared/infra/` | Shared across all modules | | Module | `/modules/{module}/shared/infra/` | Shared across BCs in one module | | BC | `/bounded-contexts/{bc}/infra/` | Scoped to single BC | Simple systems can share a single database + ORM via `shared/infra/`. --- ## Infrastructure Mappers For complex domain-to-persistence mappings, use dedicated mapper objects: ```typescript:persistence/drizzle/mappers/project.mapper.ts /** * Converts database rows to domain aggregate */ toDomain(row: ProjectRow, statusRows: StatusRow[], taskRows: TaskRow[]): Project { const statuses = statusRows.map(StatusMapper.toDomain); const tasks = taskRows.map(TaskMapper.toDomain); return Project.reconstitute( ProjectId.create(row.id), ProjectName.create(row.name), ProjectDescription.create(row.description ?? ''), statuses, tasks, row.createdAt, row.version, ); }, /** * Converts domain aggregate to database row */ toRow(project: Project): NewProjectRow { return { id: project.id.value, name: project.name.value, description: project.description.value || null, createdAt: project.createdAt, version: project.version, }; }, }; ``` ### Using Mappers in Repositories ```typescript:outbound-adapters/persistence/drizzle/project/project.repository.adapter.ts class ProjectRepository extends BaseOutboundAdapter implements ProjectRepositoryPort { async findById(id: ProjectId): Promise { const row = await this.db.query.projects.findFirst({ where: eq(projects.id, id.value), }); if (!row) return null; const statusRows = await this.db.query.statuses.findMany({ where: eq(statuses.projectId, id.value), }); const taskRows = await this.db.query.tasks.findMany({ where: eq(tasks.projectId, id.value), }); return ProjectMapper.toDomain(row, statusRows, taskRows); } async save(project: Project): Promise { const row = ProjectMapper.toRow(project); await this.db.insert(projects).values(row).onConflictDoUpdate({ target: projects.id, set: row, }); // Save child entities for (const status of project.statuses) { const statusRow = StatusMapper.toRow(status, project.id.value); await this.db.insert(statuses).values(statusRow).onConflictDoUpdate({ target: statuses.id, set: statusRow, }); } } } ``` ### Mapper Directory Structure ``` infra/ └── outbound-adapters/ └── persistence/ └── drizzle/ ├── mappers/ ← Shared mappers │ ├── project.mapper.ts │ ├── status.mapper.ts │ └── task.mapper.ts ├── project/ ← Repository implementations │ └── project.repository.adapter.ts └── project-query/ └── project-query.repository.adapter.ts ``` **toDomain** converts persistence (row) → domain (aggregate/entity) **toRow** converts domain (aggregate/entity) → persistence (row) --- ## Aggregate Hydration Repositories are responsible for hydrating aggregates using `Aggregate.reconstitute()`. **Prefer partial loading** over full loading when possible. --- ## Environment Variables - ❌ **Prohibited** in Bounded Contexts - ✅ **Allowed** in Infrastructure (persistence, external, implementations) --- # Orchestrations > Coordinate operations across multiple Bounded Contexts Coordinates operations across multiple Bounded Contexts. --- ## When to Use | Scenario | Approach | |----------|----------| | Single BC, self-contained | Direct BC call | | Multiple BCs needed | Orchestration | --- ## Three Types | Type | Purpose | Example | |------|---------|---------| | **Composition** | Read-only, multi-BC query | GET order with customer + products | | **Workflow** | Write, multi-BC command | Checkout: order + inventory + payment | | **Projection** | Denormalized read store | Dashboard aggregating multiple BCs | --- ## Structure Each orchestration is self-contained with its own layers: ``` orchestrations/{orchestration-name}/ ├── app/ │ ├── inbound/ │ │ ├── compositions/ │ │ │ └── {composition}/ │ │ ├── workflows/ │ │ │ └── {workflow}/ │ │ └── projections/ │ │ └── {projection}/ │ └── outbound/ ├── infra/ │ ├── outbound-adapters/ │ ├── persistence/ │ └── schemas/ └── presentation/ ├── bootstrap/ └── http/ ``` **Note:** Each orchestration has its own presentation layer for HTTP endpoints specific to that orchestration. --- ## Composition ```typescript:compositions/use-cases/get-order-details.composition.ts class GetOrderDetailsComposition implements GetOrderDetailsCompositionInboundPort { constructor( orderQueryRepo: OrderQueryRepositoryOutboundPort, customerQueryRepo: CustomerQueryRepositoryOutboundPort, ) { this.findOrderQuery = new FindOrderByIdQuery(orderQueryRepo); this.findCustomerQuery = new FindCustomerByIdQuery(customerQueryRepo); } async execute(input: GetOrderDetailsInput): Promise { const order = await this.findOrderQuery.execute({ orderId: input.orderId }); const customer = await this.findCustomerQuery.execute({ customerId: order.customerId }); return { order, customer }; } } ``` --- ## Workflow ```typescript:workflows/use-cases/process-checkout.workflow.ts class ProcessCheckoutWorkflow implements ProcessCheckoutWorkflowInboundPort { async execute(input: ProcessCheckoutInput): Promise { const order = await this.createOrderCommand.execute({ ... }); try { await this.reserveInventoryCommand.execute({ orderId: order.id }); await this.processPaymentCommand.execute({ orderId: order.id }); return { orderId: order.id, status: 'COMPLETED' }; } catch (error) { await this.cancelOrderCommand.execute({ orderId: order.id }); throw error; } } } ``` --- ## Projection Projections have their own infrastructure layer for denormalized storage. **Rules:** - CAN read from shared database - CAN write only to its own denormalized storage - For simple systems with shared DB, may not need separate storage ```typescript:projections/use-cases/user-dashboard.projection.ts class UserDashboardProjection implements UserDashboardProjectionInboundPort { async execute(input: UserDashboardInput): Promise { // Reads from multiple BCs, returns aggregated view } } ``` --- ## Naming | Type | Interface | File | |------|-----------|------| | Composition | `{Name}CompositionInboundPort` | `.composition.ts` | | Workflow | `{Name}WorkflowInboundPort` | `.workflow.ts` | | Projection | `{Name}ProjectionInboundPort` | `.projection.ts` | --- # Bounded Contexts > Pure domain logic with ports and use cases Pure domain logic, completely framework-free. --- ## Structure ``` bounded-contexts/{bc-name}/ ├── bootstrap/ ← Dependency wiring (at BC root!) │ ├── index.ts ← Main orchestration │ ├── adapters.bootstrap.ts │ ├── use-cases.bootstrap.ts │ ├── validators.bootstrap.ts │ ├── controller.bootstrap.ts │ └── routes.bootstrap.ts ├── app/ │ ├── ports/ │ │ ├── inbound/ │ │ │ ├── {resource}/ ← Ports grouped by resource │ │ │ │ ├── create-{resource}.command.port.ts │ │ │ │ ├── get-{resource}.query.port.ts │ │ │ │ └── list-{resources}.query.port.ts │ │ └── outbound/ ← Repository interfaces │ └── use-cases/ │ └── {resource}/ ← Use cases grouped by resource │ ├── create-{resource}.command.ts │ ├── get-{resource}.query.ts │ └── list-{resources}.query.ts ├── domain/ │ ├── aggregates/ │ │ └── {aggregate}/ │ │ └── policies/ │ ├── entities/ │ ├── value-objects/ │ ├── events/ ← Domain events │ ├── services/ ← Domain services │ └── exceptions/ ├── infra/ │ └── outbound-adapters/ ← Port implementations │ └── persistence/ │ └── drizzle/ │ ├── mappers/ ← Domain ↔ persistence mappers │ └── {resource}/ ← Repository implementations └── presentation/ ← BC-specific HTTP layer └── http/ ├── service.metadata.ts ├── resources/ ← Resource metadata ├── endpoints/ ← Endpoint metadata └── {resource}/{endpoint}/ ├── dtos.ts ← Request/Response DTOs ├── schemas.ts ← Validation schemas ├── mappers.ts ← DTO transformations └── endpoint.metadata.ts ``` The **bootstrap** folder is at the BC root, not under presentation. See [Bootstrap Pattern](/docs/patterns/bootstrap) for details. --- ## Golden Rules 1. **NO framework imports** 2. **NO environment variables** 3. **NO direct cross-BC imports** 4. **CAN import** from domain (VOs, aggregates) into infra --- ## Inbound Ports Interfaces for use cases. Implemented by queries and commands. ```typescript:ports/inbound/queries/find-user-by-id.port.ts interface FindUserByIdQueryInboundPort { execute(input: FindUserByIdInputDto): Promise; } ``` ### Naming Conventions Two patterns are acceptable: **Full naming (explicit):** | Type | Interface | File | |------|-----------|------| | Query | `{Name}QueryInboundPort` | `{resource}/{name}.query.port.ts` | | Command | `{Name}CommandInboundPort` | `{resource}/{name}.command.port.ts` | **Short naming (concise):** | Type | Interface | File | |------|-----------|------| | Query | `{Name}Port` | `{resource}/{name}.query.port.ts` | | Command | `{Name}Port` | `{resource}/{name}.command.port.ts` | Choose one convention and use it consistently throughout your project. The short naming works well when file names already indicate the type (`.query.port.ts` vs `.command.port.ts`). --- ## Outbound Ports Interfaces for external dependencies. Implemented by infrastructure. ```typescript:ports/outbound/user.repository.outbound.ts interface UserRepositoryOutboundPort { findById(id: UserId): Promise; save(user: User): Promise; } ``` --- ## Use Cases Implement inbound ports using `BaseInboundAdapter` for automatic error handling. ```typescript:use-cases/queries/find-user-by-id.use-case.ts class FindUserByIdQuery extends BaseInboundAdapter implements FindUserByIdQueryInboundPort { constructor(private readonly userRepo: UserRepositoryOutboundPort) { super(); } protected async handle(input: FindUserByIdInputDto): Promise { const user = await this.userRepo.findById(UserId.create(input.data.userId)); if (!user) { throw new NotFoundError({ message: `User ${input.data.userId} not found` }); } return FindUserByIdOutputDto.create({ id: user.id.value, email: user.email, name: user.name, }); } } ``` **Note:** Implement `handle()` (protected) instead of `execute()`. The `BaseInboundAdapter` provides `execute()` with automatic error wrapping. --- ## Domain ### Aggregates ```typescript class UserAggregate { private constructor( private readonly _id: UserId, private _email: string, private _name: string, ) {} static create(data: CreateUserData): UserAggregate { ... } static reconstitute(data: UserData): UserAggregate { ... } } ``` ### Value Objects ```typescript class UserId { private constructor(private readonly _value: string) {} static create(value: string): UserId { ... } static generate(): UserId { ... } get value(): string { return this._value; } } ``` See [Aggregates](/docs/patterns/aggregates) and [Value Objects](/docs/patterns/value-objects) for details. --- # DTOs > Data Transfer Objects for carrying data between layers using plain TypeScript interfaces with schema validation. ## Overview In onion-lasagna, DTOs are **plain TypeScript interfaces** rather than classes. Validation is handled at the HTTP boundary through schema adapters, and data transformation happens in request/response mappers. ## DTO Types | DTO Type | Purpose | Where Defined | |----------|---------|---------------| | **Request Body** | HTTP request body shape | Route definition (`request.body.schema`) | | **Query Params** | HTTP query parameters | Route definition (`request.query.schema`) | | **Path Params** | URL path parameters | Route definition (`request.params.schema`) | | **Response Body** | HTTP response shape | Route definition (`responses.{status}.schema`) | | **Use Case Input** | Input to use case | TypeScript interface in use case port | | **Use Case Output** | Output from use case | TypeScript interface in use case port | --- ## Schema-Based Validation Validation is defined in route definitions using schema adapters: ```typescript // Define the route with schemas method: 'POST', path: '/api/users', request: { body: { schema: zodSchema( z.object({ email: z.string().email(), name: z.string().min(1).max(100), password: z.string().min(8), }), ), }, }, responses: { 201: { description: 'User created', schema: zodSchema( z.object({ userId: z.string().uuid(), }), ), }, }, }); ``` The schema adapter validates incoming requests automatically before your handler runs. --- ## Use Case DTOs Use case input and output are plain TypeScript interfaces: ```typescript // Input DTO - what the use case receives interface CreateUserInput { email: string; name: string; hashedPassword: string; } // Output DTO - what the use case returns interface CreateUserOutput { userId: string; email: string; name: string; createdAt: Date; } // Use case port definition interface CreateUserUseCasePort { execute(input: CreateUserInput): Promise; } ``` --- ## Request and Response Mapping Transform between HTTP layer and use case layer using mappers: ```typescript const routes = serverRoutes(userRouter) .handle('users.create', { // Transform validated HTTP request to use case input requestMapper: (req, ctx) => ({ email: req.body.email, name: req.body.name, hashedPassword: hashPassword(req.body.password), }), useCase: createUserUseCase, // Transform use case output to HTTP response responseMapper: (output) => ({ status: 201 as const, body: { userId: output.userId, }, }), }) .build(); ``` The `req` parameter is **fully typed** based on your route definition schemas. --- ## Schema Adapters Two schema adapters are available: ### Zod Adapter ```typescript const userSchema = zodSchema( z.object({ id: z.string().uuid(), email: z.string().email(), name: z.string().min(1).max(100), }), ); ``` ### TypeBox Adapter ```typescript const userSchema = typeboxSchema( Type.Object({ id: Type.String({ format: 'uuid' }), email: Type.String({ format: 'email' }), name: Type.String({ minLength: 1, maxLength: 100 }), }), ); ``` --- ## Validation Errors When schema validation fails, an `ObjectValidationError` is thrown automatically. The framework converts this to a 400 HTTP response with structured errors: ```json { "errorCode": "VALIDATION_ERROR", "message": "Validation failed", "details": [ { "path": ["body", "email"], "message": "Invalid email" }, { "path": ["body", "password"], "message": "String must contain at least 8 character(s)" } ] } ``` --- ## Context DTOs For authenticated routes, define a context schema to validate JWT payload or middleware data: ```typescript // Auth context schema z.object({ userId: z.string(), }), ); method: 'POST', path: '/api/projects', request: { body: { schema: zodSchema( z.object({ name: z.string().min(1), }), ), }, context: { schema: authContextSchema, }, }, // ... }); ``` Access context in your handler: ```typescript .handle('projects.create', { requestMapper: (req, ctx) => ({ name: req.body.name, createdBy: ctx.userId, // Fully typed from context schema }), // ... }) ``` --- ## Type Inference Schema adapters provide full type inference: ```typescript InferRouteBody, InferRouteQuery, InferRoutePathParams, InferRouteSuccessResponse, InferRouteResponse, } from '@cosmneo/onion-lasagna/http'; // Infer request types type CreateUserBody = InferRouteBody; // { email: string; name: string; password: string } // Infer the first 2xx response type type CreateUserResponse = InferRouteSuccessResponse; // { userId: string } // Infer response for a specific status code type NotFoundResponse = InferRouteResponse; // { message: string } ``` --- ## DTO vs Value Object | Aspect | DTO | Value Object | |--------|-----|--------------| | **Purpose** | Transfer data across boundaries | Represent domain concept | | **Location** | Presentation / App layer | Domain layer | | **Form** | Plain interface + schema | Class with behavior | | **Validation** | Schema adapter at boundary | `create()` factory method | | **Identity** | None | Compared by value | --- # Value Objects > Immutable objects defined by their attributes, not identity. Used for type safety and validation. ## BaseValueObject All value objects extend `BaseValueObject` and validate in their `create()` factory method: ```typescript class Email extends BaseValueObject { private static readonly EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; static create(value: Email['value']): Email { if (!Email.EMAIL_REGEX.test(value)) { throw new InvariantViolationError({ message: 'Invalid email format', code: 'INVALID_EMAIL', }); } return new Email(value); } get domain(): string { return this.value.split('@')[1]; } } ``` Key features: - **Immutable**: Value is set at construction and cannot be changed - **Self-validating**: Validation runs in `create()` before construction - **Comparable**: Built-in `equals()` method with deep equality - **Type inference**: Use `ClassName['value']` for input type --- ## Built-in Value Objects The library provides base value objects you can extend: ### Text Types ```typescript BaseTextVo, // Configurable text with static constraints BaseShortTextVo, // 1-100 characters BaseMediumTextVo, // 1-500 characters BaseLongTextVo, // 1-5000 characters } from '@cosmneo/onion-lasagna/backend/core/onion-layers'; // Create custom text VO with constraints class ProductName extends BaseTextVo { static override defaultMinLength = 1; static override defaultMaxLength = 50; } class SkuCode extends BaseTextVo { static override defaultMinLength = 3; static override defaultMaxLength = 20; static override defaultPattern = /^[A-Z0-9-]+$/; } ``` Text VOs use `new this(value)` internally, so subclass instances are created correctly at runtime. However, TypeScript still infers the return type as the base class. For strict type safety, you can override `create()`: ```typescript class ProductName extends BaseTextVo { static override defaultMinLength = 1; static override defaultMaxLength = 50; static override create(value: ProductName['value']): ProductName { // Let parent validate, then cast to correct type BaseTextVo.create.call(this, value); return new ProductName(value); } } ``` ### Identifiers ```typescript BaseUuidV4Vo, // UUID v4 format (random) BaseUuidV7Vo, // UUID v7 format (time-ordered) } from '@cosmneo/onion-lasagna/backend/core/onion-layers'; // Usage const id = BaseUuidV4Vo.generate(); // Generate new const parsed = BaseUuidV4Vo.create(uuidStr); // Validate existing ``` ### Contact ```typescript const email = BaseEmailVo.create('user@example.com'); ``` ### Pagination ```typescript const page = BasePaginationVo.create({ page: 1, pageSize: 20 }); // Properties page.page; // 1 page.pageSize; // 20 page.offset; // 0 (calculated: (page - 1) * pageSize) // Custom max page size class AdminPaginationVo extends BasePaginationVo { static override get maxPageSize(): number { return 500; } } ``` ### Auditing ```typescript BaseAuditByVo, // createdBy, updatedBy (optional UUIDs) BaseAuditOnVo, // createdAt, updatedAt (with invariant check) } from '@cosmneo/onion-lasagna/backend/core/onion-layers'; // Create with current timestamp const auditOn = BaseAuditOnVo.now(); // Create with specific dates const auditOn = BaseAuditOnVo.create({ createdAt: new Date('2024-01-01'), updatedAt: new Date('2024-01-15'), }); // Update timestamp (returns new immutable instance) const updated = auditOn.update(); ``` **Note**: `BaseAuditOnVo` enforces that `updatedAt` cannot be before `createdAt`. --- ## Creating Custom Value Objects ### Simple Identifier ```typescript class OrderId extends BaseUuidV7Vo { // Override generate() to return correct type static override generate(): OrderId { return new OrderId(v7()); } // IMPORTANT: Override create() to return correct type static override create(value: OrderId['value']): OrderId { const validated = BaseUuidV7Vo.create(value); return new OrderId(validated.value); } } ``` **Always override `create()` for UUID subclasses.** Without this override, `OrderId.create(uuid)` returns `BaseUuidV7Vo` instead of `OrderId`, breaking type safety and `instanceof` checks. ### Value Object with Custom Validation ```typescript class PhoneNumber extends BaseValueObject { private static readonly PHONE_REGEX = /^\+[1-9]\d{1,14}$/; static create(value: PhoneNumber['value']): PhoneNumber { if (!PhoneNumber.PHONE_REGEX.test(value)) { throw new InvariantViolationError({ message: 'Invalid phone number format (E.164 required)', code: 'INVALID_PHONE', }); } return new PhoneNumber(value); } get countryCode(): string { return this.value.slice(1, 3); } } ``` ### Composite Value Object ```typescript interface AddressData { street: string; city: string; postalCode: string; country: string; } class Address extends BaseValueObject { static create(data: Address['value']): Address { if (!data.street || data.street.trim().length === 0) { throw new InvariantViolationError({ message: 'Street is required', code: 'INVALID_ADDRESS', }); } if (!data.country || data.country.length !== 2) { throw new InvariantViolationError({ message: 'Country must be 2-letter ISO code', code: 'INVALID_COUNTRY', }); } return new Address(data); } get street(): string { return this.value.street; } get city(): string { return this.value.city; } get fullAddress(): string { return `${this.value.street}, ${this.value.city}, ${this.value.country}`; } } ``` ### Enum-like Value Object ```typescript type OrderStatusValue = 'pending' | 'confirmed' | 'shipped' | 'cancelled'; class OrderStatus extends BaseValueObject { static pending(): OrderStatus { return new OrderStatus('pending'); } static confirmed(): OrderStatus { return new OrderStatus('confirmed'); } static shipped(): OrderStatus { return new OrderStatus('shipped'); } static cancelled(): OrderStatus { return new OrderStatus('cancelled'); } isPending(): boolean { return this.value === 'pending'; } isShipped(): boolean { return this.value === 'shipped'; } isModifiable(): boolean { return this.isPending() || this.value === 'confirmed'; } } ``` --- ## Equality Comparison `BaseValueObject` provides deep equality via `equals()`: ```typescript const email1 = BaseEmailVo.create('user@example.com'); const email2 = BaseEmailVo.create('user@example.com'); const email3 = BaseEmailVo.create('other@example.com'); email1.equals(email2); // true (same value) email1.equals(email3); // false (different value) ``` The comparison handles: - Primitive values - Nested objects - Arrays - Date objects --- ## Rules - Use factory methods (`create`, `generate`) - never call constructor directly - Validate in `create()` and throw `InvariantViolationError` for invalid data - Use `ClassName['value']` for type inference in create parameters - Add convenience getters for derived values - Extend built-in VOs when possible (BaseEmailVo, BaseUuidV7Vo, etc.) - Don't mutate after construction - Don't use for complex objects (use Entities/Aggregates) --- # Aggregates > Entity clusters treated as a single unit. Enforce business invariants and emit domain events. ## BaseEntity and BaseAggregateRoot The library provides base classes for entities and aggregates: ```typescript BaseEntity, BaseAggregateRoot, BaseDomainEvent, } from '@cosmneo/onion-lasagna/backend/core/onion-layers'; ``` --- ## BaseEntity Entities have identity and can be compared by ID: ```typescript abstract class BaseEntity, TProps extends object, > { private readonly _id: TId; private readonly _version: number; protected _props: TProps; // Public API get id(): TId; get version(): number; equals(other: BaseEntity): boolean; // Protected (for subclasses) protected get props(): TProps; protected idEquals(a: TId, b: TId): boolean; protected nextVersion(): number; } ``` --- ## BaseAggregateRoot Aggregates extend `BaseEntity` with domain event support: ```typescript abstract class BaseAggregateRoot, TProps extends object, > extends BaseEntity { // Add a domain event to be published protected addDomainEvent(event: BaseDomainEvent): void; // Get and clear all domain events (for publishing) public pullDomainEvents(): BaseDomainEvent[]; // Peek at events without clearing public peekDomainEvents(): readonly BaseDomainEvent[]; // Check if there are pending events public get hasDomainEvents(): boolean; // Clear all events (called after publishing) protected clearDomainEvents(): void; } ``` --- ## Creating an Aggregate ```typescript BaseAggregateRoot, BaseDomainEvent, InvariantViolationError, } from '@cosmneo/onion-lasagna/backend/core/onion-layers'; interface OrderProps { customerId: CustomerId; items: OrderItem[]; status: OrderStatus; createdAt: Date; } class OrderAggregate extends BaseAggregateRoot { private constructor(id: OrderId, props: OrderProps, version = 0) { super(id, props, version); } // Factory for NEW instances static create(data: CreateOrderData): OrderAggregate { const order = new OrderAggregate( OrderId.generate(), { customerId: CustomerId.create(data.customerId), items: [], status: OrderStatus.DRAFT, createdAt: new Date(), }, ); // Emit creation event order.addDomainEvent(new OrderCreatedEvent({ orderId: order.id.value, customerId: data.customerId, totalAmount: 0, })); return order; } // Factory for EXISTING instances (from DB) static reconstitute(data: OrderData, version: number): OrderAggregate { return new OrderAggregate( OrderId.create(data.id), { customerId: CustomerId.create(data.customerId), items: data.items.map(OrderItem.reconstitute), status: data.status, createdAt: data.createdAt, }, version, ); } // Domain methods (enforce invariants) addItem(item: AddItemData): void { if (this._props.status !== OrderStatus.DRAFT) { throw new InvariantViolationError({ message: 'Cannot add items to non-draft order', code: 'ORDER_NOT_EDITABLE', }); } if (this._props.items.length >= 50) { throw new InvariantViolationError({ message: 'Maximum 50 items per order', code: 'ORDER_LIMIT_EXCEEDED', }); } this._props.items.push(OrderItem.create(item)); this.addDomainEvent(new OrderItemAddedEvent({ orderId: this.id.value, productId: item.productId, quantity: item.quantity, })); } submit(): void { if (this._props.items.length === 0) { throw new InvariantViolationError({ message: 'Cannot submit empty order', code: 'EMPTY_ORDER', }); } this._props.status = OrderStatus.PENDING; this.addDomainEvent(new OrderSubmittedEvent({ orderId: this.id.value, submittedAt: new Date(), })); } // Getters get customerId(): CustomerId { return this._props.customerId; } get status(): OrderStatus { return this._props.status; } get items(): readonly OrderItem[] { return [...this._props.items]; } } ``` --- ## Domain Events Create domain events by extending `BaseDomainEvent`. Use a **payload object** for type-safe construction: ```typescript interface OrderCreatedPayload { orderId: string; customerId: string; totalAmount: number; } class OrderCreatedEvent extends BaseDomainEvent { constructor(payload: OrderCreatedPayload) { super('OrderCreated', payload.orderId, payload); } } interface OrderSubmittedPayload { orderId: string; submittedAt: Date; } class OrderSubmittedEvent extends BaseDomainEvent { constructor(payload: OrderSubmittedPayload) { super('OrderSubmitted', payload.orderId, payload); } } ``` The payload object pattern ensures all event data is self-contained. The `aggregateId` is extracted from the payload rather than passed separately. `BaseDomainEvent` provides: - `eventId` - Unique ID for the event - `eventName` - Name of the event - `aggregateId` - ID of the aggregate that emitted it - `occurredOn` - Timestamp when the event occurred - `payload` - Event-specific data - `toJSON()` - Serialize for storage/transport --- ## Publishing Domain Events Use `pullDomainEvents()` to get and clear events after persisting: ```typescript class CreateOrderCommand { constructor( private readonly orderRepo: OrderWriteRepositoryOutboundPort, private readonly eventPublisher: EventPublisherOutboundPort, ) {} async execute(input: CreateOrderInput): Promise { const order = OrderAggregate.create(input); // Persist first await this.orderRepo.save(order); // Then publish events (get and clear) const events = order.pullDomainEvents(); await this.eventPublisher.publishAll(events); } } ``` --- ## Optimistic Locking Use `version` for optimistic concurrency. Increment the version when persisting: ```typescript class OrderRepository { async save(order: OrderAggregate): Promise { const affectedRows = await this.db.query( `UPDATE orders SET ..., version = ? WHERE id = ? AND version = ?`, [order.version + 1, order.id.value, order.version], ); if (affectedRows === 0) { throw new ConcurrencyError('Order was modified by another process'); } } } ``` The `version` property is public for reading. The `nextVersion()` method is protected - use `version + 1` in repositories to increment the version when saving. --- ## Protected Utility Methods `BaseEntity` provides utility methods for subclasses: ### idEquals Compares two IDs of the same type using the Value Object's `equals` method: ```typescript class OrderAggregate extends BaseAggregateRoot { // Use idEquals for comparing aggregate IDs (same TId type) isSameOrder(otherId: OrderId): boolean { return this.idEquals(this.id, otherId); } // For child entity IDs, use equals() directly hasItem(itemId: OrderItemId): boolean { return this._props.items.some((item) => item.id.equals(itemId)); } } ``` ### Version Increment When saving to the database, increment the version: ```typescript // In repository async save(order: OrderAggregate): Promise { await this.db.update({ ...this.toRow(order), version: order.version + 1, }).where({ id: order.id.value, version: order.version }); } ``` The `version` property is public for reading. Use `version + 1` in your repository when persisting changes to implement optimistic locking. --- ## Hydration Repositories are responsible for hydrating aggregates: ```typescript class OrderRepository extends BaseOutboundAdapter { async findById(id: OrderId): Promise { const row = await this.persistence.findById(id.value); if (!row) return null; return OrderAggregate.reconstitute(row, row.version); } } ``` --- ## Load State Tracking When loading aggregates from the database, you may not always load all fields or relations. For example, a query might load only `id` and `status`, skipping heavy relations like `items`. If code later tries to access `items`, it would get `undefined` or stale data. `BaseAggregateRoot` provides **load state tracking** to prevent this: ```typescript abstract class BaseAggregateRoot extends BaseEntity { // Mark fields as loaded during reconstitution protected markLoaded(...fields: (keyof TProps | string)[]): void; // Check if a field is loaded protected isLoaded(field: keyof TProps | string): boolean; // Get field value or throw PartialLoadError protected requireLoaded(field: K, errorCode?: string): TProps[K]; // View loaded fields (for debugging) public get loadedFields(): ReadonlySet; } ``` ### Using Load State Tracking Mark fields as loaded in your `reconstitute` factory: ```typescript class OrderAggregate extends BaseAggregateRoot { // Factory for EXISTING instances static reconstitute( data: OrderData, version: number, options?: { includeItems?: boolean }, ): OrderAggregate { const order = new OrderAggregate( OrderId.create(data.id), { customerId: CustomerId.create(data.customerId), items: options?.includeItems ? data.items.map(OrderItem.reconstitute) : [], status: data.status, createdAt: data.createdAt, }, version, ); // Mark which fields were actually loaded order.markLoaded('customerId', 'status', 'createdAt'); if (options?.includeItems) { order.markLoaded('items'); } return order; } // Safe getter that throws if items weren't loaded get items(): readonly OrderItem[] { return this.requireLoaded('items'); } // Conditional logic based on load state get itemCount(): number { if (!this.isLoaded('items')) { return 0; // Or throw, depending on your needs } return this._props.items.length; } } ``` ### Repository with Load Options ```typescript class OrderRepository extends BaseOutboundAdapter { async findById( id: OrderId, options?: { includeItems?: boolean }, ): Promise { const row = await this.persistence.findById(id.value); if (!row) return null; // Load items only if requested const items = options?.includeItems ? await this.persistence.findItemsByOrderId(id.value) : []; return OrderAggregate.reconstitute( { ...row, items }, row.version, options, ); } } ``` ### Custom Error Codes By default, `requireLoaded` generates error codes like `ITEMS_NOT_LOADED`. You can provide custom codes: ```typescript get customer(): Customer { return this.requireLoaded('customer', 'ORDER_CUSTOMER_NOT_LOADED'); } ``` Load state tracking is optional. If you always load all fields, you don't need to use it. It's useful for performance optimization when loading partial aggregates. --- ## Rules - ✅ Extend `BaseAggregateRoot` for event-sourced aggregates - ✅ Use factory methods (`create`, `reconstitute`) - ✅ Enforce all invariants in domain methods - ✅ Use `InvariantViolationError` for business rule violations - ✅ Emit domain events for state changes - ✅ Return copies of collections from getters - ✅ Reference other aggregates by ID only - ✅ Use `version` for optimistic locking - ✅ Use `markLoaded()` in `reconstitute` when loading partial data - ✅ Use `requireLoaded()` in getters for optional relations - ❌ Never create with `new` directly - ❌ Never reference other aggregates directly - ❌ Never publish events before persisting - ❌ Never access fields that weren't marked as loaded --- # Repositories > Provide access to aggregates and read models. Three types aligned with CQRS. ## Types | Type | Returns | Used In | |------|---------|---------| | **Query Repository** | Read Models | Queries (display) | | **Read Repository** | Aggregates | Commands (before mutation) | | **Write Repository** | void / ID | Commands (persist) | --- ## Port Definition (Outbound) ```typescript:ports/outbound/user.repository.outbound.ts interface UserQueryRepositoryOutboundPort { findPaginated(options: PaginationOptions): Promise>; } interface UserReadRepositoryOutboundPort { findById(id: UserId): Promise; findByEmail(email: Email): Promise; } interface UserWriteRepositoryOutboundPort { save(user: UserAggregate): Promise; delete(id: UserId): Promise; } ``` --- ## Implementation Extend `BaseOutboundAdapter` for automatic error wrapping: ```typescript:infra/outbound-adapters/user/user.repository.ts class UserRepository extends BaseOutboundAdapter implements UserQueryRepositoryOutboundPort, UserReadRepositoryOutboundPort, UserWriteRepositoryOutboundPort { constructor(private readonly persistence: UserPersistence) { super(); } async findById(id: UserId): Promise { const row = await this.persistence.findById(id.value); if (!row) return null; return UserAggregate.reconstitute(row); } async save(user: UserAggregate): Promise { await this.persistence.upsert({ id: user.id.value, email: user.email.value, name: user.name, }); } } ``` **Note:** `BaseOutboundAdapter` automatically wraps all methods with error handling, converting any thrown errors to `InfraError`. --- ## Usage in Use Cases ```typescript // Query use case class FindUsersQuery { constructor(private readonly queryRepo: UserQueryRepositoryOutboundPort) {} async execute(input: FindUsersInput): Promise { return this.queryRepo.findPaginated(input.pagination); } } // Command use case class CreateUserCommand { constructor( private readonly readRepo: UserReadRepositoryOutboundPort, private readonly writeRepo: UserWriteRepositoryOutboundPort, ) {} async execute(input: CreateUserInput): Promise { const existing = await this.readRepo.findByEmail(Email.create(input.email)); if (existing) throw new EmailAlreadyExistsError(input.email); const user = UserAggregate.create(input); await this.writeRepo.save(user); } } ``` --- ## Rules - ✅ Define ports in BC, implement in infrastructure - ✅ Return Aggregates from Read Repository - ✅ Return Read Models from Query Repository - ✅ Hydrate aggregates in repository (not caller) - ❌ Don't put business logic in repositories --- # Schema Adapters > Schema adapters for request/response validation and OpenAPI generation. Supports Zod and TypeBox. ## Overview Schema adapters wrap validation libraries (Zod, TypeBox) with a consistent interface for: - Request/response validation in route definitions - JSON Schema generation for OpenAPI documentation - Full TypeScript type inference Available adapters: - **Zod** - TypeScript-first schema validation (`zodSchema`) - **TypeBox** - JSON Schema Type Builder (`typeboxSchema`) --- ## Schema Adapter Interface All adapters implement `SchemaAdapter`: ```typescript interface SchemaAdapter { validate(data: unknown): ValidationResult; toJsonSchema(options?: JsonSchemaOptions): JsonSchema; _output: TOutput; // Phantom type for inference _input: TInput; // Phantom type for inference } type ValidationResult = | { success: true; data: T } | { success: false; issues: ValidationIssue[] }; ``` --- ## Zod Adapter The Zod adapter wraps Zod schemas for use in route definitions: ```typescript // Create a schema adapter const userSchema = zodSchema( z.object({ id: z.string().uuid(), email: z.string().email(), name: z.string().min(1).max(100), age: z.number().int().min(0).optional(), }), ); // Use in route definition method: 'GET', path: '/api/users/:userId', responses: { 200: { description: 'User found', schema: userSchema, }, }, }); ``` --- ## TypeBox Adapter TypeBox schemas ARE JSON Schema, making them ideal for OpenAPI generation: ```typescript // Create a schema adapter const userSchema = typeboxSchema( Type.Object({ id: Type.String({ format: 'uuid' }), email: Type.String({ format: 'email' }), name: Type.String({ minLength: 1, maxLength: 100 }), age: Type.Optional(Type.Integer({ minimum: 0 })), }), ); // Use in route definition method: 'GET', path: '/api/users/:userId', responses: { 200: { description: 'User found', schema: userSchema, }, }, }); ``` --- ## Using in Route Definitions Schema adapters are used in route definitions for request and response validation: ```typescript method: 'POST', path: '/api/users', request: { // Request body validation body: { schema: zodSchema( z.object({ email: z.string().email(), name: z.string().min(1), password: z.string().min(8), }), ), }, // Query parameter validation query: { schema: zodSchema( z.object({ sendWelcomeEmail: z.coerce.boolean().optional(), }), ), }, // Path parameter validation params: { schema: zodSchema( z.object({ orgId: z.string().uuid(), }), ), }, // Context validation (from middleware) context: { schema: zodSchema( z.object({ userId: z.string(), }), ), }, }, responses: { 201: { description: 'User created', schema: zodSchema( z.object({ userId: z.string().uuid(), }), ), }, 400: { description: 'Validation error', }, }, }); ``` --- ## Automatic Validation When you register routes with a framework adapter, request validation happens automatically: ```typescript const routes = serverRoutes(userRouter) .handle('users.create', { // req.body, req.query, req.pathParams are already validated and typed requestMapper: (req, ctx) => ({ email: req.body.email, // string (validated) name: req.body.name, // string (validated) createdBy: ctx.userId, // string (from context schema) }), useCase: createUserUseCase, responseMapper: (output) => ({ status: 201 as const, body: { userId: output.userId }, }), }) .build(); ``` If validation fails, a 400 response is returned with structured errors: ```json { "errorCode": "VALIDATION_ERROR", "message": "Request validation failed", "details": [ { "path": ["body", "email"], "message": "Invalid email" } ] } ``` --- ## JSON Schema Generation Schema adapters can generate JSON Schema for OpenAPI documentation: ```typescript const userSchema = zodSchema( z.object({ name: z.string().describe('User full name'), email: z.string().email().describe('User email address'), }), ); // Get JSON Schema const jsonSchema = userSchema.toJsonSchema(); // { // type: 'object', // properties: { // name: { type: 'string', description: 'User full name' }, // email: { type: 'string', format: 'email', description: 'User email address' }, // }, // required: ['name', 'email'], // } ``` Use `generateOpenAPI()` to create a full OpenAPI spec from your router: ```typescript const spec = generateOpenAPI(userRouter, { info: { title: 'User API', version: '1.0.0' }, servers: [{ url: 'https://api.example.com' }], }); ``` --- ## Type Inference Schema adapters provide full type inference: ```typescript const userSchema = zodSchema( z.object({ name: z.string(), age: z.number(), }), ); // Infer types from schema type User = InferOutput; // { name: string; age: number } ``` Types are automatically inferred in handlers: ```typescript .handle('users.create', { requestMapper: (req, ctx) => { // req.body is typed based on the route's body schema const email: string = req.body.email; // TypeScript knows this is string return { email, createdBy: ctx.userId }; }, // ... }) ``` --- ## Value Objects vs Schema Adapters | Aspect | Schema Adapters | Value Objects | |--------|-----------------|---------------| | **Purpose** | Boundary validation | Domain invariants | | **Location** | Route definitions (presentation) | Domain layer | | **Validation** | Schema-based (JSON Schema compatible) | Plain TypeScript | | **When to use** | HTTP request/response validation | Domain concepts | **Schema adapters** validate untrusted input at system boundaries: ```typescript // Route definition with schema validation const createUserRoute = defineRoute({ request: { body: { schema: zodSchema(z.object({ email: z.string().email() })) }, }, }); ``` **Value Objects** enforce domain invariants: ```typescript // Domain validation with business rules class Email extends BaseValueObject { static create(value: string): Email { if (!Email.isValidEmail(value)) { throw new InvariantViolationError({ message: 'Invalid email' }); } return new Email(value); } } ``` --- ## Adapter Comparison | Feature | Zod | TypeBox | |---------|-----|---------| | Bundle size | ~12kb | ~15kb | | Type inference | Excellent | Good | | Error messages | Detailed, customizable | Basic | | JSON Schema | Conversion required | Native (zero-cost) | | Best for | General use | OpenAPI-first APIs | Choose **Zod** for: - Better error messages - More validation features (refinements, transforms) - Familiar API Choose **TypeBox** for: - Native JSON Schema (faster OpenAPI generation) - Better performance - Smaller runtime overhead --- # Error Handling > Typed error hierarchy with error codes for consistent error handling across layers. ## Overview The library provides a comprehensive error hierarchy based on `CodedError`. Each error type has a unique code for programmatic handling and maps to appropriate HTTP status codes. --- ## CodedError Base Class All errors extend `CodedError`: ```typescript abstract class CodedError extends Error { public readonly code: string; constructor(options: { message: string; code: string; cause?: unknown; }); static fromError(cause: unknown): CodedError; } ``` Key features: - `code` - Machine-readable identifier for error handling - `cause` - ES2022 error chaining support - `fromError()` - Factory method for wrapping unknown errors --- ## Error Hierarchy ``` CodedError (base) ├── DomainError │ ├── InvariantViolationError │ └── PartialLoadError ├── UseCaseError │ ├── NotFoundError │ ├── ConflictError │ └── UnprocessableError ├── InfraError │ ├── DbError │ ├── NetworkError │ ├── TimeoutError │ └── ExternalServiceError ├── ControllerError ├── AccessDeniedError ├── InvalidRequestError └── ObjectValidationError ``` --- ## Error Codes Each error has a predefined code: ```typescript // Domain errors ErrorCodes.Domain.DOMAIN_ERROR // 'DOMAIN_ERROR' ErrorCodes.Domain.INVARIANT_VIOLATION // 'INVARIANT_VIOLATION' ErrorCodes.Domain.PARTIAL_LOAD // 'PARTIAL_LOAD' // Use case errors ErrorCodes.App.USE_CASE_ERROR // 'USE_CASE_ERROR' ErrorCodes.App.NOT_FOUND // 'NOT_FOUND' ErrorCodes.App.CONFLICT // 'CONFLICT' ErrorCodes.App.UNPROCESSABLE // 'UNPROCESSABLE' // Infrastructure errors ErrorCodes.Infra.INFRA_ERROR // 'INFRA_ERROR' ErrorCodes.Infra.DB_ERROR // 'DB_ERROR' ErrorCodes.Infra.NETWORK_ERROR // 'NETWORK_ERROR' ErrorCodes.Infra.TIMEOUT_ERROR // 'TIMEOUT_ERROR' ErrorCodes.Infra.EXTERNAL_SERVICE_ERROR // 'EXTERNAL_SERVICE_ERROR' // Presentation errors ErrorCodes.Presentation.CONTROLLER_ERROR // 'CONTROLLER_ERROR' ErrorCodes.Presentation.ACCESS_DENIED // 'ACCESS_DENIED' ErrorCodes.Presentation.INVALID_REQUEST // 'INVALID_REQUEST' // Global errors ErrorCodes.Global.OBJECT_VALIDATION_ERROR // 'OBJECT_VALIDATION_ERROR' ``` --- ## Domain Errors Use for business rule violations in the domain layer: ```typescript class Order { addItem(item: OrderItem): void { if (this.status !== 'draft') { throw new InvariantViolationError({ message: 'Cannot add items to a submitted order', code: 'ORDER_NOT_DRAFT', // Custom code }); } // ... } } ``` ### PartialLoadError Thrown when accessing aggregate fields that weren't loaded from the database. This happens when using [Load State Tracking](/docs/patterns/aggregates#load-state-tracking): ```typescript class OrderAggregate extends BaseAggregateRoot { get items(): readonly OrderItem[] { // Throws PartialLoadError if 'items' wasn't marked as loaded return this.requireLoaded('items'); } } // In use case - handle partial load errors class GetOrderItemsQuery extends BaseInboundAdapter { protected async handle(input: GetOrderItemsInput): Promise { const order = await this.orderRepo.findById(input.orderId); if (!order) { throw new NotFoundError({ message: 'Order not found' }); } try { return { items: order.items }; } catch (error) { if (error instanceof PartialLoadError) { // Re-fetch with items included const orderWithItems = await this.orderRepo.findById( input.orderId, { includeItems: true }, ); return { items: orderWithItems!.items }; } throw error; } } } ``` `PartialLoadError` generates codes like `ITEMS_NOT_LOADED` by default. Use custom error codes via `requireLoaded('items', 'ORDER_ITEMS_MISSING')` for more descriptive errors. --- ## Use Case Errors Use for application-level failures: ```typescript NotFoundError, ConflictError, UnprocessableError, } from '@cosmneo/onion-lasagna/backend/core/onion-layers'; class CreateUserCommand { async execute(input: CreateUserInput): Promise { const existing = await this.userRepo.findByEmail(input.email); if (existing) { throw new ConflictError({ message: `User with email ${input.email} already exists`, code: 'USER_EMAIL_EXISTS', // Custom code }); } // ... } } class GetUserQuery { async execute(input: GetUserInput): Promise { const user = await this.userRepo.findById(input.userId); if (!user) { throw new NotFoundError({ message: `User ${input.userId} not found`, code: 'USER_NOT_FOUND', // Custom code }); } return this.toOutput(user); } } ``` --- ## Infrastructure Errors Use for external system failures. `BaseOutboundAdapter` automatically wraps errors: ```typescript class UserRepository extends BaseOutboundAdapter { constructor(private readonly db: Database) { super(); } // Override to customize error type protected createInfraError(error: unknown, methodName: string): InfraError { return new DbError({ message: `Database error in ${methodName}`, cause: error, }); } async findById(id: string): Promise { // If this throws, it's automatically wrapped in DbError return this.db.query('SELECT * FROM users WHERE id = ?', [id]); } } ``` --- ## Presentation Errors Use for access control and request validation: ```typescript AccessDeniedError, InvalidRequestError, } from '@cosmneo/onion-lasagna/backend/core/onion-layers'; // Access guard class CanEditResourceGuard { async guard(request: Request): Promise { const canEdit = await this.checkPermission(request); if (!canEdit) { throw new AccessDeniedError({ message: 'You do not have permission to edit this resource', }); } return { isAllowed: true }; } } ``` --- ## Validation Errors `ObjectValidationError` includes structured field-level errors: ```typescript // Thrown by validators when validation fails throw new ObjectValidationError({ message: 'Validation failed', validationErrors: [ { field: 'email', message: 'Invalid email format' }, { field: 'password', message: 'Password must be at least 8 characters' }, ], }); ``` The HTTP response transforms `field` to `item` and `code` to `errorCode`: ```json { "message": "Validation failed", "errorCode": "VALIDATION_ERROR", "errorItems": [ { "item": "email", "message": "Invalid email format" }, { "item": "password", "message": "Password must be at least 8 characters" } ] } ``` --- ## HTTP Status Code Mapping Framework integrations map errors to HTTP status codes: | Error Type | HTTP Status | Response | |------------|-------------|----------| | `ObjectValidationError` | 400 Bad Request | Includes validation errors | | `InvalidRequestError` | 400 Bad Request | Includes validation errors | | `AccessDeniedError` | 403 Forbidden | Error message | | `NotFoundError` | 404 Not Found | Error message | | `ConflictError` | 409 Conflict | Error message | | `UnprocessableError` | 422 Unprocessable Entity | Error message | | `DomainError` | 500 Internal Server Error | Masked | | `InfraError` | 500 Internal Server Error | Masked | | `ControllerError` | 500 Internal Server Error | Masked | | Unknown Error | 500 Internal Server Error | Masked | Internal errors (Domain, Infra, Controller) are masked in responses to prevent leaking implementation details. **Masked errors** return HTTP 500 with `{ "message": "An unexpected error occurred", "errorCode": "INTERNAL_ERROR" }`. Your custom error message and code are not exposed to clients. --- ## Translating Domain Errors Since `DomainError` is masked, you must translate business rule violations to `UseCaseError` in the use-case layer if you want clients to see them: ```typescript class DeleteStatusUseCase extends BaseInboundAdapter { protected async handle(input: DeleteStatusInputDto): Promise { const project = await this.projectRepository.findById(input.data.projectId); if (!project) { throw new NotFoundError({ message: 'Project not found' }); } try { project.deleteStatus(input.data.statusId); } catch (error) { // Translate domain errors to use-case errors if (error instanceof StatusNotFoundError) { throw new NotFoundError({ message: error.message, code: 'STATUS_NOT_FOUND' }); } if (error instanceof StatusInUseError) { throw new ConflictError({ message: error.message, code: 'STATUS_IN_USE' }); } if (error instanceof LastStatusError) { throw new UnprocessableError({ message: error.message, code: 'LAST_STATUS' }); } throw error; // Re-throw unknown errors } await this.projectRepository.save(project); } } ``` **When to translate:** - `NotFoundError` (404) - Resource doesn't exist - `ConflictError` (409) - State conflict preventing action - `UnprocessableError` (422) - Business rule violation **When to keep masked:** - Invariant violations that indicate bugs - Internal consistency errors --- ## Error Handling in Route Handlers The unified route handler automatically handles validation errors. When a route defines schemas, validation failures are automatically converted to `ObjectValidationError`: ```typescript const routes = serverRoutes(router) .handle('users.create', { // If validation fails, ObjectValidationError is thrown automatically requestMapper: (req) => ({ email: req.body.email, name: req.body.name, }), useCase: createUserUseCase, responseMapper: (output) => ({ status: 201 as const, body: { userId: output.userId }, }), }) .build(); ``` The framework error handlers then convert `ObjectValidationError` to HTTP 400 responses with structured validation details. --- ## Framework Error Handlers Each framework integration provides an error handler: ```typescript // Hono app.onError((err, c) => onionErrorHandler(err, c)); // Elysia app.onError(({ error }) => onionErrorHandler({ error })); // Fastify app.setErrorHandler(onionErrorHandler); // NestJS @UseFilters(OnionExceptionFilter) ``` --- # Bootstrap Pattern > Wiring dependencies together with a layered bootstrap architecture. ## Overview The bootstrap pattern orchestrates dependency injection across layers. It creates instances in the correct order: **adapters → use cases → routes**. ``` bootstrap/ ├── index.ts # Main orchestration ├── adapters.bootstrap.ts # Infrastructure adapters └── use-cases.bootstrap.ts # Application use cases ``` The unified route system handles the presentation layer wiring automatically through handlers. --- ## Main Orchestration The entry point creates all dependencies in order: ```typescript // bootstrap/index.ts // 1. Create adapters (infrastructure layer) const adapters = createAdapters(); // 2. Create use cases with adapters (application layer) const useCases = createUseCases(adapters); // 3. Create routes with handlers (presentation layer) const routes = createProjectManagementRoutes(useCases); return { adapters, useCases, routes }; } ``` --- ## Adapters Bootstrap Creates infrastructure adapters (repositories, external services): ```typescript // bootstrap/adapters.bootstrap.ts return { projectRepository: new ProjectRepository(), projectQueryRepository: new ProjectQueryRepository(), }; } ``` --- ## Use Cases Bootstrap Creates use cases with injected adapters: ```typescript // bootstrap/use-cases.bootstrap.ts return { createProjectUseCase: new CreateProjectUseCase(adapters.projectRepository), getProjectQuery: new GetProjectQuery(adapters.projectQueryRepository), listProjectsQuery: new ListProjectsQuery(adapters.projectQueryRepository), }; } ``` --- ## Routes (Handlers) Create handlers using the `serverRoutes()` builder: ```typescript // presentation/http/handlers/index.ts return serverRoutes(projectManagementRouter) .handle('projects.create', { requestMapper: (req, ctx) => ({ name: req.body.name, description: req.body.description, createdBy: ctx.userId, }), useCase: useCases.createProjectUseCase, responseMapper: (output) => ({ status: 201 as const, body: { projectId: output.projectId }, }), }) .handle('projects.list', { requestMapper: (req) => ({ page: req.query?.page ?? 1, pageSize: req.query?.pageSize ?? 20, }), useCase: useCases.listProjectsQuery, responseMapper: (output) => ({ status: 200 as const, body: output, }), }) .handle('projects.get', { requestMapper: (req) => ({ projectId: req.pathParams.projectId, }), useCase: useCases.getProjectQuery, responseMapper: (output) => ({ status: 200 as const, body: output, }), }) .build(); } ``` The `serverRoutes()` builder: - Validates requests against route schemas automatically - Provides full type inference for `req` and `ctx` parameters - Handles error conversion to HTTP responses --- ## Using the Bootstrap In your application entry point: ```typescript // src/index.ts // Bootstrap the bounded context const { routes } = bootstrapProjectManagement(); // Create Hono app const app = new Hono(); // Error handler app.onError((err, c) => onionErrorHandler(err, c)); // Register routes with middleware and context extraction registerHonoRoutes(app, routes, { middlewares: [authMiddleware], contextExtractor: (c) => ({ userId: c.get('jwtPayload')?.sub, }), }); ``` --- ## Directory Structure The bootstrap folder should be at the root of your bounded context: ``` bounded-contexts/project-management/ ├── bootstrap/ # Dependency wiring │ ├── index.ts # Main orchestration │ ├── adapters.bootstrap.ts # Infrastructure adapters │ └── use-cases.bootstrap.ts # Application use cases ├── app/ # Application layer │ ├── ports/ # Use case interfaces │ └── use-cases/ # Use case implementations ├── domain/ # Domain layer │ ├── entities/ │ ├── value-objects/ │ └── events/ ├── infra/ # Infrastructure layer │ └── persistence/ # Repository implementations └── presentation/ # Presentation layer └── http/ ├── routes/ # Route definitions ├── handlers/ # Route handlers └── router.ts # Combined router ``` --- ## Splitting Handlers For large bounded contexts, split handlers into separate files: ```typescript // presentation/http/handlers/projects.handlers.ts return serverRoutes(projectManagementRouter) .handle('projects.create', { ... }) .handle('projects.list', { ... }) .buildPartial(); // Returns partial routes } // presentation/http/handlers/tasks.handlers.ts return serverRoutes(projectManagementRouter) .handle('projects.tasks.add', { ... }) .handle('projects.tasks.list', { ... }) .buildPartial(); } // presentation/http/handlers/index.ts return [ ...createProjectHandlers(useCases), ...createTaskHandlers(useCases), ]; } ``` --- ## Benefits - **Clear dependency flow**: Adapters → Use Cases → Routes - **Simplified wiring**: No separate controller or validator bootstrap needed - **Type safety**: Full type inference from route definitions to handlers - **Testability**: Each layer can be tested independently with mocks - **Single entry point**: `bootstrapProjectManagement()` returns everything needed --- # Hono Integration > Register routes and handle errors with Hono framework. ## Installation ```bash bun add hono @hono/node-server ``` Import the Hono integration: ```typescript registerHonoRoutes, onionErrorHandler, } from '@cosmneo/onion-lasagna/http/frameworks/hono'; ``` --- ## Quick Start ```typescript // Bootstrap your bounded context const { routes } = bootstrapProjectManagement(); const app = new Hono(); // Apply error handler app.onError((err, c) => onionErrorHandler(err, c)); // Register routes registerHonoRoutes(app, routes); serve({ fetch: app.fetch, port: 3000 }); ``` --- ## Registering Routes The `registerHonoRoutes` function registers routes created with `serverRoutes()`: ```typescript // Create routes using the builder const routes = serverRoutes(userRouter) .handle('users.get', { requestMapper: (req) => ({ userId: req.pathParams.userId }), useCase: getUserQuery, responseMapper: (output) => ({ status: 200 as const, body: output }), }) .build(); // Register with Hono registerHonoRoutes(app, routes); ``` --- ## Options ```typescript interface RegisterHonoRoutesOptions { prefix?: string; // Route prefix (e.g., '/api/v1') middlewares?: HonoMiddleware[]; // Middlewares applied to all routes contextExtractor?: HonoContextExtractor; // Extract auth context } registerHonoRoutes(app, routes, { prefix: '/api', middlewares: [authMiddleware], contextExtractor: (c) => ({ userId: c.get('jwtPayload')?.sub, }), }); ``` --- ## Context Extraction (Protected Routes) For authenticated routes, provide a `contextExtractor` to pass auth data to handlers: ```typescript // JWT middleware const authMiddleware = jwt({ secret: process.env.JWT_SECRET, }); // Define context type interface AuthContext { userId: string; } // Register with context extraction registerHonoRoutes(app, routes, { middlewares: [authMiddleware], contextExtractor: (c): AuthContext => ({ userId: c.get('jwtPayload')?.sub ?? '', }), }); ``` The context is passed to your handler's `requestMapper`: ```typescript .handle('projects.create', { requestMapper: (req, ctx) => ({ name: req.body.name, createdBy: ctx.userId, // From contextExtractor }), useCase: createProjectUseCase, responseMapper: (output) => ({ status: 201 as const, body: { projectId: output.projectId }, }), }) ``` --- ## Error Handler The `onionErrorHandler` maps domain errors to HTTP responses: ```typescript app.onError((err, c) => onionErrorHandler(err, c)); ``` ### Error Mapping | Error Type | HTTP Status | Body | |------------|-------------|------| | `ObjectValidationError` | 400 | `{ errorCode, message, details }` | | `InvalidRequestError` | 400 | `{ errorCode, message, details }` | | `UseCaseError` | 400 | `{ errorCode, message }` | | `AccessDeniedError` | 403 | `{ errorCode, message }` | | `NotFoundError` | 404 | `{ errorCode, message }` | | `ConflictError` | 409 | `{ errorCode, message }` | | `UnprocessableError` | 422 | `{ errorCode, message }` | | `DomainError` | 500 | Masked | | `InfraError` | 500 | Masked | | Unknown | 500 | Masked | **Masked errors** return `{ "message": "An unexpected error occurred", "errorCode": "INTERNAL_ERROR" }`. Domain and infrastructure errors are masked to prevent leaking implementation details. --- ## Custom Error Handling Log errors before handling: ```typescript app.onError((err, c) => { console.error('Error:', err); return onionErrorHandler(err, c); }); ``` --- ## Complete Example ```typescript // Bootstrap const { routes } = bootstrapProjectManagement(); // Create app const app = new Hono(); // Middleware app.use('*', logger()); app.use('*', cors()); // Error handling app.onError((err, c) => { console.error('Error:', err); return onionErrorHandler(err, c); }); // Health check (no auth) app.get('/health', (c) => c.json({ status: 'ok' })); // Auth middleware const authMiddleware = jwt({ secret: process.env.JWT_SECRET ?? 'dev-secret', }); // API routes with auth registerHonoRoutes(app, routes, { middlewares: [authMiddleware], contextExtractor: (c) => ({ userId: c.get('jwtPayload')?.sub, }), }); // Start server serve({ fetch: app.fetch, port: 3000 }); console.log('Server running on http://localhost:3000'); ``` --- ## Cloudflare Workers ```typescript const app = new Hono(); app.onError((err, c) => onionErrorHandler(err, c)); registerHonoRoutes(app, routes); ``` ```toml # wrangler.toml name = "my-worker" main = "src/index.ts" compatibility_date = "2024-01-01" ``` --- # Elysia Integration > Register routes and handle errors with Elysia framework. ## Installation ```bash bun add elysia @elysiajs/cors @elysiajs/jwt ``` Import the Elysia integration: ```typescript registerElysiaRoutes, onionErrorHandler, type ElysiaContext, type ElysiaMiddleware, } from '@cosmneo/onion-lasagna/http/frameworks/elysia'; ``` --- ## Quick Start ```typescript const { routes } = bootstrapProjectManagement(); const app = new Elysia() .onError(({ error }) => onionErrorHandler({ error })) .get('/health', () => ({ status: 'ok' })); registerElysiaRoutes(app, routes); app.listen(3000); ``` --- ## Registering Routes The `registerElysiaRoutes` function registers routes with your Elysia app: ```typescript // Create routes using the builder const routes = serverRoutes(userRouter) .handle('users.get', { requestMapper: (req) => ({ userId: req.pathParams.userId }), useCase: getUserQuery, responseMapper: (output) => ({ status: 200 as const, body: output }), }) .build(); // Register with Elysia registerElysiaRoutes(app, routes); ``` **Note:** `registerElysiaRoutes` is a function that mutates the app, not a plugin. Call it with `app` as the first argument. --- ## Options ```typescript interface RegisterElysiaRoutesOptions { middlewares?: ElysiaMiddleware[]; // Middlewares applied to all routes contextExtractor?: ElysiaContextExtractor; // Extract auth context } registerElysiaRoutes(app, routes, { middlewares: [authMiddleware], contextExtractor: (ctx) => ({ userId: ctx.store['userId'], }), }); ``` --- ## Context Extraction (Protected Routes) For authenticated routes, provide a `contextExtractor` to pass auth data to handlers: ```typescript // Auth middleware const authMiddleware: ElysiaMiddleware = async (ctx: ElysiaContext) => { const authHeader = ctx.headers['authorization']; if (!authHeader?.startsWith('Bearer ')) { return new Response(JSON.stringify({ message: 'Unauthorized' }), { status: 401, headers: { 'Content-Type': 'application/json' }, }); } const token = authHeader.slice(7); const payload = decodeJwt(token); ctx.store['userId'] = payload.sub; return undefined; // Continue to handler }; // Register with context extraction registerElysiaRoutes(app, routes, { middlewares: [authMiddleware], contextExtractor: (ctx: ElysiaContext) => ({ userId: ctx.store['userId'] as string, }), }); ``` --- ## Error Handler The `onionErrorHandler` maps domain errors to HTTP responses: ```typescript app.onError(({ error }) => onionErrorHandler({ error })); ``` ### Error Mapping | Error Type | HTTP Status | Body | |------------|-------------|------| | `ObjectValidationError` | 400 | `{ errorCode, message, details }` | | `InvalidRequestError` | 400 | `{ errorCode, message, details }` | | `UseCaseError` | 400 | `{ errorCode, message }` | | `AccessDeniedError` | 403 | `{ errorCode, message }` | | `NotFoundError` | 404 | `{ errorCode, message }` | | `ConflictError` | 409 | `{ errorCode, message }` | | `UnprocessableError` | 422 | `{ errorCode, message }` | | `DomainError` | 500 | Masked | | `InfraError` | 500 | Masked | | Unknown | 500 | Masked | --- ## Complete Example ```typescript registerElysiaRoutes, onionErrorHandler, type ElysiaContext, type ElysiaMiddleware, } from '@cosmneo/onion-lasagna/http/frameworks/elysia'; const { routes } = bootstrapProjectManagement(); // Auth middleware const authMiddleware: ElysiaMiddleware = async (ctx: ElysiaContext) => { const authHeader = ctx.headers['authorization']; if (!authHeader?.startsWith('Bearer ')) { return new Response(JSON.stringify({ message: 'Unauthorized' }), { status: 401, headers: { 'Content-Type': 'application/json' }, }); } const token = authHeader.slice(7); try { const [, payloadBase64] = token.split('.'); const payload = JSON.parse(Buffer.from(payloadBase64!, 'base64').toString()); ctx.store['userId'] = payload.sub; } catch { return new Response(JSON.stringify({ message: 'Invalid token' }), { status: 401, headers: { 'Content-Type': 'application/json' }, }); } return undefined; }; // Create app const app = new Elysia() .use(cors()) .onError(({ error }) => { console.error('Error:', error); return onionErrorHandler({ error }); }) .get('/health', () => ({ status: 'ok' })); // Register routes with auth registerElysiaRoutes(app, routes, { middlewares: [authMiddleware], contextExtractor: (ctx: ElysiaContext) => ({ userId: ctx.store['userId'] as string, }), }); // Start server app.listen(3000, () => { console.log('Server running on http://localhost:3000'); }); ``` --- # Fastify Integration > Register routes and handle errors with Fastify framework. ## Installation ```bash bun add fastify @fastify/cors @fastify/jwt ``` Import the Fastify integration: ```typescript registerFastifyRoutes, onionErrorHandler, } from '@cosmneo/onion-lasagna/http/frameworks/fastify'; ``` --- ## Quick Start ```typescript const { routes } = bootstrapProjectManagement(); const app = Fastify(); // Apply error handler app.setErrorHandler(onionErrorHandler); // Register routes registerFastifyRoutes(app, routes); app.listen({ port: 3000 }); ``` --- ## Registering Routes The `registerFastifyRoutes` function registers routes with Fastify: ```typescript // Create routes using the builder const routes = serverRoutes(userRouter) .handle('users.get', { requestMapper: (req) => ({ userId: req.pathParams.userId }), useCase: getUserQuery, responseMapper: (output) => ({ status: 200 as const, body: output }), }) .build(); // Register with Fastify registerFastifyRoutes(app, routes); ``` --- ## Options ```typescript interface RegisterFastifyRoutesOptions { prefix?: string; // Route prefix (e.g., '/api') preHandlers?: FastifyMiddleware[]; // Fastify preHandlers (preferred) middlewares?: FastifyMiddleware[]; // Alias for preHandlers contextExtractor?: FastifyContextExtractor; // Extract auth context } registerFastifyRoutes(app, routes, { prefix: '/api', preHandlers: [authMiddleware], contextExtractor: (request) => ({ userId: request.user?.userId, }), }); ``` --- ## Context Extraction (Protected Routes) For authenticated routes, provide a `contextExtractor` to pass auth data to handlers: ```typescript // Register JWT plugin await app.register(fastifyJwt, { secret: process.env.JWT_SECRET, }); // Auth preHandler const authMiddleware = async (request: FastifyRequest, reply: FastifyReply) => { try { await request.jwtVerify(); } catch (err) { reply.status(401).send({ message: 'Unauthorized' }); } }; // Register with context extraction registerFastifyRoutes(app, routes, { preHandlers: [authMiddleware], contextExtractor: (request) => ({ userId: request.user?.sub, }), }); ``` --- ## Error Handler The `onionErrorHandler` maps domain errors to HTTP responses: ```typescript app.setErrorHandler(onionErrorHandler); ``` ### Error Mapping | Error Type | HTTP Status | Body | |------------|-------------|------| | `ObjectValidationError` | 400 | `{ errorCode, message, details }` | | `InvalidRequestError` | 400 | `{ errorCode, message, details }` | | `UseCaseError` | 400 | `{ errorCode, message }` | | `AccessDeniedError` | 403 | `{ errorCode, message }` | | `NotFoundError` | 404 | `{ errorCode, message }` | | `ConflictError` | 409 | `{ errorCode, message }` | | `UnprocessableError` | 422 | `{ errorCode, message }` | | `DomainError` | 500 | Masked | | `InfraError` | 500 | Masked | | Unknown | 500 | Masked | --- ## Custom Error Handling Log errors before handling: ```typescript app.setErrorHandler((error, request, reply) => { console.error('Error:', error); return onionErrorHandler(error, request, reply); }); ``` --- ## Complete Example ```typescript const { routes } = bootstrapProjectManagement(); const app = Fastify({ logger: true }); // Plugins await app.register(cors); await app.register(jwt, { secret: process.env.JWT_SECRET ?? 'dev-secret', }); // Error handling app.setErrorHandler((error, request, reply) => { console.error('Error:', error); return onionErrorHandler(error, request, reply); }); // Health check (no auth) app.get('/health', () => ({ status: 'ok' })); // Auth preHandler const authMiddleware = async (request, reply) => { try { await request.jwtVerify(); } catch (err) { reply.status(401).send({ message: 'Unauthorized' }); } }; // API routes with auth registerFastifyRoutes(app, routes, { preHandlers: [authMiddleware], contextExtractor: (request) => ({ userId: request.user?.sub, }), }); // Start server try { await app.listen({ port: 3000 }); console.log('Server running on http://localhost:3000'); } catch (err) { app.log.error(err); process.exit(1); } ``` --- # NestJS Integration > Use onion-lasagna patterns with NestJS framework. ## Installation ```bash bun add @nestjs/core @nestjs/common @nestjs/platform-express ``` Import the NestJS integration: ```typescript OnionRequest, OnionExceptionFilter, type ContextualRawHttpRequest, type NestContextExtractor, } from '@cosmneo/onion-lasagna/http/frameworks/nestjs'; ``` --- ## Overview NestJS integration differs from other frameworks because NestJS uses decorators and its own module system. The integration provides: - `OnionRequest` - Decorator to extract HTTP request data - `OnionExceptionFilter` - Exception filter for domain errors - Type-safe context extraction for authenticated routes --- ## Quick Start ```typescript OnionRequest, OnionExceptionFilter, type ContextualRawHttpRequest, } from '@cosmneo/onion-lasagna/http/frameworks/nestjs'; @Controller('api/users') @UseFilters(OnionExceptionFilter) constructor(private readonly routeHandlers: Map) {} @Get(':userId') async getUser(@OnionRequest() request: RawHttpRequest) { const route = this.routeHandlers.get('GET:/api/users/{userId}'); return route.handler(request); } } ``` --- ## @OnionRequest Decorator Extracts an `HttpRequest` object from the NestJS request: ```typescript @Get(':id') async getItem(@OnionRequest() request: RawHttpRequest) { // request contains: pathParams, query, body, headers } ``` The `RawHttpRequest` structure (from `@cosmneo/onion-lasagna/http/server`): ```typescript interface RawHttpRequest { pathParams: Record; query: Record; body: unknown; headers: Record; } ``` ### With Context Extractor (Protected Routes) For routes that need authentication context, pass a context extractor: ```typescript OnionRequest, type NestContextExtractor, type ContextualRawHttpRequest, } from '@cosmneo/onion-lasagna/http/frameworks/nestjs'; interface AuthContext { userId: string; } const extractAuthContext: NestContextExtractor = (ctx: ExecutionContext) => { const request = ctx.switchToHttp().getRequest(); return { userId: request.user?.userId ?? '', }; }; @Controller('api/projects') @UseGuards(JwtAuthGuard) @Get() async list( @OnionRequest(extractAuthContext) request: ContextualRawHttpRequest, ) { // request.context.userId is type-safe } } ``` --- ## OnionExceptionFilter Maps domain errors to HTTP responses: ```typescript // Apply to controller @Controller('users') @UseFilters(OnionExceptionFilter) // Or apply globally in main.ts app.useGlobalFilters(new OnionExceptionFilter()); ``` ### Error Mapping | Error Type | HTTP Status | |------------|-------------| | `ObjectValidationError` | 400 | | `InvalidRequestError` | 400 | | `UseCaseError` | 400 | | `AccessDeniedError` | 403 | | `NotFoundError` | 404 | | `ConflictError` | 409 | | `UnprocessableError` | 422 | | Internal errors | 500 | --- ## Using Unified Route Handlers For consistency with other frameworks, you can use the unified route handlers: ```typescript OnionRequest, type ContextualRawHttpRequest, type NestContextExtractor, } from '@cosmneo/onion-lasagna/http/frameworks/nestjs'; interface AuthContext { userId: string; } const extractAuthContext: NestContextExtractor = (ctx) => { const request = ctx.switchToHttp().getRequest(); return { userId: request.user?.userId ?? '' }; }; @Controller('api/projects') @UseGuards(JwtAuthGuard) constructor( @Inject('ROUTE_HANDLERS') private readonly routeHandlers: Map, ) {} private async callHandler( method: string, path: string, request: ContextualRawHttpRequest, res: Response, ) { const route = this.routeHandlers.get(`${method}:${path}`); if (!route) throw new Error(`Route not found: ${method}:${path}`); const response = await route.handler(request, request.context); res.status(response.status); return response.body; } @Post() async create( @OnionRequest(extractAuthContext) request: ContextualRawHttpRequest, @Res({ passthrough: true }) res: Response, ) { return this.callHandler('POST', '/api/projects', request, res); } @Get() async list( @OnionRequest(extractAuthContext) request: ContextualRawHttpRequest, @Res({ passthrough: true }) res: Response, ) { return this.callHandler('GET', '/api/projects', request, res); } } ``` --- ## Module Setup ```typescript const { routes } = bootstrapProjectManagement(); // Create a map of routes for injection const routeHandlersMap = new Map( routes.map((route) => [`${route.method}:${route.path}`, route]), ); @Module({ controllers: [ProjectsController], providers: [ { provide: 'ROUTE_HANDLERS', useValue: routeHandlersMap, }, ], }) ``` --- ## Global Setup Configure the exception filter globally in `main.ts`: ```typescript async function bootstrap() { const app = await NestFactory.create(AppModule); // Global exception filter app.useGlobalFilters(new OnionExceptionFilter()); await app.listen(3000); } bootstrap(); ``` --- ## Complete Example ```typescript // main.ts async function bootstrap() { const app = await NestFactory.create(AppModule); app.enableCors(); app.useGlobalFilters(new OnionExceptionFilter()); await app.listen(process.env.PORT ?? 3000); console.log(`Server running on http://localhost:${process.env.PORT ?? 3000}`); } bootstrap(); ``` ```typescript // projects.controller.ts OnionRequest, type ContextualRawHttpRequest, type NestContextExtractor, } from '@cosmneo/onion-lasagna/http/frameworks/nestjs'; interface AuthContext { userId: string; } const extractAuthContext: NestContextExtractor = (ctx) => { const request = ctx.switchToHttp().getRequest(); return { userId: request.user?.userId ?? '' }; }; @Controller('api/projects') @UseGuards(JwtAuthGuard) constructor( @Inject('ROUTE_HANDLERS') private readonly routeHandlers: Map, ) {} private async callHandler( method: string, path: string, request: ContextualRawHttpRequest, res: Response, ) { const route = this.routeHandlers.get(`${method}:${path}`); if (!route) throw new Error(`Route not found: ${method}:${path}`); const response = await route.handler(request, request.context); res.status(response.status); if (response.headers) { for (const [key, value] of Object.entries(response.headers)) { res.setHeader(key, value); } } return response.body; } @Post() async create( @OnionRequest(extractAuthContext) request: ContextualRawHttpRequest, @Res({ passthrough: true }) res: Response, ) { return this.callHandler('POST', '/api/projects', request, res); } @Get() async list( @OnionRequest(extractAuthContext) request: ContextualRawHttpRequest, @Res({ passthrough: true }) res: Response, ) { return this.callHandler('GET', '/api/projects', request, res); } @Get(':projectId') async get( @OnionRequest(extractAuthContext) request: ContextualRawHttpRequest, @Res({ passthrough: true }) res: Response, ) { return this.callHandler('GET', '/api/projects/{projectId}', request, res); } @Delete(':projectId') @HttpCode(204) async delete( @OnionRequest(extractAuthContext) request: ContextualRawHttpRequest, @Res({ passthrough: true }) res: Response, ) { return this.callHandler('DELETE', '/api/projects/{projectId}', request, res); } } ``` --- # New Endpoint > Step-by-step guide to creating a new HTTP endpoint ## Overview Creating a new endpoint involves these steps: 1. Define the route with schemas 2. Add the route to your router 3. Create the use case 4. Create the handler 5. Wire in bootstrap --- ## Steps ### 1. Define the Route Create a route definition with schemas for request validation and response documentation: ```typescript // presentation/http/routes/users.routes.ts method: 'GET', path: '/api/users/:userId', request: { params: { schema: zodSchema( z.object({ userId: z.string().uuid(), }), ), }, }, responses: { 200: { description: 'User found', schema: zodSchema( z.object({ id: z.string().uuid(), email: z.string().email(), name: z.string(), }), ), }, 404: { description: 'User not found', }, }, docs: { summary: 'Get user by ID', tags: ['Users'], operationId: 'getUser', }, }); ``` ### 2. Add to Router Add the route to your router definition: ```typescript // presentation/http/router.ts users: { get: getUserRoute, create: createUserRoute, list: listUsersRoute, }, }); ``` ### 3. Create the Use Case Define the use case port and implementation: ```typescript // app/ports/get-user.query.port.ts interface GetUserQueryPort { execute(input: GetUserInput): Promise; } interface GetUserInput { userId: string; } interface GetUserOutput { id: string; email: string; name: string; } ``` ```typescript // app/use-cases/get-user.query.ts class GetUserQuery extends BaseInboundAdapter implements GetUserQueryPort { constructor(private readonly userRepo: UserRepositoryPort) { super(); } protected async handle(input: GetUserInput): Promise { const user = await this.userRepo.findById(input.userId); if (!user) { throw new NotFoundError({ message: `User ${input.userId} not found`, code: 'USER_NOT_FOUND', }); } return { id: user.id.value, email: user.email.value, name: user.name, }; } } ``` ### 4. Create the Handler Add the handler using the `serverRoutes()` builder: ```typescript // presentation/http/handlers/users.handlers.ts return serverRoutes(userRouter) .handle('users.get', { requestMapper: (req) => ({ userId: req.pathParams.userId, }), useCase: useCases.getUserQuery, responseMapper: (output) => ({ status: 200 as const, body: { id: output.id, email: output.email, name: output.name, }, }), }) .buildPartial(); } ``` ### 5. Wire in Bootstrap Add the use case to your bootstrap: ```typescript // bootstrap/use-cases.bootstrap.ts return { // ... other use cases getUserQuery: new GetUserQuery(adapters.userRepository), }; } ``` --- ## Route Definition Options ### Request Configuration | Property | Description | |----------|-------------| | `request.body.schema` | Request body schema | | `request.query.schema` | Query parameters schema | | `request.params.schema` | Path parameters schema | | `request.headers.schema` | Request headers schema | | `request.context.schema` | Context schema (from middleware) | ### Response Configuration | Property | Description | |----------|-------------| | `responses[status].description` | Response description for OpenAPI | | `responses[status].schema` | Response body schema | ### Documentation | Property | Description | |----------|-------------| | `docs.summary` | Short description for OpenAPI | | `docs.description` | Detailed description | | `docs.tags` | OpenAPI tags for grouping | | `docs.operationId` | Unique operation identifier | --- ## Void Operations (DELETE, UPDATE) For operations that return no content (HTTP 204): ### Route Definition ```typescript method: 'DELETE', path: '/api/users/:userId', request: { params: { schema: zodSchema( z.object({ userId: z.string().uuid(), }), ), }, }, responses: { 204: { description: 'User deleted', }, 404: { description: 'User not found', }, }, docs: { summary: 'Delete a user', tags: ['Users'], operationId: 'deleteUser', }, }); ``` ### Handler ```typescript .handle('users.delete', { requestMapper: (req) => ({ userId: req.pathParams.userId, }), useCase: useCases.deleteUserUseCase, responseMapper: () => ({ status: 204 as const, body: undefined, }), }) ``` --- ## Protected Endpoints For endpoints requiring authentication, add a context schema: ### Route with Context ```typescript method: 'POST', path: '/api/posts', request: { body: { schema: zodSchema( z.object({ title: z.string().min(1), content: z.string(), }), ), }, context: { schema: zodSchema( z.object({ userId: z.string(), }), ), }, }, responses: { 201: { description: 'Post created', schema: zodSchema(z.object({ postId: z.string() })), }, }, }); ``` ### Handler with Context ```typescript .handle('posts.create', { requestMapper: (req, ctx) => ({ title: req.body.title, content: req.body.content, authorId: ctx.userId, // From context schema }), useCase: useCases.createPostUseCase, responseMapper: (output) => ({ status: 201 as const, body: { postId: output.postId }, }), }) ``` The context is provided by the `contextExtractor` in framework registration: ```typescript registerHonoRoutes(app, routes, { middlewares: [authMiddleware], contextExtractor: (c) => ({ userId: c.get('jwtPayload')?.sub, }), }); ``` --- ## Checklist - [ ] Route definition created with schemas - [ ] Route added to router - [ ] Use case port defined - [ ] Use case implemented with `BaseInboundAdapter` - [ ] Handler added using `serverRoutes()` builder - [ ] Use case wired in bootstrap - [ ] Routes exported from bootstrap --- # New Bounded Context > Step-by-step guide to creating a new Bounded Context ## Structure ``` bounded-contexts/{bc-name}/ ├── app/ │ ├── ports/ │ │ ├── inbound/ │ │ └── outbound/ │ └── use-cases/ │ ├── queries/ │ └── commands/ ├── domain/ │ ├── aggregates/ │ ├── entities/ │ ├── value-objects/ │ └── exceptions/ └── infra/ ``` --- ## Steps ### 1. Create Folder Structure Create all directories under `bounded-contexts/{bc-name}/`. ### 2. Define Domain Model Start with aggregates and value objects using the library base classes: ```typescript:domain/value-objects/user-id.vo.ts static override generate(): UserId { return new UserId(crypto.randomUUID()); } static override create(value: string): UserId { BaseUuidV4Vo.create(value); // Validates UUID format return new UserId(value); } } ``` ```typescript:domain/aggregates/user.aggregate.ts interface UserProps { email: string; name: string; } static create(data: CreateUserData): UserAggregate { ... } static reconstitute(data: UserData, version: number): UserAggregate { ... } } ``` ### 3. Define Domain Exceptions Domain exceptions extend `DomainError` for consistent error handling: ```typescript:domain/exceptions/user-not-found.error.ts constructor(userId: string) { super({ message: `User not found: ${userId}`, code: 'USER_NOT_FOUND', }); } } ``` Domain errors are masked (return 500) in HTTP responses. Translate them to use case errors (`NotFoundError`, `ConflictError`, `UnprocessableError`) in your use cases if you want clients to see meaningful error messages. ### 4. Define Outbound Ports ```typescript:app/ports/outbound/user.repository.outbound.ts interface UserReadRepositoryOutboundPort { findById(id: UserId): Promise; } interface UserWriteRepositoryOutboundPort { save(user: UserAggregate): Promise; } ``` ### 5. Define Inbound Ports ```typescript:app/ports/inbound/create-user.command.inbound.ts interface CreateUserCommandInboundPort { execute(input: CreateUserInput): Promise; } ``` ### 6. Implement Use Cases ```typescript:app/use-cases/commands/create-user.command.ts class CreateUserCommand implements CreateUserCommandInboundPort { constructor( private readonly readRepo: UserReadRepositoryOutboundPort, private readonly writeRepo: UserWriteRepositoryOutboundPort, ) {} async execute(input: CreateUserInput): Promise { const user = UserAggregate.create(input); await this.writeRepo.save(user); return { id: user.id.value }; } } ``` ### 7. Implement Infrastructure (if BC-scoped) ```typescript:infra/implementations/user/user.repository.ts class UserRepository implements UserReadRepositoryOutboundPort, UserWriteRepositoryOutboundPort { constructor(private readonly persistence: UserPersistence) {} // ... } ``` --- ## Checklist - [ ] Folder structure created - [ ] Value objects defined - [ ] Aggregates defined - [ ] Domain exceptions created - [ ] Outbound ports defined - [ ] Inbound ports defined - [ ] Use cases implemented - [ ] Infrastructure implemented (if BC-scoped) --- # CLI Overview > Planned tooling to automate common tasks **Coming Soon** - The features described in this section are planned for future releases and are not yet available. ## Current State vs Future | Task | Current | Future | |------|---------|--------| | Controller wiring | Manual in bootstrap | CLI generated | | HTTP client | Manual | CLI generated | | New endpoint | 6+ files created manually | CLI scaffolded | | Dependency rules | Developer discipline | Linter enforced | --- ## Planned Tools | Tool | Purpose | |------|---------| | **Controller Generator** | Auto-wire validate → guard → map → execute → respond | | **HTTP Client Generator** | TypeScript-safe clients from request/response schemas | | **Endpoint Scaffold** | Generate all files for a new endpoint | | **Linter Rules** | Enforce one-way module deps, layer import rules | --- ## See Also These features are planned for future releases: - Controller Wiring - HTTP Client Generation - Endpoint Scaffolding - Linter Rules --- # Endpoint Scaffolding > CLI command to generate all files for a new endpoint **Coming Soon** - This feature is planned for a future release and is not yet available. ## Command ```bash onion-cli generate endpoint users/get-user --method GET --path /users/:userId ``` --- ## Generated Files ``` presentation/http/users/get-user/ ├── endpoint.metadata.ts ├── request.dto.ts ├── response.dto.ts ├── access-guard.ts ├── to-use-case.mapper.ts └── to-response.mapper.ts ``` Plus stubs in the BC: ``` app/ports/inbound/get-user.query.inbound.ts app/use-cases/queries/get-user.query.ts ``` --- ## Options | Flag | Description | |------|-------------| | `--method` | HTTP method (GET, POST, etc.) | | `--path` | URL path with params | | `--bc` | Target bounded context | | `--orchestration` | Create as composition/workflow instead | --- # Controller Wiring > Auto-generate controllers that wire all endpoint components **Coming Soon** - This feature is planned for a future release and is not yet available. ## Current Manual Approach ```typescript:bootstrap/user.bootstrap.ts const userPersistence = new UserPersistence(db); const userRepo = new UserRepository(userPersistence); const getUserQuery = new GetUserQuery(userRepo); const getUserAccessGuard = new GetUserAccessGuard(); // Controller manually wires everything class GetUserController { static async execute(event: APIGatewayEvent) { const request = parseRequest(event); // 1. Validate const validated = getUserRequestSchema.parse(request); // 2. Check access const guardResult = await getUserAccessGuard.guard(validated); if (!guardResult.allowed) throw new ForbiddenError(); // 3. Map to input const input = toGetUserInput(validated); // 4. Execute const output = await getUserQuery.execute(input); // 5. Map to response return toGetUserResponse(output); } } ``` --- ## Future Generated Approach ```bash onion-cli generate controller get-user ``` Generates a controller that: - Reads endpoint metadata - Wires validation, guard, mappers, use case - Handles exception mapping - Outputs framework-specific handler --- # HTTP Client Generation > Generate TypeScript-safe HTTP clients from schemas **Coming Soon** - This feature is planned for a future release and is not yet available. ## Goal Frontend developers get auto-completed, type-safe API calls. --- ## Source of Truth - `presentation/http/{resource}/{endpoint}/request.dto.ts` - `presentation/http/{resource}/{endpoint}/response.dto.ts` --- ## Generated Output ```typescript:packages/shared/clients/http/user.client.ts class UserClient { constructor( private readonly baseUrl: string, private readonly auth: AuthConfig, ) {} async getUser(userId: string): Promise { const response = await fetch(`${this.baseUrl}/users/${userId}`, { headers: this.auth.headers(), }); return getUserResponseSchema.parse(await response.json()); } async createUser(data: CreateUserRequest['body']): Promise { // ... } } ``` --- ## Usage ```typescript const userClient = new UserClient('https://api.example.com', authConfig); const user = await userClient.getUser('123'); // Fully typed! ``` --- # Linter Rules > ESLint rules to enforce architecture constraints **Coming Soon** - This feature is planned for a future release and is not yet available. ## Rules ### One-Way Module Dependencies Modules can import from each other, but only one direction. ```typescript // ✅ OK: Module A imports from Module B // ❌ ERROR: Module B also imports from Module A (creates cycle) ``` ### Layer Import Restrictions | From | Can Import | |------|------------| | BC | Shared only | | Infrastructure | BC ports, domain, shared | | Orchestrations | BC ports + use cases, shared | | Presentation | All inner layers | ```typescript // In bounded-contexts/user/ // ❌ ERROR: Cannot import from infrastructure ``` ### No `implements` in Persistence/External ```typescript // ❌ ERROR: Only implementations/ can use `implements` class UserPersistence implements UserRepositoryOutboundPort { ... } ``` --- ## Configuration ```javascript:eslint.config.js onionLasagnaPlugin.configs.recommended, ]; ``` --- # Anti-Patterns > What NOT to do when implementing Onion Lasagna Architecture ## Bounded Context ### ❌ Framework Code in Domain ```typescript // BAD: Using Express in domain class CreateUserCommand { execute(req: Request, res: Response) { ... } } ``` ### ❌ Environment Variables in Domain ```typescript // BAD: Reading env vars in BC const apiKey = process.env.API_KEY; ``` ### ❌ Direct Cross-BC Import ```typescript // BAD: Importing from another BC ``` --- ## Infrastructure ### ❌ Business Logic in Repository ```typescript // BAD: Price calculation in repository async findWithDiscount(id: string): Promise { const product = await this.db.find(id); product.price = product.price * 0.9; // BAD! return product; } ``` ### ❌ `implements` in Persistence/External ```typescript // BAD: Persistence should NOT implement port class UserPersistence implements UserRepositoryOutboundPort { ... } // GOOD: Only implementations/ use `implements` class UserRepository implements UserRepositoryOutboundPort { constructor(private persistence: UserPersistence) {} } ``` --- ## Presentation ### ❌ Business Logic in Access Guard ```typescript // BAD: Custom logic in guard async guard(request: Request): Promise { const discount = request.user.isVIP ? 0.2 : 0; // BAD! return { allowed: true, discount }; } ``` ### ❌ Business Logic in Mapper ```typescript // BAD: Calculation in mapper function toResponse(order: Order): OrderResponse { return { ...order, total: order.items.reduce((sum, i) => sum + i.price, 0), // BAD! }; } ``` --- ## Dependencies ### ❌ Outer Layer Import in Inner Layer ```typescript // BAD: BC importing from Infrastructure ``` ### ❌ Two-Way Module Dependencies ```typescript // BAD: Module A imports Module B, AND Module B imports Module A // This creates circular dependency ``` --- ## Naming ### ❌ Inconsistent Port Naming ```typescript // BAD: Missing port suffix interface UserRepository { ... } // GOOD interface UserRepositoryOutboundPort { ... } ``` ---