Skip to content

Building TicketFlow: A Progressive Karin Tutorial

Welcome to the TicketFlow tutorial! In this guide, we will build a production-ready event booking API step-by-step. Instead of copy-pasting final code, we will evolve the application incrementally, adding features and complexity as we go.


Prerequisites

Before starting, ensure you have:

bash
# Bun 1.2.10 or higher
bun --version

# MongoDB running locally
docker run -d -p 27017:27017 --name mongodb mongo

# Redis running locally
docker run -d -p 6379:6379 --name redis redis

Phase 1: Project Initialization

Let's start by creating a new Karin project using the CLI.

Step 1: Scaffold the Project

bash
# Install CLI globally
bun install -g @project-karin/cli

# Create project
karin new ticket-flow

Select the following options:

  1. Project Name: ticket-flow
  2. Environment: Traditional Server (Bun)
  3. Framework Adapter: H3 (High Performance)
  4. Init Git & Install Deps: Yes

Navigate to the folder:

bash
cd ticket-flow

Step 2: Verify Installation

Run the development server to make sure everything works:

bash
bun run dev

Visit http://localhost:3000 and you should see a JSON response.


Phase 2: Database Integration (MongoDB)

Now we will add persistence to our application using MongoDB.

Step 3: Install Dependencies

We need the Mongoose plugin and the Config plugin to manage environment variables.

bash
bun add @project-karin/mongoose @project-karin/config

Step 4: Generate Events Resource

Use the CLI to generate the structure for our "Events" feature.

bash
karin g resource events

Select Yes when asked to generate CRUD entry points.

This creates:

  • src/events/entities/events.entity.ts
  • src/events/events.controller.ts
  • src/events/events.service.ts
  • DTOs folder

Step 5: Define the Event Entity

Open src/events/entities/events.entity.ts and define the schema:

typescript
import { Schema, Prop } from "@project-karin/mongoose";

@Schema("Events")
export class Events {
  @Prop({ required: true, index: true })
  title: string;

  @Prop({ required: true })
  date: Date;

  @Prop({ required: true, min: 0 })
  price: number;

  @Prop({ required: true, min: 0 })
  stock: number;
}

Step 6: Configure main.ts (Version 1)

Now we need to register the plugins and the model in our application entry point.

Open src/main.ts:

typescript
import { ConfigPlugin } from "@project-karin/config";
import { KarinFactory, Logger } from "@project-karin/core";
import { MongoosePlugin } from "@project-karin/mongoose";
import { H3Adapter } from "@project-karin/platform-h3";
import { Events } from "./events/entities/events.entity"; // Import entity

async function bootstrap() {
  const logger = new Logger("Bootstrap");

  // 1. Initialize Config
  const config = new ConfigPlugin({
    requiredKeys: ["MONGO_URI"],
  });

  // 2. Initialize Mongoose
  const mongoose = new MongoosePlugin({
    uri: () => config.get("MONGO_URI"), // Lazy resolution
    options: () => ({ dbName: "tickets" }),
    models: [Events], // ✅ Explicitly register the model
  });

  // 3. Create App
  const app = await KarinFactory.create(new H3Adapter(), {
    scan: "./src/**/*.ts",
    plugins: [config, mongoose],
  });

  app.listen(3000, () => {
    logger.info(`Server running on http://localhost:3000 🚀`);
  });
}

bootstrap();

Create a .env file in the root:

env
MONGO_URI=mongodb://localhost:27017

Step 7: Implement Basic Service

Open src/events/events.service.ts and implement the create method:

typescript
import { Service } from "@project-karin/core";
import { InjectModel, Model } from "@project-karin/mongoose";
import { Events } from "./entities/events.entity";
import type { CreateEventsDto } from "./dtos/create-events.dto";

@Service()
export class EventsService {
  constructor(
    @InjectModel("Events") private readonly eventModel: Model<Events>,
  ) {}

  async create(data: CreateEventsDto) {
    return this.eventModel.create(data);
  }

