Skip to content

koative/katana

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

2 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Katana

Modern, type-safe backend boilerplate built with Bun, Elysia, and Drizzle ORM.

Features

  • 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

Tech Stack

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

Quick Start

Prerequisites

Installation

# 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 dev

Server runs at http://localhost:3000 API docs at http://localhost:3000/docs

Project Structure

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

Creating a New Module

Follow these steps to create a new CRUD module:

1. Create Database Schema

// 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";

2. Create Model (Validation Schemas)

// 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(),
});

3. Create Service (Business Logic)

// 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;
  },
};

4. Create Routes

// 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";

5. Register the Module

// 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";

6. Generate Migration

bun run db:generate
bun run db:migrate

API Response Format

Success Response

{ "data": { "id": "abc123", "name": "Example" } }

Paginated Response

{
  "data": [{ "id": "abc123", "name": "Example" }],
  "meta": { "page": 1, "limit": 10, "total": 100, "totalPages": 10 }
}

Error Response

{
  "error": {
    "code": "NOT_FOUND",
    "message": "Resource not found",
    "requestId": "req_abc123"
  }
}

Error Handling

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();

Authentication

// 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
})

Caching

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:*");

Health Endpoints

Endpoint Description
GET /health Basic health check
GET /health/live Kubernetes liveness probe
GET /health/ready Kubernetes readiness probe (checks DB & Redis)

Environment Variables

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

Scripts

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

License

MIT

About

Modern, type-safe backend boilerplate built with Bun, Elysia, and Drizzle ORM.

Topics

Resources

Stars

Watchers

Forks