Selecciona el idioma

Cómo construir una API robusta y escalable en TypeScript con Hono y principios Clean Architecture
viernes, 10 de octubre de 2025 15 minutos de lectura

📜 Introducción


Construir una API robusta y escalable no se trata únicamente de elegir las librerías “correctas”, sino de diseñar una arquitectura que resista el crecimiento y el cambio.

En este artículo te mostraré cómo estructurar una API moderna en TypeScript, siguiendo los principios de Clean Architecture, con un enfoque práctico centrado exclusivamente en código, organización y buenas prácticas.

construction

🔧 Stack Tecnológico


Para este ejemplo usaremos un stack rápido y sencillo:

  • Framework HTTP: Hono — rápido, minimalista y con excelente soporte para TypeScript.
  • Inyección de dependencias: InversifyJS — nos permite aplicar inversión de control y mantener el código desacoplado.
  • Lenguaje: TypeScript, para garantizar tipado estático y una arquitectura más predecible.

🏗️ Estructura del proyecto


Una buena organización no solo mejora la legibilidad, sino que también evita el acoplamiento innecesario y facilita el mantenimiento a largo plazo. Os presentaré una estructura de proyecto siguiendo principios de Clean Architecture y DDD.

En esta API, cada módulo representará un contexto del negocio (por ejemplo: user, auth, billing, product, etc.). Cada módulo agrupará todas las piezas relacionadas con ese contexto: entidades, casos de uso, controladores, etc.

src/
└── <module_name>/
    ├── domain/                                             # Core de la aplicación. Reglas que nunca deben cambiar por motivos técnicos.
    │   ├── entities/<entity_name>.entity.ts                # Objeto con una identidad única y un conjunto de atributos que representan algo en el negocio. La entidad NO es una tabla de base de datos, pero normalmente tiene una correspondencia con una.
    │   ├── repositories/<entity_name>.repository.ts        # Interfaz que define cómo acceder a los datos, pero sin detalles técnicos. Piensa en ellas como operaciones que puedes realizar (por ejemplo, saveUser(user) o findByEmail(email), pero no cómo se realizan.
    │   ├── errors/<entity_name>.error.ts                   # Errores personalizados que representan problemas empresariales, no técnicos.
    │   └── enums/<enum_name>.enum.ts
    ├── application                                         # Aplica reglas de negocio utilizando entidades, utiliza repositorios para recuperar o guardar datos y no contenga lógica de negocio compleja (eso está en el «dominio»).
    │   └─ <use_case_name>.use-case.ts
    ├── infra/                                              # Responsable de conectarse a tecnologías externas como bases de datos, API, marcos y otros servicios. No contiene lógica de negocio, solo implementa lo que se define en «dominio» y se utiliza en «aplicación».
    │   └── memory-<entity_name>.repository.ts
    ├── presentation/
    │   ├── dtos/<dto_name>.dto.ts
    │   └── routers/v<version_number>
    │       └── <router_name>.router.ts
    └── <module_name>.module.ts                              # Registro de clases para la inyección de dependencias en el contenedor.

👉 Capas

  • Domain: Ninguna clase aquí debería importar algo de infra o presentation. El dominio es completamente agnóstico de la tecnología.
  • Application: La capa de aplicación depende solo del dominio, nunca directamente de infraestructura o controladores. Aquí es donde entra en juego la inyección de dependencias con InversifyJS, permitiendo conectar interfaces (repositories) con implementaciones concretas más adelante.
  • Infra: Esta capa depende del dominio, pero el dominio no depende de ella. Esa inversión de dependencias es lo que mantiene la arquitectura limpia y reemplazable.
  • Presentation: Esta capa traduce el “mundo exterior” (por ejemplo, JSON del cliente) al lenguaje interno del dominio. No debería contener lógica de negocio, solo adaptadores y validación básica.

📐 Ejemplo paso a paso

Vamos a crear un ejemplo sencillo de una entidad usuario, desde como crear la entidad en la capa de dominio hasta como crear el controlador en la capa de presentación, pasando por todas las capas entre medias.

1. Crear entidades desde el dominio

Todas las entidades tienen unas propiedades en común com su identificador, la fecha de creación y la fecha de su última actualización. Para ello extenderemos todas las entidades de una clase abstracta con esos atributos.

// src/shared/domain/types/entity.type

export type EntityProps = ConstructorParameters<typeof Entity>[0];

export abstract class Entity {
  createdAt: Date;

  id: string;

  updatedAt: Date;

