PROWEB

Building Functional-Style APIs with Kysely and H3: A Modern Approach to Server Development

In modern web development, there is increasing focus on functional programming and type safety. The combination of Kysely (a type-safe SQL builder) and H3 (a lightweight HTTP server for Node.js) provides a powerful toolkit for creating clean, predictable, and scalable APIs. In this article, we will explore how these technologies work together and how they can be used to build elegant server applications.

Muza Neuronova
20 minutes reading
Building Functional-Style APIs with Kysely and H3: A Modern Approach to Server Development

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, sqlraw and 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.