Skip to content

Step-by-Step Elysia Microservices with Drizzle and PostgreSQL

Building microservices architecture in 2026 requires more than just splitting a monolith into smaller services. You need type-safe communication between services, fast request routing, and database patterns that scale horizontally without introducing latency or consistency issues.

This tutorial walks through building a modular microservices platform using ElysiaJS 1.4 as the API framework, Drizzle ORM for type-safe PostgreSQL queries, and Bun as the runtime. You'll implement an API gateway pattern, service discovery, JWT authentication, error management, and horizontal scaling strategies—all with end-to-end TypeScript inference.

Why Build Microservices with Elysia, Drizzle, and PostgreSQL?

ElysiaJS 1.4 (released September 2025) benchmarks at 293,991 requests per second without validation and 223,924 req/s with validation on Intel i7-13700K with Bun 1.3.2. That throughput makes it viable for high-traffic API gateways where other Node.js frameworks would bottleneck.

Drizzle ORM compiles TypeScript schemas directly to raw SQL, eliminating the abstraction overhead that Prisma introduces. When you define a NOT NULL column in Drizzle, TypeScript immediately flags every handler that omits it. No schema drift between your database and your API contracts.

PostgreSQL 16+ adds logical replication improvements that support horizontal scaling of read replicas. Combined with Bun's zero-copy HTTP parsing and connection pooling via postgres-js, this stack handles 10,000+ concurrent connections without the callback hell of older Node.js patterns.

Please note

This article is part of an experiment and is entirely generated, written, and published automatically using my AI pipeline, which you can read about in this article.

Is my experience a good fit for you?

Losing customers from my website...
Looking for a developer...
I need a website for my...
Needs to be optimized...

Prerequisites and Project Structure

Before writing code, set up your environment and define the service boundaries.

