Backend · Architecture

Hono로 짓는 Hexagonal Architecture

비즈니스 로직을 Hono·Drizzle·Zod에서 떼어 내고, Hono RPC로 CodeGen 없이 프론트엔드까지 타입을 잇는 백엔드 구조.

2026-10-14 Hono · Drizzle ORM · Zod · TypeScript

1. 개요

Feature-based Hexagonal Architecture를 따릅니다. 비즈니스 로직(Domain Core)은 Hono, Drizzle, Zod 같은 프레임워크와 DB 기술을 전혀 모르게 둡니다.

API 타입은 Hono RPC로 공유합니다. 프론트엔드가 백엔드 라우터의 타입을 그대로 가져다 쓰므로, CodeGen 없이도 요청과 응답 타입이 양쪽에서 항상 일치합니다.

        [ Client (React / Next.js) ]
                     │
                     ▼  Hono RPC / HTTP
┌──────────────────────────────────────────────────────┐
│  Inbound Adapter   (Hono Routes + Zod Validator)     │
│        │                                             │
│        ▼                                             │
│  ┌────────────────────────────────────────────────┐  │
│  │  Domain Core   (Use Cases · Entities · Ports)  │  │
│  │  - no external dependencies                    │  │
│  └───────────────────────┬────────────────────────┘  │
│                          │ Outbound Port (interface) │
│                          ▼                           │
│  Outbound Adapter  (Drizzle ORM / External APIs)     │
└──────────────────────────────────────────────────────┘

2. 디렉터리 구조

src/
├── features/                          # feature(도메인) 단위로 나눈 모듈
│   ├── users/
│   │   ├── domain/                    # [CORE] 순수 비즈니스 로직 (외부 의존성 0)
│   │   │   ├── user.entity.ts         # 도메인 엔티티
│   │   │   ├── user.errors.ts         # 도메인 에러
│   │   │   ├── ports/
│   │   │   │   └── user-repository.port.ts   # Outbound Port
│   │   │   └── use-cases/
│   │   │       ├── create-user.use-case.ts
│   │   │       └── get-user.use-case.ts
│   │   │
│   │   ├── adapters/                  # [INFRA] 기술 구현체
│   │   │   ├── inbound/http/          # 들어오는 요청 (Driving)
│   │   │   │   ├── users.routes.ts    # Hono 라우터 (RPC 노출)
│   │   │   │   └── users.dto.ts       # Zod 스키마
│   │   │   └── outbound/persistence/  # 나가는 요청 (Driven)
│   │   │       ├── drizzle-user.repository.ts
│   │   │       └── user.schema.ts     # Drizzle 테이블 스키마
│   │   │
│   │   ├── testing/                   # 테스트용 Port 구현체 (in-memory)
│   │   │   └── in-memory-user.repository.ts
│   │   │
│   │   └── users.module.ts            # Composition Root — 구현체를 조립해 라우터를 export
│   │
│   └── auth/                          # users와 같은 구조
│
├── shared/                            # 전역 인프라 · 공통 모듈
│   ├── config/env.ts
│   ├── db/client.ts
│   ├── middleware/
│   │   ├── auth.middleware.ts
│   │   └── logger.middleware.ts
│   └── http/
│       └── error-handler.ts           # app.onError에 등록할 전역 에러 핸들러
│
└── index.ts                           # 진입점 + RPC용 AppType export

3. 핵심 규칙

  1. domain/은 순수하게 유지합니다.
    • hono, drizzle-orm, zod 같은 외부 npm 패키지를 import하지 않습니다.
    • Entity, Error, Use Case, 그리고 자기 도메인의 Port 인터페이스만 순수 TypeScript로 정의합니다.
  2. 의존성은 한 방향으로만 흐릅니다.
    • Adapters → Domain (Ports · Use Cases) → Entities
    • Domain은 Adapters나 Shared를 참조하지 않습니다.
    • Inbound Adapter와 Outbound Adapter도 서로를 모릅니다. 둘을 연결하는 일은 Composition Root(users.module.ts)만 합니다.
  3. Feature끼리는 서로의 내부를 모릅니다.
    • features/users는 features/auth의 adapters를 직접 import하지 않습니다. 필요하면 domain/ports에 정의한 인터페이스로 연결합니다.

4. 단계별 구현

Step 1. Entity · Error · Port 정의 (domain/)

src/features/users/domain/user.entity.ts
export interface User {
  id: string;
  email: string;
  name: string;
  createdAt: Date;
}

export type NewUser = Omit<User, 'id' | 'createdAt'>;
src/features/users/domain/user.errors.ts
export class UserAlreadyExistsError extends Error {
  constructor(readonly email: string) {
    super(`User already exists: ${email}`);
    this.name = 'UserAlreadyExistsError';
  }
}
src/features/users/domain/ports/user-repository.port.ts
import type { NewUser, User } from '../user.entity';

export interface UserRepositoryPort {
  save(user: NewUser): Promise<User>;
  findByEmail(email: string): Promise<User | null>;
}