  constructor(params: Pick<Entity, 'createdAt' | 'id' | 'updatedAt'>) {
    this.createdAt = params.createdAt;
    this.id = params.id;
    this.updatedAt = params.updatedAt;
  }

  static generateId(): Entity['id'] {
    return crypto.randomUUID()
  }

  static generateProps(): EntityProps {
    return {
      createdAt: new Date(),
      id: Entity.generateId(),
      updatedAt: new Date(),
    };
  }
}
// src/user/domain/entities/user.entity

import { Entity, EntityProps } from '@/shared/domain/types/entity.type';

export abstract class User extends Entity {
  email: string;

  constructor(params: EntityProps & Pick<User, 'email'>) {
    super(params);
    this.createdAt = params.createdAt;
    this.id = params.id;
    this.updatedAt = params.updatedAt;
  }

  static generateId() {
    return crypto.randomUUID()
  }

  static generateProps() {
    return {
      createdAt: new Date(),
      id: Entity.generateId(),
      updatedAt: new Date(),
    };
  }
}

2. Interfaz de repositorio

Crearemos una clase abstracta para implementar repository pattern.

// src/user/domain/repositories/user.repository

import { User } from '@/user/domain/entities/user.entity';

export abstract class UserRepo {
  abstract create: (params: User) => Promise<User>;

  abstract findOneByEmail: (email: User['email']) => Promise<User | null>;
}

3. Implementar el repositorio de dominio con una tecnología

En este ejemplo implementaremos un un repositorio en memoria, pero aquí puedes usar cualquier base de datos siempre y cuando implementes el repositorio del dominio.

// src/user/infra/memory-user.repository

import { inject } from 'inversify';
import { UserRepo } from '@/user/domain/repositories/user.repository';
import { User } from '@/user/domain/entities/user.entity';

export class MemoryUserRepo implements UserRepo {
  private memoryData: User[] = [];

  create(...params: Parameters<User['create']>): ReturnType<UserRepo['create']> {
    const [user] = params;
    this.memoryData.push(user);
    return Promise.resolve(user);
  }

  findOneByEmail(...params: Parameters<UserRepo['findOneByEmail']>): ReturnType<UserRepo['findOneByEmail']> {
    const [email] = params;
    const user = this.memoryData.find(el => el.email === email);
    return  Promise.resolve(user ? new User(user): null);
  }
}

4. Crear los casos de uso

Vamos a crear el caso de uso para dar de alta un nuevo usuario. Todos los casos de uso tendrán una ejecución en común, para ello crearemos una clase abstracta para extederla en todos los casos de uso.

// src/shared/domain/types/use-case.type

export abstract class UseCase<Input, Output> {
  async execute(input: Input): Promise<Output> {
    await this.before(input);
    const validatedInput = await this.validate(input);
    const output = await this.run(validatedInput);
    await this.after(output);
    return output;
  }

  // Hooks: override if you need it

  protected async before(_i: Input): Promise<void> {}

  protected async validate(_i: Input): Promise<Input> {
    return _i;
  }
  
  protected async after(_o: Output): Promise<void> {}

  // Must be implemented by the concrete use case
  protected abstract run(i: Input): Promise<Output>;

}
// src/user/application/user-creation.use-case.ts

import { inject, injectable } from 'inversify';
import { User } from '@/user/domain/entities/user.entity';
import { UserRepo } from '@/user/domain/repositories/user.repository';
import { UseCase } from '@/shared/domain/types/use-case.type';

type Input = Pick<User, 'email'>
type Output = void;

@injectable()
export class UserCreation extends UseCase<Input, Output> {
  constructor(@inject(UserRepo.name) private readonly userRepo: UserRepo) {
    super();
  }

  protected async run({ email }: Input): Promise<Output> {
    const user = new User({
      ...User.generateProps();
      email,
    });

    await this.userRepo.create(user);
  }

  async validate(input: Input): Promise<Input> {
    // Validate email received ...
    return input;
  }

  async after(output: Output): Promise<void> {
    // Send mail ...
  }
}

5. Crear el controlador HTTP

Aquí importaremos los casos de uso y haremos una validación de los datos (a través de un esquema Zod) que nos vienen del mundo exterior.

// src/shared/presentation/types/hono-router.type.ts

import { Hono } from 'hono';

export abstract class HonoRouter {
  basePath: string;

  constructor(params: { basePath: string }) {
    this.basePath = params.basePath;
  }

  abstract run(app: Hono): void;
}
// src/user/presentation/dtos/user.dto

export const CreateUserPost = z.object({
  email: z.string().trim(),
});
// src/user/presentation/routers/v1/user.router

