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?
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
Recommended Monorepo Structure
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.tsThis 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.
// 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.$inferInsertPublish 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.
// 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 dbMount this database client in your Elysia app using .decorate().
// 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.
// 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).
// 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.
// 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.
// 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.
// 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.
// 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:
bunx drizzle-kit generateRun migrations in your Docker entrypoint before starting the service:
# docker-entrypoint.sh
#!/bin/bash
echo "Running database migrations..."
bunx drizzle-kit migrate
echo "Starting service..."
bun run src/index.tsImportant 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.
| Strategy | Pool Size | Use Case | Tradeoff |
|---|---|---|---|
| Shared global pool | 20 connections | Single service instance | Simple but doesn't scale horizontally |
| Per-instance pool | 10 connections × 4 replicas | Horizontal scaling | Needs centralized monitoring |
| PgBouncer passthrough | 100 logical → 20 physical | Multi-service shared DB | Adds 1-2ms latency per query |
| Redis connection pooler | Dynamic sizing | High-throughput reads | Extra 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.
// 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.
// 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):
# 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/dataDeploy 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.
// 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:
.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
- ElysiaJS 1.4 on Bun handles 200k+ req/s, making it viable for API gateway workloads that would bottleneck Node.js frameworks.
- Drizzle ORM compiles to raw SQL with zero overhead—your TypeScript schemas are your database schemas, eliminating schema drift.
- Connection pooling requires
postgres-jswith tuned pool sizes: 10-20 connections per service instance, managed through PgBouncer for horizontal scaling. - Service discovery via Redis plus async messaging through RabbitMQ decouples microservices and prevents temporal coupling failures.
- 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.