Step 2. Use Case 구현 (domain/use-cases/)

src/features/users/domain/use-cases/create-user.use-case.ts
import type { User } from '../user.entity';
import type { UserRepositoryPort } from '../ports/user-repository.port';
import { UserAlreadyExistsError } from '../user.errors';

export class CreateUserUseCase {
  constructor(private readonly userRepository: UserRepositoryPort) {}

  async execute(email: string, name: string): Promise<User> {
    const existing = await this.userRepository.findByEmail(email);
    if (existing) {
      throw new UserAlreadyExistsError(email);
    }

    return this.userRepository.save({ email, name });
  }
}

Step 3. Outbound Adapter 구현 (adapters/outbound/)

src/features/users/adapters/outbound/persistence/user.schema.ts
import { pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core';

export const usersTable = pgTable('users', {
  id: uuid('id').primaryKey().defaultRandom(),
  email: text('email').notNull().unique(),
  name: text('name').notNull(),
  createdAt: timestamp('created_at').notNull().defaultNow(),
});
src/features/users/adapters/outbound/persistence/drizzle-user.repository.ts
import { eq } from 'drizzle-orm';
import { db } from '../../../../../shared/db/client';
import type { NewUser, User } from '../../../domain/user.entity';
import type { UserRepositoryPort } from '../../../domain/ports/user-repository.port';
import { usersTable } from './user.schema';

// DB 행 → 도메인 Entity. 테이블 컬럼이 바뀌어도 도메인은 영향을 받지 않는다.
const toDomain = (row: typeof usersTable.$inferSelect): User => ({
  id: row.id,
  email: row.email,
  name: row.name,
  createdAt: row.createdAt,
});

export class DrizzleUserRepository implements UserRepositoryPort {
  async findByEmail(email: string): Promise<User | null> {
    const [row] = await db.select().from(usersTable).where(eq(usersTable.email, email));
    return row ? toDomain(row) : null;
  }

  async save(user: NewUser): Promise<User> {
    const [row] = await db.insert(usersTable).values(user).returning();
    return toDomain(row);
  }
}

Step 4. Inbound Adapter 구현 (adapters/inbound/)

Hono RPC — 체이닝을 끊지 말 것

라우트 타입은 메서드 체이닝이 이어질 때만 쌓입니다. new Hono().get().post()처럼 한 표현식으로 이어 쓰세요. app.get(...)을 문장마다 따로 호출하면 그 라우트는 AppType에 남지 않습니다.

src/features/users/adapters/inbound/http/users.dto.ts
import { z } from 'zod';

export const createUserSchema = z.object({
  email: z.string().email(),
  name: z.string().min(2),
});

라우터는 구현체를 직접 만들지 않습니다. Use Case를 인자로 받는 팩토리 함수로 만들고, 도메인 에러를 HTTP 상태 코드로 바꾸는 일만 맡깁니다. 409를 이렇게 라우트에서 반환해야 RPC 타입에 포함되어 프론트엔드에서 분기할 수 있습니다.

src/features/users/adapters/inbound/http/users.routes.ts
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import type { CreateUserUseCase } from '../../../domain/use-cases/create-user.use-case';
import { UserAlreadyExistsError } from '../../../domain/user.errors';
import { createUserSchema } from './users.dto';

export const createUserRoutes = (createUser: CreateUserUseCase) =>
  new Hono()
    .post('/', zValidator('json', createUserSchema), async (c) => {
      const { email, name } = c.req.valid('json');
      try {
        const user = await createUser.execute(email, name);
        return c.json(user, 201);
      } catch (err) {
        if (err instanceof UserAlreadyExistsError) {
          return c.json({ error: 'USER_ALREADY_EXISTS' as const }, 409);
        }
        throw err; // 예상하지 못한 에러는 전역 onError가 처리한다
      }
    });

Step 5. Composition Root (users.module.ts)

Port와 구현체를 실제로 연결하는 곳은 이 파일 하나뿐입니다. DB를 바꾸거나 테스트용 구현체로 갈아 끼울 때도 이 파일만 고치면 됩니다.

src/features/users/users.module.ts
import { CreateUserUseCase } from './domain/use-cases/create-user.use-case';
import { DrizzleUserRepository } from './adapters/outbound/persistence/drizzle-user.repository';
import { createUserRoutes } from './adapters/inbound/http/users.routes';

const userRepository = new DrizzleUserRepository();
const createUserUseCase = new CreateUserUseCase(userRepository);

export const userRoutes = createUserRoutes(createUserUseCase);

Step 6. 진입점, 전역 에러 핸들러, AppType

에러는 미들웨어가 아니라 onError에서

Hono는 핸들러에서 던진 에러를 내부에서 잡아 app.onError로 넘깁니다. 그래서 app.use('*', ...) 미들웨어에서 try { await next() } catch로 감싸도 에러가 잡히지 않습니다.

src/shared/http/error-handler.ts
import type { ErrorHandler } from 'hono';
import { HTTPException } from 'hono/http-exception';

export const errorHandler: ErrorHandler = (err, c) => {
  if (err instanceof HTTPException) {
    return err.getResponse();
  }
  console.error(err);
  return c.json({ error: 'INTERNAL_SERVER_ERROR' }, 500);
};
src/index.ts
import { Hono } from 'hono';
import { userRoutes } from './features/users/users.module';
import { errorHandler } from './shared/http/error-handler';
import { loggerMiddleware } from './shared/middleware/logger.middleware';

const app = new Hono();

// 1. 공통 미들웨어와 에러 핸들러
app.use('*', loggerMiddleware);
app.onError(errorHandler);

// 2. feature별 라우터 연결 — 체이닝 결과를 변수에 받아야 타입이 남는다
const routes = app.route('/api/users', userRoutes);

// 3. 서버 실행용 default export
export default app;

// 4. 프론트엔드 공유용 — 값이 아니라 타입만 내보낸다
export type AppType = typeof routes;

5. 프론트엔드 연동 (Hono RPC Client)

프론트엔드는 백엔드의 실행 코드가 아니라 AppType 타입만 가져옵니다. import type이므로 백엔드 코드는 번들에 포함되지 않습니다. (프론트엔드에서 백엔드 소스 경로에 접근할 수 있는 모노레포 구성을 가정합니다.)

client/src/lib/api-client.ts
import { hc } from 'hono/client';
import type { AppType } from '../../../backend/src/index';

export const client = hc<AppType>('http://localhost:3000');

export async function submitForm() {
  const res = await client.api.users.$post({
    json: { email: 'user@example.com', name: 'Hong' },
  });

  if (res.status === 409) {
    const { error } = await res.json(); // 'USER_ALREADY_EXISTS'
    return;
  }

  if (res.ok) {
    const data = await res.json(); // { id, email, name, createdAt }로 추론된다
    console.log(data.id, new Date(data.createdAt));
  }
}

Date는 string으로 받는다

응답은 JSON을 거치므로 클라이언트에서 createdAt은 Date가 아니라 string으로 추론됩니다. 날짜로 다루려면 new Date(data.createdAt)로 변환하세요.

6. 테스트

Use Case 단위 테스트

도메인은 Port에만 의존하므로 DB나 Hono 없이 in-memory 구현체 하나로 바로 테스트할 수 있습니다. 이 구현체는 testing/에 두고 단위 테스트와 통합 테스트에서 함께 씁니다.

src/features/users/testing/in-memory-user.repository.ts
import type { NewUser, User } from '../domain/user.entity';
import type { UserRepositoryPort } from '../domain/ports/user-repository.port';

export class InMemoryUserRepository implements UserRepositoryPort {
  private users: User[] = [];

  async findByEmail(email: string) {
    return this.users.find((u) => u.email === email) ?? null;
  }

  async save(input: NewUser) {
    const user = { ...input, id: crypto.randomUUID(), createdAt: new Date() };
    this.users.push(user);
    return user;
  }
}
src/features/users/domain/use-cases/create-user.use-case.test.ts
import { describe, expect, it } from 'vitest';
import { InMemoryUserRepository } from '../../testing/in-memory-user.repository';
import { UserAlreadyExistsError } from '../user.errors';
import { CreateUserUseCase } from './create-user.use-case';

describe('CreateUserUseCase', () => {
  it('같은 이메일로 두 번 가입하면 UserAlreadyExistsError', async () => {
    const useCase = new CreateUserUseCase(new InMemoryUserRepository());

    await useCase.execute('a@example.com', 'Hong');

    await expect(useCase.execute('a@example.com', 'Kim'))
      .rejects.toBeInstanceOf(UserAlreadyExistsError);
  });
});

라우트 통합 테스트

라우터가 Use Case를 인자로 받으므로, 테스트에서는 in-memory 구현체를 넣어 라우터를 만들면 됩니다. app.request()는 서버를 띄우지 않고 메모리 안에서 요청을 처리합니다.

src/features/users/adapters/inbound/http/users.routes.test.ts
import { describe, expect, it } from 'vitest';
import { CreateUserUseCase } from '../../../domain/use-cases/create-user.use-case';
import { InMemoryUserRepository } from '../../../testing/in-memory-user.repository';
import { createUserRoutes } from './users.routes';

const post = (app: ReturnType<typeof createUserRoutes>, body: unknown) =>
  app.request('/', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(body),
  });

describe('POST /api/users', () => {
  const app = createUserRoutes(new CreateUserUseCase(new InMemoryUserRepository()));

  it('생성 201 → 중복 409', async () => {
    const body = { email: 'a@example.com', name: 'Hong' };
    expect((await post(app, body)).status).toBe(201);
    expect((await post(app, body)).status).toBe(409);
  });

  it('검증 실패는 400', async () => {
    expect((await post(app, { email: 'not-an-email', name: 'H' })).status).toBe(400);
  });
});