import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { inject, injectable } from 'inversify';
import { HonoRouter } from '@/shared/presentation/types/hono-router.type';
import { UserCreation } from '@/user/application/user-creation.use-case';
import { CreateUserPost } from '@/user/presentation/dtos/user.dto';

@injectable()
export class UserRouterV1 extends HonoRouter {
  constructor(@inject(UserCreation.name) private readonly userCreation: UserCreation) {
    super({ basePath: '/users/v1' });
  }

  run(app: Hono): void {
    app.post('/', zValidator('json', CreateUserPost), async c => {
      const profile = c.get('profile');
      const data = c.req.valid('json');

      const user = await this.userCreation.execute({ email: data.email });

      return c.json({ user });
    });
  }
}

7. Configuración de la inyección de dependencias

Aquí es donde viene la magia. Vamos a registrar todas las clases inyectables del módulo en el contenedor del módulo y a su vez, el contendor lo registraremos en el contenedor global de la aplicación. Para este ejemplo, recordad también crear el contenedor del módulo shared y registrarlo en el contenedor global.

// src/user/user.module.ts

import { ContainerModule } from 'inversify';
import { UserRepo } from '@/user/domain/repositories/user.repository';
import { MemoryUserRepo } from '@/user/infra/memory-user.repository';
import { UserCreation } from '@/user/application/user-creation.use-case';
import { UserRouterV1 } from '@/user/presentation/routers/v1/user.router';

export const UserModule = new ContainerModule(bind => {
  bind<UserRepo>(UserRepo.name).to(MemoryUserRepo).inSingletonScope();

  bind<UserCreation>(UserCreation.name).to(UserCreation).inSingletonScope();

  bind<UserRouterV1>(UserRouterV1.name).to(UserRouterV1).inSingletonScope();
});
// src/inversify.config.ts

import { UserModule } from '@/user/user.module';

export const container = new Container();

container.load(UserModule);

8. Crear el servidor HTTP con Hono

Daremos de alta el servidor usando Hono. Desde aquí registramos los controladores HTTP.

// src/shared/presentation/helpers/hono-api.helper.ts

import { Hono } from 'hono';
import { injectable } from 'inversify';
import { Router } from '@/shared/presentation/types/api.type';

@injectable()
export class HonoApiHelper {
  attachRouter(router: Router, rootApp: Hono) {
    const app = new Hono();
    router.run(app);
    rootApp.route(router.basePath, app);
  }
}
// src/main.ts

import 'reflect-metadata';
import 'dotenv/config';

import { Hono } from 'hono';
import { logger } from 'hono/logger';
import { cors } from 'hono/cors';
import { serve } from '@hono/node-server';
import { showRoutes } from 'hono/dev';
import { container } from '@/inversify.config';
import { HonoApiHelper } from '@/shared/presentation/helpers/hono-api.helper';
import { UserRouterV1 } from '@/shared/presentation/routers/v1/user.router';

export async function main() {
  const honoApiHelper = container.get<HonoApiHelper>(HonoApiHelper.name);
  const userRouterV1 = container.get<UserRouterV1>(UserRouterV1.name);

    const app = new Hono().basePath('/api');

    app.use('*', logger());

    app.use('*', cors());

    app.get('/health', c => c.json({ message: 'OK' }));

    honoApiHelper.attachRouter(userRouterV1, app);

    showRoutes(app, { colorize: true });
    
    serve({ fetch: app.fetch, port: 8080 });

    return app;
  
}

const app = await main();

export default app;

❌ Errores customizados


Tener errores customizados nos facilita su manejo y monitorización ya que podemos clasificarlos según el tipo de error que sea.

Creación del error

Para crear errores customizados lo primero que debemos de hacer es crear una clase la cual usaremos para extender todos los errores que daremos de alta según el módulo o contexto de negocio. Vamos a ello:

// src/shared/domain/types/app-error.type.ts

export type AppErrorOptions = {
  cause?: ErrorOptions['cause'];
  httpStatus?: number;
};

export class AppError extends Error {
  readonly code: string;

  readonly httpStatus: number;

  constructor(code: string, message: string, options?: AppErrorOptions) {
    super(message, { ...(typeof options?.cause !== 'undefined' && { cause: options.cause }) });
    this.name = `${this.getPrefixErrorName(code)}_ERROR.${new.target.name}`;
    this.code = code;
    this.httpStatus = options?.httpStatus ?? 500;
  }