  async findAll() {
    return this.eventModel.find().exec();
  }
}

Phase 3: Validation & Error Handling

To make our API robust, we need to validate inputs and handle database errors gracefully.

Step 8: Define Validation Schema

Open src/events/dtos/create-events.dto.ts:

typescript
import { z } from "zod";

export const CreateEventsSchema = z.object({
  title: z.string().min(3, "Title must be at least 3 characters"),
  date: z.coerce.date(),
  price: z.number().min(0),
  stock: z.number().int().min(1),
});

export type CreateEventsDto = z.infer<typeof CreateEventsSchema>;

Step 9: Apply Validation Pipe

Update src/events/events.controller.ts to use ZodValidationPipe:

typescript
import { Controller, Post, Body, ZodValidationPipe } from "@project-karin/core";
import { CreateEventsSchema, CreateEventsDto } from "./dtos/create-events.dto";
// ... imports

@Controller("/events")
export class EventsController {
  constructor(private readonly service: EventsService) {}

  @Post("/")
  create(
    // ✅ Validate body against schema
    @Body(new ZodValidationPipe(CreateEventsSchema)) body: CreateEventsDto,
  ) {
    return this.service.create(body);
  }
  // ...
}

Step 10: Create Global Exception Filter

Create src/common/filters/mongoose.filter.ts to handle DB errors:

typescript
import { Catch, ExceptionFilter, ArgumentsHost } from "@project-karin/core";
import { Error as MongooseError } from "mongoose";

@Catch(MongooseError.CastError, MongooseError.ValidationError)
export class MongooseExceptionFilter implements ExceptionFilter {
  catch(exception: any, host: ArgumentsHost) {
    return new Response(
      JSON.stringify({
        statusCode: 400,
        message: "Database Validation Error",
        details: exception.message,
      }),
      { status: 400 },
    );
  }
}

Step 11: Register Filter Globally

Update src/main.ts (Version 2):

typescript
import { MongooseExceptionFilter } from "./common/filters/mongoose.filter";

// ... inside bootstrap
const app = await KarinFactory.create(new H3Adapter(), {
  scan: "./src/**/*.ts",
  plugins: [config, mongoose],
  globalFilters: [new MongooseExceptionFilter()], // ✅ Register global filter
});

Phase 4: Performance Optimization (Redis)

Our API works, but we want it to be fast. Let's add Redis caching.

Step 12: Install Redis Plugin

bash
bun add @project-karin/redis ioredis

Step 13: Update main.ts (Version 3)

typescript
// ... imports
import { RedisPlugin } from "@project-karin/redis";

async function bootstrap() {
  // ... config setup

  const redis = new RedisPlugin({
    url: "redis://localhost:6379",
    failureStrategy: "warn",
  });

  const app = await KarinFactory.create(new H3Adapter(), {
    scan: "./src/**/*.ts",
    plugins: [config, mongoose, redis], // Add redis here
    globalFilters: [new MongooseExceptionFilter()],
  });
}

Step 14: Add Caching to Service

Update src/events/events.service.ts:

typescript
import { InjectRedis, Redis } from "@project-karin/redis";

@Service()
export class EventsService {
  constructor(
    @InjectModel("Events") private readonly eventModel: Model<Events>,
    @InjectRedis() private redis: Redis,
  ) {}

  async findAll() {
    const cached = await this.redis.get("events:all");
    if (cached) return JSON.parse(cached);

    const events = await this.eventModel.find().exec();
    await this.redis.set("events:all", JSON.stringify(events), "EX", 60);
    return events;
  }
}

Phase 5: Complex Logic (Bookings)

Now let's add the booking system with atomic operations.

Step 15: Generate Bookings Resource

bash
karin g resource bookings

Step 16: Define Booking Entity

src/bookings/entities/bookings.entity.ts:

typescript
import { Schema, Prop } from "@project-karin/mongoose";

