Modern, type-safe backend boilerplate built with Bun, Elysia, and Drizzle ORM.
- High Performance - Bun runtime with native SQL driver
- Type Safety - End-to-end TypeScript with Drizzle ORM
- Authentication - Better Auth integration with session management
- Caching - Redis support with automatic cache invalidation
- API Documentation - Auto-generated OpenAPI/Swagger docs
- Rate Limiting - Distributed rate limiting with Redis
- Health Checks - Kubernetes-ready health endpoints
| Technology | Purpose |
|---|---|
| Bun | Runtime & package manager |
| Elysia | Web framework |
| Drizzle ORM | Type-safe database ORM |
| Better Auth | Authentication |
| PostgreSQL | Database |
| Redis | Caching & rate limiting |
| TypeBox | Runtime validation |
# Clone the repository
git clone https://github.com/your-username/katana.git
cd katana
# Install dependencies
bun install
# Copy environment file
cp .env.example .env
# Start PostgreSQL and Redis
bun run docker:up
# Generate and apply migrations
bun run db:generate
bun run db:migrate
# Start development server
bun run devServer runs at http://localhost:3000
API docs at http://localhost:3000/docs
src/
├── index.ts # Application entry point
├── common/ # Shared utilities
│ ├── errors/ # Error factory functions
│ └── response/ # Response helpers
├── config/ # Configuration files
│ ├── app.ts # Server config
│ ├── cache.ts # Cache TTL settings
│ ├── env.ts # Environment validation
│ └── redis.ts # Redis config
├── core/ # Framework setup
│ ├── app.ts # Elysia app factory
│ ├── logger.ts # Pino logger
│ ├── server.ts # Bootstrap & graceful shutdown
│ ├── middleware/ # Global middleware
│ └── plugins/ # Elysia plugins
├── db/ # Database layer
│ ├── client.ts # Drizzle client
│ ├── schema/ # Table definitions
│ └── migrations/ # SQL migrations
├── infrastructure/ # External services
│ └── cache/ # Redis client & helpers
└── modules/ # Feature modules
├── auth/ # Authentication
├── health/ # Health checks
├── user/ # User management
└── item/ # Example CRUD module
Follow these steps to create a new CRUD module:
// src/db/schema/product.schema.ts
import { pgTable, text, timestamp, integer } from "drizzle-orm/pg-core";
import { user } from "./auth.schema";
export const product = pgTable("product", {
id: text("id").primaryKey(),
name: text("name").notNull(),
price: integer("price").notNull(),
userId: text("user_id").references(() => user.id, { onDelete: "cascade" }),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});
export type ProductRecord = typeof product.$inferSelect;
export type NewProductRecord = typeof product.$inferInsert;Export in src/db/schema/index.ts:
export * from "./product.schema";// src/modules/product/model.ts
import { t } from "elysia";
import type { ProductRecord } from "@/db/schema/product.schema";
export const productSchema = t.Object({
id: t.String(),
name: t.String({ minLength: 1, maxLength: 100 }),
price: t.Number({ minimum: 0 }),
userId: t.Union([t.String(), t.Null()]),
createdAt: t.String({ format: "date-time" }),
updatedAt: t.String({ format: "date-time" }),
});
export const createProductSchema = t.Object({
name: t.String({ minLength: 1, maxLength: 100 }),
price: t.Number({ minimum: 0 }),
});
export const updateProductSchema = t.Object({
name: t.Optional(t.String({ minLength: 1, maxLength: 100 })),
price: t.Optional(t.Number({ minimum: 0 })),
});
// Infer types from schemas
export type Product = ProductRecord;
export type ProductResponse = (typeof productSchema)["static"];
export type CreateProductInput = (typeof createProductSchema)["static"] & {
userId?: string | null;
};
export type UpdateProductInput = (typeof updateProductSchema)["static"];
// Format helper (Date -> ISO string)
export const formatProduct = (product: Product): ProductResponse => ({
...product,
createdAt: product.createdAt.toISOString(),
updatedAt: product.updatedAt.toISOString(),
});// src/modules/product/service.ts
import { eq } from "drizzle-orm";
import { nanoid } from "nanoid";
import { db } from "@/db/client";
import { product } from "@/db/schema";
import { cache } from "@/infrastructure/cache";
import type { CreateProductInput, Product, UpdateProductInput } from "./model";
export const productService = {
async findById(id: string): Promise<Product | null> {
const cached = await cache.get<Product>(`product:${id}`);
if (cached) return cached;
const [record] = await db
.select()
.from(product)
.where(eq(product.id, id))
.limit(1);
if (record) await cache.set(`product:${id}`, record, 300);
return record ?? null;
},
async create(input: CreateProductInput): Promise<Product> {
const [record] = await db
.insert(product)
.values({
id: nanoid(),
name: input.name,
price: input.price,
userId: input.userId ?? null,
createdAt: new Date(),
updatedAt: new Date(),
})
.returning();
return record;
},
async update(id: string, input: UpdateProductInput): Promise<Product | null> {
const [record] = await db
.update(product)
.set({ ...input, updatedAt: new Date() })
.where(eq(product.id, id))
.returning();
if (record) await cache.del(`product:${id}`);
return record ?? null;
},
async delete(id: string): Promise<boolean> {
const result = await db.delete(product).where(eq(product.id, id)).returning();
if (result.length > 0) await cache.del(`product:${id}`);
return result.length > 0;
},
};// src/modules/product/index.ts
import { Elysia } from "elysia";
import { notFound } from "@/common/errors";
import { dataResponseSchema, json } from "@/common/response";
import { authPlugin } from "@/modules/auth";
import {
createProductSchema,
formatProduct,
productSchema,
updateProductSchema,
} from "./model";
import { productService } from "./service";
export const productRoutes = new Elysia({ prefix: "/products" })
.use(authPlugin)
.get("/:id", async ({ params }) => {
const product = await productService.findById(params.id);
if (!product) throw notFound("Product not found");
return json(formatProduct(product));
}, {
detail: { tags: ["Product"], summary: "Get product" },
response: dataResponseSchema(productSchema),
})
.post("/", async ({ body, user }) => {
const product = await productService.create({ ...body, userId: user.id });
return json(formatProduct(product));
}, {
auth: true,
body: createProductSchema,
detail: { tags: ["Product"], summary: "Create product" },
response: dataResponseSchema(productSchema),
})
.patch("/:id", async ({ params, body }) => {
const product = await productService.update(params.id, body);
if (!product) throw notFound("Product not found");
return json(formatProduct(product));
}, {
auth: true,
body: updateProductSchema,
detail: { tags: ["Product"], summary: "Update product" },
response: dataResponseSchema(productSchema),
})
.delete("/:id", async ({ params, set }) => {
const deleted = await productService.delete(params.id);
if (!deleted) throw notFound("Product not found");
set.status = 204;
}, {
auth: true,
detail: { tags: ["Product"], summary: "Delete product" },
});
export type ProductRoutes = typeof productRoutes;
export { productService } from "./service";
export type { Product, CreateProductInput } from "./model";// src/modules/index.ts
import { productRoutes } from "./product";
export const v1Routes = new Elysia({ prefix: "/api/v1" })
.use(authPlugin)
.use(userRoutes)
.use(itemRoutes)
.use(productRoutes); // Add your new module
export { productRoutes } from "./product";bun run db:generate
bun run db:migrate{ "data": { "id": "abc123", "name": "Example" } }{
"data": [{ "id": "abc123", "name": "Example" }],
"meta": { "page": 1, "limit": 10, "total": 100, "totalPages": 10 }
}{
"error": {
"code": "NOT_FOUND",
"message": "Resource not found",
"requestId": "req_abc123"
}
}Use functional error factories:
import { notFound, badRequest, forbidden, unauthorized } from "@/common/errors";
// In route handlers
if (!entity) throw notFound("Entity not found");
if (!valid) throw badRequest("Invalid input", { field: "email" });
if (!owner) throw forbidden("Access denied");
if (!authenticated) throw unauthorized();// Required authentication - `user` is guaranteed
.get("/me", ({ user }) => json(user), { auth: true })
// Optional authentication - check `currentUser`
.get("/public", ({ currentUser }) => {
if (currentUser) {
// Authenticated user
}
// Guest user
})import { cache } from "@/infrastructure/cache";
// Get or set with TTL
const data = await cache.getOrSet("key", () => fetchData(), 300);
// Manual operations
await cache.set("key", value, 60);
const cached = await cache.get<Type>("key");
await cache.del("key");
await cache.delPattern("user:*");| Endpoint | Description |
|---|---|
GET /health |
Basic health check |
GET /health/live |
Kubernetes liveness probe |
GET /health/ready |
Kubernetes readiness probe (checks DB & Redis) |
| Variable | Required | Default | Description |
|---|---|---|---|
DATABASE_URL |
Yes | - | PostgreSQL connection string |
BETTER_AUTH_SECRET |
Yes | - | Auth secret (32+ chars) |
BETTER_AUTH_URL |
Yes | - | Application base URL |
PORT |
No | 3000 |
Server port |
HOST |
No | 0.0.0.0 |
Server host |
NODE_ENV |
No | development |
Environment |
REDIS_URL |
No | - | Redis connection string |
REDIS_ENABLED |
No | false |
Enable Redis |
LOG_LEVEL |
No | info |
Log level |
CORS_ORIGINS |
No | * |
Allowed CORS origins |
RATE_LIMIT_ENABLED |
No | true |
Enable rate limiting |
| Script | Description |
|---|---|
bun run dev |
Start development server with hot reload |
bun run start |
Start production server |
bun run test |
Run tests |
bun run test:watch |
Run tests in watch mode |
bun run db:generate |
Generate migrations from schema |
bun run db:migrate |
Apply pending migrations |
bun run db:studio |
Open Drizzle Studio |
bun run biome:check |
Lint and format code |
bun run docker:up |
Start Docker services |
bun run docker:down |
Stop Docker services |
MIT