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 TypePurposeWhere Defined
Request BodyHTTP request body shapeRoute definition (request.body.schema)
Query ParamsHTTP query parametersRoute definition (request.query.schema)
Path ParamsURL path parametersRoute definition (request.params.schema)
Response BodyHTTP response shapeRoute definition (responses.{status}.schema)
Use Case InputInput to use caseTypeScript interface in use case port
Use Case OutputOutput from use caseTypeScript interface in use case port

Schema-Based Validation

Validation is defined in route definitions using schema adapters:

import { defineRoute } from '@cosmneo/onion-lasagna/http/route';
import { zodSchema, z } from '@cosmneo/onion-lasagna/http/schema/zod';

// Define the route with schemas
export const createUserRoute = defineRoute({
  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:

// 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<CreateUserOutput>;
}

Request and Response Mapping

Transform between HTTP layer and use case layer using mappers:

import { serverRoutes } from '@cosmneo/onion-lasagna/http/server';
import { userRouter } from './router';

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

import { zodSchema, z } from '@cosmneo/onion-lasagna/http/schema/zod';

const userSchema = zodSchema(
  z.object({
    id: z.string().uuid(),
    email: z.string().email(),
    name: z.string().min(1).max(100),
  }),
);

TypeBox Adapter

import { typeboxSchema, Type } from '@cosmneo/onion-lasagna/http/schema/typebox';

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:

{
  "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:

// Auth context schema
export const authContextSchema = zodSchema(
  z.object({
    userId: z.string(),
  }),
);

export const createProjectRoute = defineRoute({
  method: 'POST',
  path: '/api/projects',
  request: {
    body: {
      schema: zodSchema(
        z.object({
          name: z.string().min(1),
        }),
      ),
    },
    context: {
      schema: authContextSchema,
    },
  },
  // ...
});

Access context in your handler:

.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:

import type {
  InferRouteBody,
  InferRouteQuery,
  InferRoutePathParams,
  InferRouteSuccessResponse,
  InferRouteResponse,
} from '@cosmneo/onion-lasagna/http';

// Infer request types
type CreateUserBody = InferRouteBody<typeof createUserRoute>;
// { email: string; name: string; password: string }

// Infer the first 2xx response type
type CreateUserResponse = InferRouteSuccessResponse<typeof createUserRoute>;
// { userId: string }

// Infer response for a specific status code
type NotFoundResponse = InferRouteResponse<typeof getUserRoute, 404>;
// { message: string }

DTO vs Value Object

AspectDTOValue Object
PurposeTransfer data across boundariesRepresent domain concept
LocationPresentation / App layerDomain layer
FormPlain interface + schemaClass with behavior
ValidationSchema adapter at boundarycreate() factory method
IdentityNoneCompared by value