@Schema("Bookings")
export class Bookings {
  @Prop({ required: true }) eventId: string;
  @Prop({ required: true }) userId: string;
  @Prop({ required: true }) quantity: number;
  @Prop({ required: true }) totalPrice: number;
  @Prop({ default: Date.now }) createdAt: Date;
}

Step 17: Update main.ts (Version 4)

Register the new model:

typescript
import { Bookings } from "./bookings/entities/bookings.entity";

// ...
const mongoose = new MongoosePlugin({
  // ...
  models: [Events, Bookings], // ✅ Add Bookings
});

Step 18: Implement Atomic Booking

In src/bookings/bookings.service.ts:

typescript
import { Service, BadRequestException } from "@project-karin/core";
import { InjectModel, Model } from "@project-karin/mongoose";
import { Bookings } from "./entities/bookings.entity";
import { Events } from "../../events/entities/events.entity";

@Service()
export class BookingsService {
  constructor(
    @InjectModel("Bookings") private bookingModel: Model<Bookings>,
    @InjectModel("Events") private eventModel: Model<Events>,
  ) {}

  async create(userId: string, eventId: string, quantity: number) {
    // Atomic operation: Decrement stock ONLY if enough exists
    const event = await this.eventModel.findOneAndUpdate(
      { _id: eventId, stock: { $gte: quantity } },
      { $inc: { stock: -quantity } },
      { new: true },
    );

    if (!event) throw new BadRequestException("Sold out");

    return this.bookingModel.create({
      userId,
      eventId,
      quantity,
      totalPrice: event.price * quantity,
    });
  }
}

Phase 6: Security (Guards)

Step 19: Create Auth Guard

src/common/auth.guard.ts:

typescript
import {
  CanActivate,
  ExecutionContext,
  UnauthorizedException,
} from "@project-karin/core";

export class AuthGuard implements CanActivate {
  canActivate(context: ExecutionContext): boolean {
    const req = context.switchToHttp().getRequest();
    if (!req.headers.get("authorization")) throw new UnauthorizedException();
    return true;
  }
}

Step 20: Protect Controller

src/bookings/bookings.controller.ts:

typescript
import { Controller, Post, Body, UseGuards } from "@project-karin/core";
import { AuthGuard } from "../common/auth.guard";
import { BookingsService } from "./bookings.service";

@Controller("/bookings")
@UseGuards(AuthGuard) // 🛡️ Protect routes
export class BookingsController {
  constructor(private service: BookingsService) {}

  @Post()
  create(@Body() body: any) {
    return this.service.create("user_123", body.eventId, body.quantity);
  }
}

Phase 7: Documentation (OpenAPI)

Finally, let's generate automatic documentation for our API.

Step 21: Install OpenAPI Plugin

bash
bun add @project-karin/openapi

Step 22: Final main.ts

Update main.ts to include the OpenAPI plugin.

typescript
import { OpenApiPlugin } from "@project-karin/openapi";
import { AnalyticsPlugin } from "./common/plugins/analytics.plugin";

async function bootstrap() {
  // ... previous config

  const openapi = new OpenApiPlugin({
    title: "TicketFlow API",
    version: "1.0.0",
    path: "/docs",
  });

  const app = await KarinFactory.create(new H3Adapter(), {
    scan: "./src/**/*.ts",
    plugins: [config, mongoose, redis, openapi],
    globalFilters: [new MongooseExceptionFilter()],
  });

  const port = parseInt(config.get("PORT") || "3000", 10);
  app.listen(port, () => {
    console.log(`Docs available at http://localhost:${port}/docs`);
  });
}

Now visit http://localhost:3000/docs to see your interactive API documentation! 🎉


Conclusion

You have built a complete, production-ready API with:

  • Validation (Zod)
  • Error Handling (Global Filters)
  • Database (Mongoose with Atomic Ops)
  • Caching (Redis)
  • Security (Guards)
  • Documentation (OpenAPI)

Happy coding with Karin! 🦊

Released under the MIT License.