  getResponse(_req: Request, res: Response): Response {
    const body = {
      code: this.code,
      message: this.message,
      name: this.name,
    };

    return new Response(JSON.stringify(body), {
      status: this.httpStatus,
      headers: {
        ...res.headers,
        'Content-Type': 'application/json',
      },
    });
  }

  static getDefaultResponse(req: Request, res: Response) {
    const body = {
      code: '000',
      message: 'Uncontrolled unexpected error',
      name: 'UnknownError',
    };

    return new Response(JSON.stringify(body), {
      status: 500,
      headers: {
        ...res.headers,
        'Content-Type': 'application/json',
      },
    });
  }

  private getPrefixErrorName(code: string): string {
    const [prefixCode] = code.split('-');

    const [prefixErrorName] =
      Object.entries(PREFIX_ERRORS).find(([_key, value]) => value === prefixCode) || [];

    return prefixErrorName || 'UNKNOWN';
  }
}

export const PREFIX_ERRORS = {
  USER: '1',
};

Ahora hagamos un ejemplo para ver como podemos crear errores customizados de negocio usando AppError. Vamos a crear un error para bloquear la creación de usuario con un email ya existente, para ello vamos a agrupar los errores por entidad con un namespace:

// src/user/domain/errors/user.error.ts

import { AppError, PREFIX_ERRORS } from '@/shared/domain/types/app-error.type';

export namespace UserError {
  export class AlreadyExistsByEmail extends AppError {
    constructor(message: string) {
      super(`${PREFIX_ERRORS.USER}-001`, message, {
        httpStatus: 406, // Aquí puedes si quieres puedes tener un objeto que relacione el http status con el código correspondiente. Incluso lo puedes mapear desde el AppError
      });
    }
  }
}

Lo usaríamos de la siguiente manera en el caso de uso de crear un usuario:

import { UserError } from '@/user/domain/errors/user.error';

throw new UserError.AlreadyExistsByEmail(`User cannot be created because a user with the same email address already exists: ${email}`);

Manejo del error

Podemos tener errors customizados pero no nos sirve de nada sino manejamos estos errores de la forma que nos gustaría. Para ello vamos a ver dos ejemplos de su manejo:

1. Manejo del error en las respuesta HTTP

Podemos enviar la respuesta HTTP del error parseado en un método que nos proporciona Hono para el manejo global de errores (todos los frameworks ya sea Fastify, Express, etc tiene un método para capturar los errores globales).

Enviaremos el código del error por seguridad gracias al método getResponse que hemos definido en AppError:

const app = new Hono();

app.onError((err, c) => {
  const [req, res] = [c.req.raw, c.res];

  console.error(err);

  return err instanceof AppError
    ? err.getResponse(req, res)
    : AppError.getDefaultResponse(req, res);
});

2. Manejo del error en un bloque try/catch

Podemos saber cual ha sido la razón del error comprobando de que tipo es el error proporcionado en el bloque del catch. Y en base a eso, manejorlo.

import { UserError } from '@/user/domain/errors/user.error';

try {
  const existsUserByEmail = users.some(el => el.email === email);

  if (existsUserByEmail) {
    throw new UserError.AlreadyExistsByEmail(...);
  }

  // ...
} catch(error) {
  if (error instanceof UserError.AlreadyExistsByEmail) {
    // ...
  }
}

☄️ Contexto por cada petición HTTP


Algo imprescindible de un API es identificar cada petición que recibimos o incluso también añadir más datos a cada petición como el usuario autenticado, o la aplicación de origen, etc.

Podemos añadir un contexto adicional, independientemente al framework HTTP que utilicemos para recuperar esta información desde cualquier capa de nuestro proyecto.

Con la ayuda de AsyncLocalStorage de la API de async_hooks lo podemos lograr:

1. Creamos el contexto

import { injectable } from 'inversify';
import { AsyncLocalStorage } from 'node:async_hooks';
import { randomUUID } from 'node:crypto';

type ContextStore = {
  request?: {
    method: string;
    url: string;
  };
  traceId: string;
};

@injectable()
export class ContextService {
  private store = new AsyncLocalStorage<ContextStore>();

  static readonly httpHeaders = {
    traceId: 'X-Trace-Id',
  };

  initializeStore<R>(callback: () => R, store: ContextStore): R {
    return this.store.run(store, callback);
  }

  getStore(): ContextStore | undefined {
    return this.store.getStore();
  }

  generateTraceId(): string {
    return randomUUID();
  }
}

2. Inicializamos el contexto por cada petición HTTP

import { Hono } from 'hono';
import { container } from '@/inversify.config';
import { ContextService } from '@/shared/application/core/services/context.service';

