Introduction to Technologies
Below is not just an overview, but working patterns and fixes for sharp corners that are usually encountered in production.
Kysely: Type-Safe SQL Builder
Kysely is a modern SQL builder for TypeScript with a very strong type system. Unlike heavy ORMs (where SQL is hidden under the carpet), Kysely keeps SQL visible but guarantees type correctness at compile time. This means:
- Rename a column — and TypeScript will immediately show all places where you forgot to adapt.
- Joins and aliases are type-checked, fewer "silent" bugs.
- Convenient for building dynamic queries without type pain.
Practice:
- For large schemas — add kysely-codegen (or generate types from your DB) to guarantee type freshness.
- Use CamelCasePlugin for mapping snake_case columns to camelCase in code.
- For complex cases, sql
rawand custom expressions come in handy.
H3: Minimalist HTTP Server
H3 is a unified HTTP layer that feels equally comfortable in Node.js, Bun, Deno, and Edge environments (Cloudflare Workers). In real projects, it's good because:
- Clear, predictable handler API.
- Works great with functional style (middleware are regular functions).
- Lightweight runtime and compatibility with Nitro.
In production, pay attention to:
- Body limits (to avoid overwhelming the process with large uploads).
- Streaming (streaming responses).
- Request context (event.context) — put user, requestId, and everything needed down the chain there.
Functional API Architectural Principles
The idea is simple: each function does one thing, composition builds behavior. This reduces the amount of "magic" and makes testing easier.
1. Separation of Concerns
// Database layer
export const findUserById = async (id: string) => {
const db = useDatabase()
return await db
.selectFrom('users')
.where('id', '=', id)
.selectAll()
.executeTakeFirst()
}
// Validation layer
export const validateUserData = (data: unknown) => {
return userSchema.parse(data)
}
// Business logic layer
export const processUserUpdate = async (id: string, data: UserUpdateData) => {
const validatedData = validateUserData(data)
return await updateUser(id, validatedData)
}
Practice:
- Business rules shouldn't know about HTTP. This makes logic portable and simple for unit tests.
- Repositories shouldn't know about validation schemas — let them accept already valid data.
2. Function Composition
const withAuth = (handler: Function) => (event: H3Event) => {
const token = getHeader(event, 'authorization')
if (!token) {
throw createError({ statusCode: 401, statusMessage: 'Unauthorized' })
}
return handler(event)
}
const withValidation = (schema: ZodSchema) => (handler: Function) =>
async (event: H3Event) => {
const body = await readBody(event)
const validatedData = schema.parse(body)
return handler(event, validatedData)
}
export default defineEventHandler(
withAuth(
withValidation(userUpdateSchema)(
async (event: H3Event, data: UserUpdateData) => {
return await processUserUpdate(getRouterParam(event, 'id'), data)
}
)
)
)
Practice:
- Set precise types for handlers: eventHandler instead of Function. Kysely and Zod "collapse" types better this way.
- Add requestId to context and log pipeline stages — in production this saves hours of bug hunting. Useful to connect with AsyncLocalStorage.
Project Setup
Below are specific code improvements that save nerves in production.
Database Configuration (Important: Connections and MySQL RETURNING)
The original code has two sharp points:
- useDatabase() creates a new pool every time — expensive and can "eat" connections.
- .returningAll() is not supported by Kysely's MySQL dialect — you'll crash at runtime.
Let's fix this.
// database/types.ts
export interface Database {
users: {
id: string
email: string
name: string
created_at: Date
updated_at: Date
}
posts: {
id: string
title: string
content: string
author_id: string
published: boolean
created_at: Date
updated_at: Date
}
tags: {
id: string
name: string
slug: string
}
post_tags: {
post_id: string
tag_id: string
}
}
// database/connection.ts
import { createPool } from 'mysql2'
import { Kysely, MysqlDialect } from 'kysely'
import type { Database } from './types'
let _db: Kysely<Database> | null = null
export const createDatabase = () => {
const config = useRuntimeConfig()
const dialect = new MysqlDialect({
pool: createPool({
database: config.dbName,
host: config.dbHost,
user: config.dbUser,
password: config.dbPassword,
connectionLimit: 10,
// Practice:
// timezone: 'Z', // store everything in UTC
// dateStrings: true, // if you want strings instead of Date
}),
})
return new Kysely<Database>({ dialect })
}
export const useDatabase = () => {
if (!_db) _db = createDatabase()
return _db
}
Practice:
- Make a singleton for the pool. For per-request transactions — pass trx explicitly.
- Standardize time zone (UTC) and date types. Different TZ on server and DB — classic cause of "broken" dates.
- For PostgreSQL you can enable .returning(), but for MySQL — no, need to return data manually.
Creating Validation Schemas
Zod is fine. Add strict schema and normalization.
// schemas/user.ts
import { z } from 'zod'
export const userCreateSchema = z.object({
email: z.string().email().transform((s) => s.toLowerCase().trim()),
name: z.string().min(2).max(100).trim(),
}).strict()
export const userUpdateSchema = z.object({
email: z.string().email().transform((s) => s.toLowerCase().trim()).optional(),
name: z.string().min(2).max(100).trim().optional(),
}).strict()
export type UserCreateData = z.infer<typeof userCreateSchema>
export type UserUpdateData = z.infer<typeof userUpdateSchema>
Creating Repositories (Fixing MySQL returning and giving "battle-tested" details)
Base Repository
// repositories/base.repository.ts
import type { Kysely } from 'kysely'
import type { Database } from '../database/types'
export abstract class BaseRepository<TTable extends keyof Database> {
constructor(protected db: Kysely<Database>) {}
protected abstract getTableName(): TTable
async findById(id: string) {
return await this.db
.selectFrom(this.getTableName())
.where('id', '=', id)
.selectAll()
.executeTakeFirst()
}
async findAll(limit = 10, offset = 0) {
return await this.db
.selectFrom(this.getTableName())
.selectAll()
.limit(limit)
.offset(offset)
.execute()
}
async create(
data: Omit<Database[TTable], 'id' | 'created_at' | 'updated_at'>
) {
const id = crypto.randomUUID()
const now = new Date()
// MySQL doesn't have RETURNING — return what we know, or do follow-up SELECT
await this.db
.insertInto(this.getTableName())
.values({
...(data as any),
id,
created_at: now,
updated_at: now,
} as any)
.executeTakeFirst()
// Follow-up SELECT — if you need to return exact fields from DB (triggers, defaults)
const row = await this.findById(id)
return row ?? { id, ...(data as any), created_at: now, updated_at: now }
}
async update(id: string, data: Partial<Database[TTable]>) {
await this.db
.updateTable(this.getTableName())
.set({
...(data as any),
updated_at: new Date(),
})
.where('id', '=', id)
.executeTakeFirst()
return await this.findById(id)
}
async delete(id: string) {
await this.db
.deleteFrom(this.getTableName())
.where('id', '=', id)
.execute()
return { id }
}
}
Practice:
- For unique keys, always set an index at DB level and catch duplicate errors (in MySQL — ER_DUP_ENTRY). "Application-level" checks without unique index don't solve the race condition.
- If you need to update in bulk — think about compiled queries and batching.
Specialized Repositories
No changes in essence, but check indexes: posts(author_id, published), tags(name), post_tags(post_id, tag_id).
// repositories/user.repository.ts
import { BaseRepository } from './base.repository'
import type { Database } from '../database/types'
export class UserRepository extends BaseRepository<'users'> {
protected getTableName() { return 'users' as const }
async findByEmail(email: string) {
return await this.db
.selectFrom('users')
.where('email', '=', email)
.selectAll()
.executeTakeFirst()
}
async findWithPosts(userId: string) {
return await this.db
.selectFrom('users')
.leftJoin('posts', 'posts.author_id', 'users.id')
.where('users.id', '=', userId)
.select([
'users.id',
'users.email',
'users.name',
'users.created_at',
'users.updated_at',
'posts.id as post_id',
'posts.title as post_title',
'posts.content as post_content',
'posts.published as post_published',
])
.execute()
}
}
// repositories/post.repository.ts
import { BaseRepository } from './base.repository'
export class PostRepository extends BaseRepository<'posts'> {
protected getTableName() { return 'posts' as const }
async findByAuthor(authorId: string, published = true) {
let query = this.db
.selectFrom('posts')
.where('author_id', '=', authorId)
if (published) {
query = query.where('published', '=', true)
}
return await query.selectAll().execute()
}
async findWithTags(postId: string) {
return await this.db
.selectFrom('posts')
.leftJoin('post_tags', 'post_tags.post_id', 'posts.id')
.leftJoin('tags', 'tags.id', 'post_tags.tag_id')
.where('posts.id', '=', postId)
.select([
'posts.id',
'posts.title',
'posts.content',
'posts.author_id',
'posts.published',
'posts.created_at',
'tags.id as tag_id',
'tags.name as tag_name',
'tags.slug as tag_slug',
])
.execute()
}
async findByTag(tagName: string) {
return await this.db
.selectFrom('posts')
.innerJoin('post_tags', 'post_tags.post_id', 'posts.id')
.innerJoin('tags', 'tags.id', 'post_tags.tag_id')
.where('tags.name', '=', tagName)
.where('posts.published', '=', true)
.selectAll('posts')
.execute()
}
}
Creating Services (Eliminating Races, Adding DI)
The original service creates a repository inside itself — this complicates testing. Better to inject dependencies. And critically: email uniqueness check should rely on a unique index.
// services/user.service.ts
import { UserRepository } from '../repositories/user.repository'
import type { UserCreateData, UserUpdateData } from '../schemas/user'
export class UserService {
constructor(private userRepository: UserRepository) {}
async createUser(data: UserCreateData) {
try {
// Rely on UNIQUE(email) in DB
return await this.userRepository.create(data as any)
} catch (err: any) {
// MySQL
if (err?.code === 'ER_DUP_ENTRY') {
throw new Error('User with this email already exists')
}
// Postgres: if (err?.code === '23505') ...
throw err
}
}
async updateUser(id: string, data: UserUpdateData) {
const user = await this.userRepository.findById(id)
if (!user) throw new Error('User not found')
// If email changes — we'll catch the conflict at DB level
return await this.userRepository.update(id, data as any)
}
async getUserWithPosts(id: string) {
const user = await this.userRepository.findById(id)
if (!user) throw new Error('User not found')
return await this.userRepository.findWithPosts(id)
}
async deleteUser(id: string) {
const user = await this.userRepository.findById(id)
if (!user) throw new Error('User not found')
return await this.userRepository.delete(id)
}
}
Dependency composition at handler level:
// composition root (example)
import { useDatabase } from '../database/connection'
import { UserRepository } from '../repositories/user.repository'
import { UserService } from '../services/user.service'
export const makeUserService = () => {
const db = useDatabase()
const repo = new UserRepository(db)
return new UserService(repo)
}
Creating API Handlers (Typing, Errors, Context)
Let's add types and normalize errors.
// utils/error-handler.ts
export const handleServiceError = (error: unknown) => {
if (error instanceof Error) {
if (error.message.includes('not found')) {
return createError({ statusCode: 404, statusMessage: error.message })
}
if (error.message.includes('already exists')) {
return createError({ statusCode: 409, statusMessage: error.message })
}
}
return createError({ statusCode: 500, statusMessage: 'Internal Server Error' })
}
// utils/validation.ts
import { z, ZodError, ZodSchema } from 'zod'
import type { H3Event } from 'h3'
export const withValidation = <T>(schema: ZodSchema<T>) => {
return async (event: H3Event): Promise<T> => {
try {
const body = await readBody(event)
return schema.parse(body)
} catch (error) {
if (error instanceof ZodError) {
throw createError({
statusCode: 400,
statusMessage: 'Validation Error',
data: error.errors,
})
}
throw error
}
}
}
// api/users/index.get.ts
import { makeUserService } from '../../composition'
import { handleServiceError } from '../../utils/error-handler'
export default defineEventHandler(async (event) => {
try {
const service = makeUserService()
const query = getQuery(event)
const limit = Math.min(Number(query.limit) || 10, 100)
const offset = Number(query.offset) || 0
return await service['userRepository'].findAll(limit, offset)
} catch (error) {
throw handleServiceError(error)
}
})
// api/users/index.post.ts
import { makeUserService } from '../../composition'
import { userCreateSchema } from '../../schemas/user'
import { withValidation } from '../../utils/validation'
import { handleServiceError } from '../../utils/error-handler'
export default defineEventHandler(async (event) => {
try {
const service = makeUserService()
const data = await withValidation(userCreateSchema)(event)
return await service.createUser(data)
} catch (error) {
throw handleServiceError(error)
}
})
// api/users/[id].get.ts
import { makeUserService } from '../../composition'
import { handleServiceError } from '../../utils/error-handler'
export default defineEventHandler(async (event) => {
try {
const service = makeUserService()
const id = getRouterParam(event, 'id')
if (!id) throw createError({ statusCode: 400, statusMessage: 'User ID is required' })
return await service.getUserWithPosts(id)
} catch (error) {
throw handleServiceError(error)
}
})
// api/users/[id].put.ts
import { makeUserService } from '../../composition'
import { userUpdateSchema } from '../../schemas/user'
import { withValidation } from '../../utils/validation'
import { handleServiceError } from '../../utils/error-handler'
export default defineEventHandler(async (event) => {
try {
const service = makeUserService()
const id = getRouterParam(event, 'id')
if (!id) throw createError({ statusCode: 400, statusMessage: 'User ID is required' })
const data = await withValidation(userUpdateSchema)(event)
return await service.updateUser(id, data)
} catch (error) {
throw handleServiceError(error)
}
})
Practice:
- Limit limit and validate offset.
- Immediately include pino logger: time, method, path, requestId. At high loads without this — like driving without headlights.
Advanced Patterns
Authentication and Context
Let's extend types for event.context so TypeScript knows about user:
// types/h3.d.ts
import 'h3'
declare module 'h3' {
interface H3EventContext {
user?: { id: string; email: string; roles: string[] }
requestId?: string
}
}
// middleware/auth.ts
export const withAuth = (handler: (event: H3Event) => Promise<any>) => {
return async (event: H3Event) => {
const token = getHeader(event, 'authorization')?.replace('Bearer ', '')
if (!token) {
throw createError({ statusCode: 401, statusMessage: 'Authorization token required' })
}
try {
const user = await verifyToken(token) // implement as needed
event.context.user = user
return handler(event)
} catch {
throw createError({ statusCode: 401, statusMessage: 'Invalid token' })
}
}
}
Practice:
- Add AsyncLocalStorage for requestId and pass it to logs, DB queries, and external services — correlation saves in incidents.
Caching: From Map to Redis
In-memory Map is useful for local development, but in production use Redis (ioredis) or KeyDB. Add lock against "thundering herd" and invalidation.
// utils/cache.ts (sketch for Redis)
import { redis } from '../infra/redis'
export const withCache = <T>(
key: string,
ttlSec: number,
fetcher: () => Promise<T>
) => {
return async (): Promise<T> => {
const cached = await redis.get(key)
if (cached) return JSON.parse(cached)
const lockKey = `${key}:lock`
const acquired = await redis.set(lockKey, '1', 'NX', 'EX', 5)
if (!acquired) {
// Wait, then read again
await new Promise(r => setTimeout(r, 100))
const after = await redis.get(key)
if (after) return JSON.parse(after)
}
const data = await fetcher()
await redis.set(key, JSON.stringify(data), 'EX', ttlSec)
await redis.del(lockKey)
return data
}
}
Also think about ETag/If-None-Match for GET — this will reduce traffic and load.
Transactions
Good that you use trx and pass it down. Let's add a nuance: strictly pass trx to repositories — this makes code obvious.
// utils/transaction.ts
import type { Kysely } from 'kysely'
import type { Database } from '../database/types'
import { useDatabase } from '../database/connection'
export const withTransaction = async <T>(
callback: (trx: Kysely<Database>) => Promise<T>
): Promise<T> => {
const db = useDatabase()
return await db.transaction().execute(async (trx) => callback(trx))
}
// services/post.service.ts
import { PostRepository } from '../repositories/post.repository'
import { withTransaction } from '../utils/transaction'
import type { Kysely } from 'kysely'
import type { Database } from '../database/types'
export class PostService {
constructor(private makeRepo: (db: Kysely<Database>) => PostRepository) {}
async createPostWithTags(postData: any, tagIds: string[]) {
return withTransaction(async (trx) => {
const postRepo = this.makeRepo(trx)
const post = await postRepo.create(postData)
if (tagIds.length) {
await trx
.insertInto('post_tags')
.values(tagIds.map((tagId) => ({ post_id: post!.id, tag_id: tagId })))
.execute()
}
return post
})
}
}
Practice:
- For MySQL consider isolation (InnoDB, REPEATABLE READ by default). Complex reports — at snapshot/version level.
- If you need outbox pattern (events to Kafka/Rabbit) — write to outbox table in the same transaction, and move sending to worker (BullMQ + Redis).
Performance and Code Hygiene
- Connection pools: watch connectionLimit and timeouts. At peaks, lack of connections — frequent cause of degradation.
- Compiled queries: for "hot" endpoints will reduce parsing overhead.
- Indexes: EXPLAIN is your friend. At real traffic, wrong index turns server into a pumpkin.
- Logs and metrics: pino (log), prom-client (metrics) + Grafana/Prometheus. Errors with codes and time — minimum.
Testing (DI instead of new inside service)
Your test example implies mocks, but service creates repository itself. With DI this is real.
// tests/services/user.service.test.ts
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { UserService } from '../../services/user.service'
describe('UserService', () => {
let userService: UserService
let mockRepo: any
beforeEach(() => {
mockRepo = {
create: vi.fn(),
findById: vi.fn(),
findByEmail: vi.fn(),
update: vi.fn(),
}
userService = new UserService(mockRepo)
})
it('should create user successfully', async () => {
const userData = { email: 'test@example.com', name: 'Test User' }
mockRepo.create.mockResolvedValue({ id: '1', ...userData })
const result = await userService.createUser(userData as any)
expect(mockRepo.create).toHaveBeenCalled()
expect(result).toEqual({ id: '1', ...userData })
})
it('should throw error on duplicate email', async () => {
const userData = { email: 'test@example.com', name: 'Test User' }
mockRepo.create.mockRejectedValue({ code: 'ER_DUP_ENTRY' })
await expect(userService.createUser(userData as any))
.rejects.toThrow('User with this email already exists')
})
})
If you want integration tests — use Testcontainers (spins up real DB in Docker), this greatly increases test confidence.
Story from Practice
When we launched the first release with email uniqueness check "in the application", under peak load duplicates started coming in — classic race between SELECT and INSERT. Solution: unique index at DB level + catching conflicts (ER_DUP_ENTRY), then careful error mapping to 409. After that, no incidents. This is not "like in Express where you can throw middleware and pray" — here strict types and clear function composition help you, but invariants must remain in the DB.
When to Consider NestJS, Fastify, Prisma, etc.
- NestJS: if you need strict modular architecture, DI "out of the box", guards, interceptors, pipes.
- Fastify: if you want maximum HTTP layer performance and plugin ecosystem, but H3 is already quite fast.
- Prisma: convenient migrations, great DX, but ORM abstractions can be heavier. Sometimes Prisma is used only for schemas/migrations in combination with Kysely.
- Redis + BullMQ: jobs, retries, rate limiting, delayed processing.
- TypeORM: possible, but Kysely usually gives cleaner types and more transparent SQL.
Conclusion
The Kysely + H3 combination is:
- Type safety at compile time and transparent SQL.
- Clean functional composition without "magical" framework.
- Performance without overhead fat.
Key practices to avoid shooting yourself in the foot:
- Singleton DB pool and avoiding .returning() in MySQL (do follow-up SELECT).
- Unique indexes and duplicate error handling instead of "eye checks".
- DI for testability.
- Cache in Redis, not in memory.
- Logs, metrics, requestId through AsyncLocalStorage.
This approach scales well and behaves predictably on high-load projects. And if you ever need "enterprise" wrapper — much of what's written here painlessly moves to NestJS or Fastify, because it's initially built on simple and clean functions.

