Хороший фреймворк для API экономит не время на старте, а время на переделке. Как только валидация, TypeScript-типы и документация живут в трёх разных местах, они начинают разъезжаться: поле переименовали в коде — в Swagger оно осталось старым, а в валидации уже новым. Мы в PROWEB собираем бэкенды на связке Bun + Elysia + Drizzle, где схема одна: она валидирует вход, типизирует обработчик и попадает в OpenAPI. В этой статье показываем, как собрать такой API — с кодом и практикой продакшена.
Почему Bun
Bun — рантайм JavaScript и TypeScript на движке JavaScriptCore с набором инструментов вокруг: пакетным менеджером, тест-раннером и поддержкой .env из коробки.
- Запускает TypeScript напрямую — без ts-node, tsx и отдельных шагов сборки
- bun install быстро ставит зависимости и пишет лок-файл
bun.lock - Встроенный bun:test — тесты без настройки Vitest или Jest
- Совместим с Node: большинство npm-пакетов работают без изменений
bun init -y
bun add elysia @elysiajs/openapi drizzle-orm postgres
bun add -d drizzle-kit @types/bun
bun --hot src/index.ts # dev-сервер с автоперезагрузкойЕдинственная зона осторожности — нативные модули: если библиотека тянет Node-аддон, проверьте её работу под Bun до того, как она окажется в центре архитектуры.
Elysia: HTTP-слой, где валидация и есть типы
Elysia — веб-фреймворк, спроектированный под Bun и end-to-end типобезопасность. Минимальный сервер с живой OpenAPI-документацией выглядит так:
// src/index.ts
import { Elysia } from 'elysia'
import { openapi } from '@elysiajs/openapi'
const app = new Elysia()
.use(openapi())
.get('/health', () => ({ status: 'ok' }))
.listen(3000)
console.log(`Документация: http://localhost:${app.server?.port}/openapi`)Схемы описываются в стиле TypeBox через t и вешаются на маршрут тремя полями: body (тело запроса), params / query (путь и строка запроса) и response (ответ по кодам статусов). Одна схема решает три задачи сразу:
import { Elysia, t } from 'elysia'
const TaskInput = t.Object({
title: t.String({ minLength: 1, maxLength: 200 })
})
const Task = t.Object({
id: t.String({ format: 'uuid' }),
title: t.String(),
done: t.Boolean(),
createdAt: t.String({ format: 'date-time' })
})
new Elysia().post('/tasks', ({ body, set }) => {
// body уже типизирован: { title: string }
set.status = 201
return createTask(body)
}, {
body: TaskInput, // валидация запроса
response: { 201: Task }, // и контроль формы ответа
detail: { summary: 'Создать задачу', tags: ['tasks'] }
})- Неверный запрос Elysia отклоняет сама — с кодом 422 и описанием, без ручных if
- Внутри обработчика
bodyуже типизирован по схеме — кастовать не нужно - response — не перестраховка: он отрезает лишние поля из ответа и попадает в OpenAPI
- detail — summary, tags и description маршрута идут прямо в документацию
Сквозная логика собирается в плагины. Авторизация — классический пример: derive достаёт токен из заголовка, проверяет и кладёт сессию в контекст; маршруты, подключившие плагин, просто читают session:
// src/plugins/auth.ts
import { Elysia } from 'elysia'
export const auth = new Elysia({ name: 'auth' })
.derive(({ headers, error }) => {
const token = headers.authorization?.replace('Bearer ', '')
const session = token ? verifySession(token) : null
if (!session) {
throw error(401, { code: 'UNAUTHORIZED', message: 'Требуется авторизация' })
}
return { session }
})
// src/routes/me.ts
export const meRoutes = new Elysia({ prefix: '/me' })
.use(auth)
.get('/', ({ session }) => getProfile(session.userId))verifySession здесь — ваша проверка (например, @elysiajs/jwt или своя таблица сессий). Throw внутри derive прерывает обработку — запрос даже не дойдёт до маршрута.
Ошибки сводятся в один обработчик: наружу — стабильные коды, в логи — детали.
app.onError(({ code, error, set, request }) => {
if (code === 'VALIDATION') {
set.status = 400
return { code: 'VALIDATION', message: 'Проверьте параметры запроса' }
}
console.error(request.url, error)
set.status = 500
return { code: 'INTERNAL', message: 'Внутренняя ошибка сервера' }
})Drizzle: схема базы как код
Drizzle — TypeScript-ORM без магии: таблицы описываются в коде, запросы читаются почти как SQL, миграции генерируются из схемы.
// src/db/schema.ts
import { boolean, pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core'
export const tasks = pgTable('tasks', {
id: uuid('id').defaultRandom().primaryKey(),
title: text('title').notNull(),
done: boolean('done').notNull().default(false),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
})Обратите внимание на имена: в TypeScript — camelCase, в SQL — snake_case; имя колонки прописано явно, поэтому в базе не появится createdAt в кавычках.
Подключение — драйвер postgres и обёртка Drizzle. Модуль выполняется один раз, поэтому пул соединений в приложении единственный:
// src/db/index.ts
import { drizzle } from 'drizzle-orm/postgres-js'
import postgres from 'postgres'
import * as schema from './schema'
const client = postgres(process.env.DATABASE_URL!, { max: 10 })
export const db = drizzle(client, { schema })Миграции — через drizzle-kit: описали схему, сгенерировали SQL, накатили:
// drizzle.config.ts
import { defineConfig } from 'drizzle-kit'
export default defineConfig({
dialect: 'postgresql',
schema: './src/db/schema.ts',
out: './drizzle',
dbCredentials: { url: process.env.DATABASE_URL! }
})bunx drizzle-kit generate # SQL-миграция появится в ./drizzle
bunx drizzle-kit migrate # применить миграции- Файлы миграций храните в репозитории; drizzle-kit push (накат схемы напрямую) оставьте для прототипа
- В Postgres .returning() отдаёт вставленную или обновлённую строку — второй SELECT не нужен
- Транзакция —
db.transaction(async (tx) => …), и дальше по цепочке передавайтеtx, а неdb
Запросы — как SQL, но с автодополнением и проверкой типов:
import { desc, eq } from 'drizzle-orm'
const list = await db.select().from(tasks)
.orderBy(desc(tasks.createdAt))
.limit(100)
const [created] = await db.insert(tasks)
.values({ title: 'Собрать API на Bun' })
.returning()
await db.update(tasks)
.set({ done: true, updatedAt: new Date() })
.where(eq(tasks.id, id))OpenAPI: документация, которая не отстаёт от кода
Схемы TypeBox, которые вы уже описали для валидации, Elysia превращает в OpenAPI сама. Плагину остаётся общая информация о сервисе:
app.use(openapi({
documentation: {
info: { title: 'Tasks API', version: '1.0.0' },
tags: [{ name: 'tasks', description: 'Задачи' }]
}
}))Дальше спека работает в двух ролях:
- Живая документация. Интерактивный UI на
/openapiвсегда соответствует коду, потому что собирается из него же - Контракт для клиентов. JSON-спека на
/openapi/jsonскачивается скриптом, и из неё генерируется типизированный клиент — например, хуки TanStack Query через @hey-api/openapi-ts или типы через openapi-typescript
Если и сервер, и клиент на Elysia, есть более короткий путь — Eden (@elysiajs/eden): типизированный клиент напрямую из типов сервера, без промежуточной спеки. Мы всё же держимся за OpenAPI: это универсальный контракт, по которому работают фронтенд, мобильные клиенты и внешние интеграции, — и он не привязывает потребителей к вашему фреймворку.
Практика: генерируйте клиента из живого сервера (dev или staging), а не из файла, закоммиченного когда-то давно. Цепочка простая: скрипт скачал спеку → перегенерировал клиент → typecheck. Поменяете поле в API — фронтенд не соберётся, пока не обновит клиента.
Сквозной пример: API задач целиком
Соберём всё вместе — CRUD с валидацией, Drizzle и честными кодами ответов:
// src/routes/tasks.ts
import { Elysia, t } from 'elysia'
import { desc, eq } from 'drizzle-orm'
import { db } from '../db'
import { tasks } from '../db/schema'
const Task = t.Object({
id: t.String({ format: 'uuid' }),
title: t.String(),
done: t.Boolean(),
createdAt: t.String({ format: 'date-time' })
})
const TaskInput = t.Object({ title: t.String({ minLength: 1, maxLength: 200 }) })
const TaskPatch = t.Object({
title: t.Optional(t.String({ minLength: 1, maxLength: 200 })),
done: t.Optional(t.Boolean())
})
const ErrorDto = t.Object({ code: t.String(), message: t.String() })
// Date-поля отдаём ISO-строками: и для JSON предсказуемо, и спека честная
const toDto = (row: typeof tasks.$inferSelect) => ({
id: row.id,
title: row.title,
done: row.done,
createdAt: row.createdAt.toISOString()
})
export const taskRoutes = new Elysia({ prefix: '/tasks', tags: ['tasks'] })
.get('/', async () => {
const rows = await db.select().from(tasks)
.orderBy(desc(tasks.createdAt))
.limit(100)
return rows.map(toDto)
}, {
response: t.Array(Task),
detail: { summary: 'Список задач' }
})
.get('/:id', async ({ params, error }) => {
const [row] = await db.select().from(tasks).where(eq(tasks.id, params.id))
if (!row) return error(404, { code: 'NOT_FOUND', message: 'Задача не найдена' })
return toDto(row)
}, {
params: t.Object({ id: t.String({ format: 'uuid' }) }),
response: { 200: Task, 404: ErrorDto },
detail: { summary: 'Получить задачу' }
})
.post('/', async ({ body, set }) => {
const [row] = await db.insert(tasks)
.values({ title: body.title })
.returning()
set.status = 201
return toDto(row)
}, {
body: TaskInput,
response: { 201: Task },
detail: { summary: 'Создать задачу' }
})
.patch('/:id', async ({ params, body, error }) => {
const patch: Partial<typeof tasks.$inferInsert> = { updatedAt: new Date() }
if (body.title !== undefined) patch.title = body.title
if (body.done !== undefined) patch.done = body.done
const [row] = await db.update(tasks)
.set(patch)
.where(eq(tasks.id, params.id))
.returning()
if (!row) return error(404, { code: 'NOT_FOUND', message: 'Задача не найдена' })
return toDto(row)
}, {
params: t.Object({ id: t.String({ format: 'uuid' }) }),
body: TaskPatch,
response: { 200: Task, 404: ErrorDto },
detail: { summary: 'Обновить задачу' }
})
.delete('/:id', async ({ params, set }) => {
await db.delete(tasks).where(eq(tasks.id, params.id))
set.status = 204
}, {
params: t.Object({ id: t.String({ format: 'uuid' }) }),
detail: { summary: 'Удалить задачу' }
})// src/index.ts
import { Elysia } from 'elysia'
import { openapi } from '@elysiajs/openapi'
import { taskRoutes } from './routes/tasks'
const app = new Elysia()
.use(openapi({
documentation: {
info: { title: 'Tasks API', version: '1.0.0' },
tags: [{ name: 'tasks', description: 'Задачи' }]
}
}))
.onError(({ code, error, set, request }) => {
if (code === 'VALIDATION') {
set.status = 400
return { code: 'VALIDATION', message: 'Проверьте параметры запроса' }
}
console.error(request.url, error)
set.status = 500
return { code: 'INTERNAL', message: 'Внутренняя ошибка сервера' }
})
.use(taskRoutes)
.listen(3000)
export { app }Мелочь, которая показывает ценность схем: :id описан как uuid — кривой идентификатор отсеется валидацией с 422 ещё до запроса к Postgres, а не упадёт 500 внутри драйвера.
Практика эксплуатации
Тесты без сети. Elysia-приложение можно дёргать обычным Request через app.handle — без порта и сокетов:
// test/tasks.test.ts
import { describe, expect, it } from 'bun:test'
import { app } from '../src/index'
describe('POST /tasks', () => {
it('создаёт задачу', async () => {
const res = await app.handle(new Request('http://localhost/tasks', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ title: 'Первая задача' })
}))
expect(res.status).toBe(201)
expect((await res.json()).title).toBe('Первая задача')
})
it('отклоняет пустой заголовок', async () => {
const res = await app.handle(new Request('http://localhost/tasks', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ title: '' })
}))
expect(res.status).toBe(422)
})
})- Для интеграционных тестов — отдельная база или Testcontainers; обработчики тестируются и без неё
- Docker: базовый образ
oven/bunиbun install --frozen-lockfile; если нужен один артефакт —bun build --compileсобирает самодостаточный бинарник - Наблюдаемость: pino для структурных логов,
requestIdв каждом ответе и/healthдля балансировщика - Гигиена: лимит размера body, rate limit на публичных эндпоинтах, CORS через
@elysiajs/cors, если фронтенд на другом домене - Миграции — отдельный шаг деплоя до старта приложения, а не «при первом запросе»
История из практики
Сервер одного из наших продуктов написан ровно на этом стеке. Он отдаёт живую спеку, скрипт в монорепозитории скачивает её и генерирует типизированный клиент (TanStack Query) сразу для трёх фронтендов — сайта, кабинета и админки. Когда мы меняем API — переименовываем поле или уточняем тип — сборка фронтендов падает на typecheck ещё до деплоя: клиент уже знает новое поле, а старый код с ним несовместим. Документация в этой схеме не ведётся отдельно — она просто не может отстать.
Когда выбрать что-то другое
- NestJS — большая команда и нужна архитектура с DI, модулями и гвардами «из коробки»
- Fastify — критична экосистема плагинов; он тоже запускается под Bun, если рантайм менять хочется, а фреймворк — нет
- Hono — целитесь в edge-среды и один код на нескольких рантаймах
В остальных случаях связка Bun + Elysia + Drizzle даёт необычно короткий путь от «описал схему» до «валидация работает, типы сошлись, документация готова, клиент сгенерирован».
Коротко
Одна TypeBox-схема в Elysia даёт валидацию, TypeScript-типы и OpenAPI; Drizzle держит схему базы в коде и генерирует миграции; типизированный клиент собирается из живой спеки сервера. Код и документация перестают расходиться — потому что это один и тот же код.