const app = new Hono();

const contextService = container.get<ContextService>(ContextService.name);

app.use('*', async (c, next) => {
  const traceId = contextService.generateTraceId();

  await contextService.initializeStore(next, {
    request: { method: c.req.method, url: c.req.url },
    traceId,
  });

  c.res.headers.set(ContextService.httpHeaders.traceId, traceId);
});

Si queremos modificar algún valor del contexto una vez inicializado simplemente recuperamos los datos y le damos un nuevo valor al atributo que queramos:

import { container } from '@/inversify.config';
import { ContextService } from '@/shared/application/core/services/context.service';

const contextService = container.get<ContextService>(ContextService.name);
const contextStore = contextService.getStore();
contextStore.traceId = '<random_trace_id>';

3. Mejoramos la monitorización

Ahora que tenemos la información de cada petición desacoplada podemos utilizar esta información por ejemplo para cuando hagamos logs. De esta forma sabremos todo el contexto de cada registro.

A continuación haremos un ejemplo de logger usando pino:

import { ContextService } from '@/shared/application/core/services/context.service';

export abstract class LoggerService {
  protected readonly MESSAGE_KEY = 'message';

  constructor(private readonly contextService: ContextService) {}

  abstract debug<T>(obj: T, msg?: string): void;

  abstract error(error: unknown, msg?: string): void;

  abstract info<T>(obj: T, msg?: string): void;

  abstract warn<T>(obj: T, msg?: string): void;

  getMetadataFromStore() {
    const httpStore = this.contextService.getStore();
    const { request, traceId, profile } = httpStore || {};

    return {
      ...(request && { request }),
      ...(traceId && { traceId }),
      ...(profile && { profile: { id: profile.id } }),
    };
  }

  normalizeLogData<T>(obj: T, msg?: string) {
    return {
      ...(typeof obj === 'object' && obj),
      ...this.getMetadataFromStore(),
      [this.MESSAGE_KEY]: typeof obj === 'string' ? obj : msg,
    };
  }
}
import { inject, injectable } from 'inversify';
import { pino, type Logger } from 'pino';
import { LoggerService } from '@/shared/application/core/services/logger.service';
import { ContextService } from '@/shared/application/core/services/context.service';

@injectable()
export class PinoLogger extends LoggerService {
  private pinoLogger: Logger;

  constructor(@inject(ContextService.name) contextService: ContextService) {
    super(contextService);
    this.pinoLogger = pino(...);
  }

  info<T>(obj: T, msg?: string): void {
    this.pinoLogger.info(this.normalizeLogData(obj, msg));
  }

  warn<T>(obj: T, msg?: string): void {
    this.pinoLogger.info(this.normalizeLogData(obj, msg));
  }

  debug<T>(obj: T, msg?: string): void {
    this.pinoLogger.info(this.normalizeLogData(obj, msg));
  }

  error(error: unknown, msg?: string): void {
    this.pinoLogger.error({
      ...this.getMetadataFromStore(),
      ...(!!msg && { [this.MESSAGE_KEY]: msg }),
      err: error,
    });
  }
}

🪏 Validación de las variables de entorno


¿Como algo tan sencillo nos puede ayudar a resolver bugs tan rápido? Recomiendo crear un esquema de validación (en mi caso uso zod) de variables de entorno y validarlas cuando se inicializa la app.

En este caso creamos un helper para validar y recuperar variables de entorno:

import { injectable } from 'inversify';
import { z } from 'zod';

@injectable()
export class EnvVarsHelper {
  private static schema = z.object({
    DATABASE_URL: z.string().nonempty(),
    NODE_ENV: z.enum(['development', 'production']),
    // ...
  });

  private static envVars?: z.infer<typeof EnvVarsHelper.schema>;

  static getEnvVars(): z.infer<typeof EnvVarsHelper.schema> {
    return EnvVarsHelper.envVars || EnvVarsHelper.validateEnvVars();
  }

  static validateEnvVars(): z.infer<typeof EnvVarsHelper.schema> {
    const envVars = EnvVarsHelper.schema.parse(process.env);
    EnvVarsHelper.envVars = envVars;
    return EnvVarsHelper.envVars;
  }
}

Ahora validamos las variables de entorno al arrancar la app. De esta forma si alguna variable falta o es incorrecto lo primero que veremos por consola será ese error:

import { Hono } from 'hono';

const { ... } = EnvVarsHelper.validateEnvVars();

const app = new Hono();

Espero que te haya resultado interesante, muchas gracias 🤗.

🤜 🤛