Environment Requirements

  • Bun 1.3+ installed (curl -fsSL https://bun.sh/install | bash)
  • PostgreSQL 16+ (local or cloud-hosted on Neon/Supabase/Railway)
  • Docker for running service containers in development
elysia-microservices/
├── services/
│   ├── gateway/          # API Gateway service
│   ├── auth/             # Authentication service
│   ├── users/            # User management service
│   └── posts/            # Content/service B service
├── packages/
│   └── shared/           # Shared types, validators, DB schemas
├── docker-compose.yml
└── drizzle.config.ts

This structure lets each service run independently while sharing type definitions through the packages/shared directory. When you update a schema in shared, TypeScript propagates the change across all services that depend on it.

Setting Up the Shared Package

The shared package is the foundation of type-safe microservices. Create it first.

typescript
// packages/shared/src/schema.ts
import { pgTable, varchar, text, timestamp, boolean, serial } from 'drizzle-orm/pg-core'
import { createId } from '@paralleldrive/cuid2'

export const users = pgTable('users', {
  id: varchar('id', { length: 128 })
    .$defaultFn(() => createId())
    .primaryKey(),
  email: varchar('email', { length: 255 }).notNull().unique(),
  passwordHash: varchar('password_hash', { length: 255 }).notNull(),
  role: varchar('role', { length: 20 }).default('user').notNull(),
  isActive: boolean('is_active').default(true).notNull(),
  createdAt: timestamp('created_at').defaultNow().notNull(),
  updatedAt: timestamp('updated_at').defaultNow().notNull()
})

export const posts = pgTable('posts', {
  id: serial('id').primaryKey(),
  title: varchar('title', { length: 255 }).notNull(),
  content: text('content').notNull(),
  slug: varchar('slug', { length: 300 }).notNull().unique(),
  authorId: varchar('author_id', { length: 128 })
    .notNull()
    .references(() => users.id, { onDelete: 'cascade' }),
  published: boolean('published').default(false).notNull(),
  viewCount: serial('view_count').default(0).notNull(),
  createdAt: timestamp('created_at').defaultNow().notNull(),
  updatedAt: timestamp('updated_at').defaultNow().notNull()
})

export type User = typeof users.$inferSelect
export type NewUser = typeof users.$inferInsert
export type Post = typeof posts.$inferSelect
export type NewPost = typeof posts.$inferInsert

Publish this package to a private registry or use workspace links. Every service imports from @shared/schema to ensure consistency.

How to Set Up Elysia with Drizzle ORM and PostgreSQL Connection Pooling

Connection pooling is where most Elysia microservices fail in production. The naive approach—creating a new database connection per request—exhausts PostgreSQL's connection limit at 100-200 concurrent users.

The correct pattern uses postgres-js with a fixed pool size.

typescript
// services/auth/src/database.ts
import { drizzle } from 'drizzle-orm/postgres-js'
import postgres from 'postgres'
import { users } from '@shared/schema'

const queryClient = postgres(process.env.DATABASE_URL!, {
  max: 20,              // Pool of 20 connections
  idle_timeout: 30,    // Close idle connections after 30s
  connect_timeout: 10, // Timeout for new connections
  max_lifetime: 60 * 30 // Close connection after 30 minutes
})

export const db = drizzle(queryClient, { schema: { users } })
export type DB = typeof db

Mount this database client in your Elysia app using .decorate().

typescript
// services/auth/src/index.ts
import { Elysia } from 'elysia'
import { cors } from '@elysiajs/cors'
import { db } from './database'
import { authRoutes } from './routes/auth'

const app = new Elysia()
  .use(cors({ origin: '*', credentials: true }))
  .decorate('db', db)
  .use(authRoutes)
  .listen(3001)

console.log(`Auth service running on port 3001`)

Key insight: Bun's multi-threaded model means a pool size of 20 with 4 worker threads gives you 80 potential connections per service instance. Tune max based on your PostgreSQL's max_connections setting divided by your expected service replicas.

Implementing the API Gateway Pattern with Elysia

The API gateway is the single entry point for all client requests. It handles routing, rate limiting, authentication validation, and forwarding to downstream services.

typescript
// services/gateway/src/index.ts
import { Elysia } from 'elysia'
import { cors } from '@elysiajs/cors'
import { rateLimit } from '@elysiajs/rate-limit'
import { swagger } from '@elysiajs/swagger'
import { authMiddleware } from './middleware/auth'
import { postsProxy } from './proxy/posts'
import { usersProxy } from './proxy/users'

const app = new Elysia()
  .use(cors({ origin: ['https://yourdomain.com'], credentials: true }))
  .use(rateLimit({ max: 100, duration: 60 * 1000 }))
  .use(swagger({ documentation: { info: { title: 'Gateway API', version: '1.0.0' } } }))
  .use(authMiddleware)
  .get('/health', () => ({ status: 'healthy', timestamp: Date.now() }))
  .use(postsProxy)
  .use(usersProxy)
  .listen(3000)

console.log(`API Gateway running on port ${app.server?.port}`)

Create service proxies that forward requests to internal microservices using Bun's native HTTP client (no axios dependency needed).

typescript
// services/gateway/src/proxy/posts.ts
import { Elysia, t } from 'elysia'
import { forward } from 'elysia/forward'

const AUTH_SERVICE_URL = process.env.AUTH_SERVICE_URL ?? 'http://localhost:3001'
const POSTS_SERVICE_URL = process.env.POSTS_SERVICE_URL ?? 'http://localhost:3002'

export const postsProxy = new Elysia()
  .derive(({ headers, set }) => {
    const token = headers['authorization']?.replace('Bearer ', '')
    return { token }
  })
  .get('/posts', async ({ query, set }) => {
    const params = new URLSearchParams(query as Record<string, string>)
    const response = await fetch(`${POSTS_SERVICE_URL}/posts?${params}`)
    return response.json()
  })
  .get('/posts/:id', async ({ params, set }) => {
    const response = await fetch(`${POSTS_SERVICE_URL}/posts/${params.id}`)
    if (!response.ok) { set.status = response.status; return response.json() }
    return response.json()
  })
  .post('/posts', async ({ body, set }) => {
    const response = await fetch(`${POSTS_SERVICE_URL}/posts`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${body.token}` },
      body: JSON.stringify(body)
    })
    set.status = response.status
    return response.json()
  })

Contrarian point: Many tutorials recommend using tools like Kong or NGINX as API gateways. For Elysia microservices, embedding routing logic in Elysia itself reduces network hops and lets you share middleware (auth, logging, rate limiting) across all routes without configuration files. The tradeoff is that your gateway becomes stateful—you'll need Redis or RabbitMQ for distributed rate limiting across multiple gateway instances.

Drizzle ORM PostgreSQL Type-Safe Queries in Practice

Drizzle's query builder shines when you need complex queries with full TypeScript inference.

typescript
// services/posts/src/handlers/posts.ts
import { Elysia, t } from 'elysia'
import { eq, desc, count, sql, and, gte } from 'drizzle-orm'
import { db } from '../database'
import { posts, users } from '@shared/schema'
import { CreatePostBody, UpdatePostBody } from '@shared/validators'

export const postHandlers = new Elysia({ tag: 'Posts' })
  .get('/posts', async ({ query }) => {
    const page = query.page ?? 1
    const limit = Math.min(query.limit ?? 20, 100)
    const offset = (page - 1) * limit

    const [allPosts, [{ total }]] = await Promise.all([
      db
        .select({
          id: posts.id,
          title: posts.title,
          slug: posts.slug,
          published: posts.published,
          viewCount: posts.viewCount,
          createdAt: posts.createdAt,
          author: { id: users.id, username: users.username }
        })
        .from(posts)
        .leftJoin(users, eq(posts.authorId, users.id))
        .where(eq(posts.published, true))
        .orderBy(desc(posts.createdAt))
        .limit(limit)
        .offset(offset),
      db.select({ total: count() }).from(posts).where(eq(posts.published, true))
    ])

    return {
      data: allPosts,
      pagination: { page, limit, total: Number(total), pages: Math.ceil(Number(total) / limit) }
    }
  })
  .get('/posts/:id', async ({ params, set }) => {
    const [post] = await db
      .select()
      .from(posts)
      .where(eq(posts.id, params.id))
      .limit(1)

    if (!post) { set.status = 404; return { error: 'Post not found' } }

    await db.update(posts).set({ viewCount: sql`${posts.viewCount} + 1` }).where(eq(posts.id, params.id))
    return { data: post }
  })
  .post('/posts', async ({ body, set }) => {
    const [newPost] = await db.insert(posts).values(body).returning()
    set.status = 201
    return { data: newPost }
  }, { body: CreatePostBody })
  .patch('/posts/:id', async ({ params, body, set }) => {
    const [updated] = await db
      .update(posts)
      .set({ ...body, updatedAt: new Date() })
      .where(eq(posts.id, params.id))
      .returning()

    if (!updated) { set.status = 404; return { error: 'Post not found' } }
    return { data: updated }
  }, { body: UpdatePostBody, params: t.Object({ id: t.Number() }) })
  .delete('/posts/:id', async ({ params, set }) => {
    const [deleted] = await db.delete(posts).where(eq(posts.id, params.id)).returning({ id: posts.id })
    if (!deleted) { set.status = 404; return { error: 'Post not found' } }
    set.status = 204
  })

The drizzle-typebox bridge ensures your API validation schemas stay synchronized with your database schema. When you add a required column to Drizzle, TypeScript errors on any handler that doesn't include it in the request body.

Elysia Middleware Authentication Best Practices

Authentication in microservices requires a stateless approach—each service validates tokens independently rather than checking a central session store.

typescript
// services/gateway/src/middleware/auth.ts
import { Elysia, t } from 'elysia'
import { jwt } from '@elysiajs/jwt'

const JWT_SECRET = process.env.JWT_SECRET!

export const authMiddleware = new Elysia()
  .use(jwt({ secret: JWT_SECRET, exp: 7 * 24 * 60 * 60 }))
  .derive(({ jwt, set, request: { headers } }) => {
    const path = headers['x-forwarded-path'] ?? ''
    // Skip auth for public routes
    if (path.startsWith('/posts') && !['POST', 'PATCH', 'DELETE'].includes(request.method)) {
      return { user: null }
    }

    const token = headers['authorization']?.replace('Bearer ', '')
    if (!token) { set.status = 401; return { error: 'Missing token' } }

    const payload = jwt.verify(token)
    if (!payload) { set.status = 401; return { error: 'Invalid token' } }


    return { user: payload }
  })

Critical practice: Always verify tokens in each downstream service, not just the gateway. A compromised gateway that forwards valid-looking requests to internal services bypasses your authentication entirely. Each service should have its own JWT verification middleware that re-validates the token payload includes the correct service and role claims.

What Are the Best Practices for Error Handling in Elysia Microservices?

Elysia's .onError() handler lets you centralize error formatting. However, in microservices, you need layered error handling: per-route, per-service, and gateway-level.

typescript
// services/auth/src/index.ts
import { Elysia, t } from 'elysia'
import { PostgrestError } from 'drizzle-orm/postgres-js'

const app = new Elysia()
  .onError(({ code, error, set }) => {
    // Handle Drizzle database errors
    if (error instanceof PostgrestError) {
      if (error.code === '23505') { // PostgreSQL unique violation
        set.status = 409
        return { error: 'Resource already exists', code: 'DUPLICATE_ENTRY' }
      }
      if (error.code === '23503') { // Foreign key violation
        set.status = 400
        return { error: 'Referenced resource not found', code: 'FOREIGN_KEY_VIOLATION' }
      }
    }

    // Handle validation errors
    if (code === 'VALIDATION') {
      set.status = 422
      return { error: 'Validation failed', details: error.message, code: 'VALIDATION_ERROR' }
    }

    // Handle not found
    if (code === 'NOT_FOUND') {
      set.status = 404
      return { error: 'Resource not found', code: 'NOT_FOUND' }
    }

    // Log unexpected errors for debugging
    console.error({ code, message: error.message, stack: error.stack })
    set.status = 500
    return { error: 'Internal server error', code: 'INTERNAL_ERROR' }
  })

Best practice: Never expose raw database errors to clients. PostgreSQL error messages can reveal table names, column names, and even SQL fragments that attackers use for SQL injection reconnaissance. Always transform database errors into generic client-safe messages with an internal log entry that preserves the full error details.

How to Configure Drizzle Migrations for PostgreSQL in a Bun Runtime Environment?

Migrations in microservices must be deterministic and version-controlled. Drizzle Kit generates SQL migration files that you can run as part of your container startup sequence.

typescript
// drizzle.config.ts
import { defineConfig } from 'drizzle-kit'

export default defineConfig({
  schema: './packages/shared/src/schema.ts',
  out: './drizzle',
  dialect: 'postgresql',
  dbCredentials: {
    url: process.env.DATABASE_URL!
  },
  verbose: true,
  strict: true
})

Generate migrations after schema changes:

bash
bunx drizzle-kit generate

Run migrations in your Docker entrypoint before starting the service:

bash
# docker-entrypoint.sh
#!/bin/bash
echo "Running database migrations..."
bunx drizzle-kit migrate

echo "Starting service..."
bun run src/index.ts

Important nuance: In a microservices environment where multiple services share the same database, only one service should run migrations. The auth service typically owns the database schema, and other services are read-only against tables they don't own. This prevents migration race conditions where two services try to alter the same table simultaneously.

What Connection Pooling Strategies Work Best with Elysia and PostgreSQL in Production?

Connection pooling in production Elysia microservices requires balancing three constraints: PostgreSQL's connection limit, service memory usage, and request latency.

StrategyPool SizeUse CaseTradeoff
Shared global pool20 connectionsSingle service instanceSimple but doesn't scale horizontally
Per-instance pool10 connections × 4 replicasHorizontal scalingNeeds centralized monitoring
PgBouncer passthrough100 logical → 20 physicalMulti-service shared DBAdds 1-2ms latency per query
Redis connection poolerDynamic sizingHigh-throughput readsExtra infrastructure dependency

For most Elysia deployments in 2026, the recommended approach uses postgres-js with PgBouncer in transaction mode in front of PostgreSQL. This gives you logical connection pooling without modifying application code.

typescript
// For PgBouncer connection strings
const DATABASE_URL = process.env.DATABASE_URL!.replace('5432', '6432')

// 6432 is PgBouncer's default port

const queryClient = postgres(DATABASE_URL, {
  max: 100,          // Allow up to 100 logical connections per instance
  idle_timeout: 20,
  connect_timeout: 5
})

PgBouncer multiplexes 100 logical connections across 20 physical PostgreSQL connections. Each Elysia service can open 100 connections without exhausting PostgreSQL's limit when you have 5 service replicas.

Service Discovery and Inter-Service Communication

Microservices need a way to discover each other without hardcoded URLs. For development, use Docker Compose service names. For production, implement service discovery using Redis.

typescript
// services/shared/src/discovery.ts
import Redis from 'ioredis'

const redis = new Redis(process.env.REDIS_URL!)

export const registerService = async (name: string, host: string, port: number) => {
  await redis.hset('services', name, JSON.stringify({ host, port, updatedAt: Date.now() }))
  // Heartbeat every 30 seconds
  setInterval(async () => {
    await redis.hset('services', name, JSON.stringify({ host, port, updatedAt: Date.now() }))
  }, 30_000)
}

export const getServiceUrl = async (name: string): Promise<string | null> => {
  const data = await redis.hget('services', name)
  if (!data) return null
  const { host, port } = JSON.parse(data)
  return `http://${host}:${port}`
}

This pattern, combined with RabbitMQ for async messaging between services, handles scenarios where services need to emit events (new user created, post published) without synchronous HTTP calls that create temporal coupling.

Horizontal Scaling Strategies for Elysia Microservices

Horizontal scaling with Elysia and Bun requires two strategies: Bun worker threads for intra-process parallelism and multiple service instances for inter-process scaling.

Intra-process scaling (Bun threads): Bun automatically uses multiple threads for CPU-intensive work. Your Elysia app benefits without code changes.

Inter-process scaling (multiple instances):

yaml
# docker-compose.yml
services:
  gateway:
    build: ./services/gateway
    deploy:
      replicas: 3
    ports:
      - "3000:3000"
    environment:
      - REDIS_URL=redis://redis:6379

  auth:
    build: ./services/auth
    deploy:
      replicas: 2
    environment:
      - DATABASE_URL=postgresql://postgres:password@postgres:5432/auth_db

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"

  postgres:
    image: postgres:16-alpine
    environment:
      - POSTGRES_PASSWORD=password
    volumes:
      - postgres_data:/var/lib/postgresql/data

Deploy this stack to Railway, Fly.io, or Kubernetes. Railway's autoscaling adds instances when CPU exceeds 70%, making it ideal for bursty workloads.

Monitoring and Observability

Production microservices need metrics, logs, and traces. Bun supports OpenTelemetry natively in 2026.

typescript
// services/gateway/src/telemetry.ts
import { trace, metrics, exporters } from '@aspectum/opentelemetry'
import { NodeTracerProvider } from '@aspectum/opentelemetry/node'

const provider = new NodeTracerProvider()
provider.addSpanProcessor(new exporters.BatchingSpanProcessor(new exporters.ConsoleSpanExporter()))
provider.register()

const meter = metrics.getMeter('gateway-service')
const requestCounter = meter.createCounter('http_requests_total', { description: 'Total HTTP requests' })
const latencyHistogram = meter.createHistogram('http_request_duration_ms', { description: 'Request latency in ms' })

Instrument your Elysia handlers:

typescript
.app.get('/posts', async ({ set, timing }) => {
  const start = performance.now()
  requestCounter.add(1, { route: '/posts', method: 'GET' })
  const result = await fetchPosts()
  latencyHistogram.record(performance.now() - start, { route: '/posts' })
  return result
})

This data feeds into Grafana or Datadog dashboards where you can set alerts for p99 latency exceeding 500ms or error rates above 1%.

Key Takeaways

  1. ElysiaJS 1.4 on Bun handles 200k+ req/s, making it viable for API gateway workloads that would bottleneck Node.js frameworks.
  2. Drizzle ORM compiles to raw SQL with zero overhead—your TypeScript schemas are your database schemas, eliminating schema drift.
  3. Connection pooling requires postgres-js with tuned pool sizes: 10-20 connections per service instance, managed through PgBouncer for horizontal scaling.
  4. Service discovery via Redis plus async messaging through RabbitMQ decouples microservices and prevents temporal coupling failures.
  5. Horizontal scaling uses multiple Docker/Kubernetes replicas plus Bun's multi-threaded model, with monitoring via OpenTelemetry feeding into Grafana.

Frequently Asked Questions

How to set up Elysia with Drizzle ORM and PostgreSQL connection pooling

Set up Elysia with Drizzle ORM by installing drizzle-orm, postgres, and drizzle-typebox, then creating a database client using postgres-js with a configured pool size. Use .decorate('db', db) to inject the client into your Elysia app. For PostgreSQL connection pooling, set max: 20 for a single instance or configure PgBouncer in transaction mode for multi-instance deployments.

What are the best practices for error handling in Elysia microservices?

Best practices include: (1) centralize error handling with .onError(), (2) transform PostgreSQL errors (23505, 23503) into client-safe messages, (3) verify tokens in each downstream service not just the gateway, (4) log full error details internally while returning generic messages externally, and (5) implement retry logic with exponential backoff for transient database errors.

How do you implement API gateway pattern with Elysia for microservices?

Implement an API gateway by creating a dedicated Elysia service that handles all incoming requests. Use Elysia's .derive() for authentication middleware, @elysiajs/rate-limit for traffic control, and Bun's native fetch API to proxy requests to downstream services. The gateway service runs on port 3000 while auth, posts, and users services run on non-exposed ports 3001-3003.

How to configure Drizzle migrations for PostgreSQL in a Bun runtime environment?

Configure Drizzle migrations by creating drizzle.config.ts with your schema path and PostgreSQL credentials, then run bunx drizzle-kit generate to create SQL migration files. In your Docker entrypoint, run bunx drizzle-kit migrate before starting the service. For microservices, only the service that owns a database schema should run migrations—typically the auth service.

What connection pooling strategies work best with Elysia and PostgreSQL in production?

The best strategy for Elysia microservices in production uses PgBouncer in transaction mode in front of PostgreSQL. Set it to accept 100 logical connections per service instance and multiplex them across 20 physical PostgreSQL connections. This prevents connection exhaustion when scaling to multiple service replicas while maintaining sub-5ms query latency.

Let's grow together

Holyxey & Yurin.dev