Belajar membuat REST API tiket kereta menggunakan ElysiaJS, Supabase sebagai database, dan Docker untuk containerization. Tutorial ini menggunakan pendekatan MVP (Minimum Viable Product) lengkap dengan load testing menggunakan Bombardier untuk memastikan API siap production.
Bagian 1: Pengantar MVP & Tech Stack
Saya Angga Risky Setiawan, Founder dan CEO BuildWithAngga. Di tutorial ini kita akan bangun sistem booking tiket kereta dari nol — tapi dengan mindset MVP. Artinya, kita fokus ke fitur inti yang benar-benar dibutuhkan, bukan fitur "nice to have".
Apa itu MVP (Minimum Viable Product)?
MVP adalah versi paling sederhana dari produk yang masih bisa memberikan value ke user. Konsepnya:
Fokus ke Core Features Fitur yang tanpanya produk tidak bisa digunakan sama sekali.
Validate First, Scale Later Tujuan MVP adalah membuktikan ide works, bukan membangun produk sempurna.
Iterate Based on Feedback Setelah MVP live, kumpulkan feedback dan improve.
Banyak developer terjebak membangun fitur lengkap dari awal — payment gateway, seat selection, loyalty points, promo codes — lalu kehabisan waktu atau budget sebelum produk launch. MVP menghindari ini.
Fitur MVP Tiket Kereta
Untuk sistem tiket kereta, ini fitur yang benar-benar essential:
Yang Kita Bangun (MVP):
- Stations — Data stasiun kereta
- Schedules — Jadwal kereta (rute, waktu, harga, seat available)
- Bookings — Pemesanan tiket dengan data penumpang
Yang TIDAK Kita Bangun (Future Features):
- ❌ Payment gateway integration
- ❌ Seat selection (pilih kursi spesifik)
- ❌ User authentication & accounts
- ❌ Loyalty points & membership
- ❌ Promo codes & discounts
- ❌ E-ticket dengan QR code
- ❌ Notification (email/SMS)
Fitur-fitur di atas penting, tapi bukan untuk MVP. Kita bisa tambahkan setelah core system proven works.
Tech Stack
| Technology | Purpose | Why Choose |
|---|---|---|
| ElysiaJS | Backend framework | Fast, type-safe, Bun-optimized |
| Supabase | Database | Free PostgreSQL, easy setup, realtime |
| Docker | Containerization | Consistent environment across machines |
| Bombardier | Load testing | Validate performance before production |
| Bun | Runtime | Faster than Node.js |
Kenapa kombinasi ini?
ElysiaJS memberikan developer experience yang excellent dengan built-in validation dan type safety. Performance-nya juga top tier karena optimized untuk Bun.
Supabase adalah PostgreSQL yang sudah di-manage. Tidak perlu setup database server sendiri, tinggal pakai. Free tier-nya generous — 500MB database, 1GB storage.
Docker memastikan "works on my machine" tidak jadi masalah. Environment di laptop kamu = environment di server production.
Bombardier untuk load testing. Sebelum deploy, kita perlu tahu API kita bisa handle berapa request per second.
Architecture Overview
┌─────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Client │────▶│ ElysiaJS API │────▶│ Supabase │
│ (Postman/ │ │ (Docker) │ │ PostgreSQL │
│ Frontend) │ └─────────────────┘ └─────────────────┘
└─────────────┘ │
▼
┌─────────────────┐
│ Bombardier │
│ (Load Test) │
└─────────────────┘
Flow sederhana:
- Client hit API endpoint
- ElysiaJS process request, validate input
- Query ke Supabase (PostgreSQL)
- Return response ke client
Docker membungkus ElysiaJS API supaya bisa dijalankan di mana saja dengan environment yang sama.
Endpoint yang Akan Dibuat
| Category | Endpoints | Description |
|---|---|---|
| Stations | 2 | List & detail stasiun |
| Schedules | 2 | Search & detail jadwal |
| Bookings | 4 | Create, view, confirm, cancel |
| Health | 1 | Health check |
Total 9 endpoints untuk MVP yang fully functional.
Expected Output
Setelah selesai tutorial ini, kamu akan punya:
# API running di Docker
<http://localhost:3000>
# Swagger documentation
<http://localhost:3000/docs>
# Sample request
curl <http://localhost:3000/schedules?from=GMR&to=BD>
Response:
{
"data": [
{
"id": "uuid-here",
"train_name": "Argo Parahyangan",
"departure_time": "06:00",
"arrival_time": "09:00",
"price": 150000,
"available_seats": 50,
"origin": { "code": "GMR", "name": "Gambir", "city": "Jakarta" },
"destination": { "code": "BD", "name": "Bandung", "city": "Bandung" }
}
]
}
💡 Mini Tips: MVP bukan tentang "quick and dirty code". Tetap tulis clean code dengan proper validation dan error handling — tapi batasi scope fitur. Technical debt di MVP akan sangat mahal dibayar nanti ketika scaling.
Di bagian selanjutnya, kita akan setup Supabase dan buat database schema.
Bagian 2: Setup Supabase & Database Schema
Supabase adalah "Firebase alternative" yang menggunakan PostgreSQL. Kita dapat database, authentication, storage, dan realtime subscriptions — semua gratis untuk tier awal.
Create Supabase Project
- Buka supabase.com dan sign up/login
- Create New Project
- Organization: Pilih atau buat baru
- Project name:
tiket-kereta - Database password: Generate strong password (simpan!)
- Region: Singapore (terdekat untuk Indonesia)
- Tunggu provisioning (~2 menit)
- Copy credentials dari Project Settings → API:
- Project URL:
https://xxxxx.supabase.co - Service Role Key:
eyJhbGc...(secret, jangan expose ke frontend!)
- Project URL:
Database Schema
Buka SQL Editor di Supabase dashboard dan jalankan query berikut:
-- =============================================
-- STATIONS TABLE
-- =============================================
CREATE TABLE stations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
code VARCHAR(5) UNIQUE NOT NULL, -- "GMR", "BD", "YK"
name VARCHAR(100) NOT NULL, -- "Gambir", "Bandung"
city VARCHAR(50) NOT NULL, -- "Jakarta", "Bandung"
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Index untuk pencarian by code
CREATE INDEX idx_stations_code ON stations(code);
-- =============================================
-- SCHEDULES TABLE
-- =============================================
CREATE TABLE schedules (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
train_name VARCHAR(100) NOT NULL, -- "Argo Parahyangan"
train_number VARCHAR(20) NOT NULL, -- "77A"
origin_id UUID NOT NULL REFERENCES stations(id),
destination_id UUID NOT NULL REFERENCES stations(id),
departure_time TIME NOT NULL,
arrival_time TIME NOT NULL,
price INTEGER NOT NULL, -- dalam Rupiah
available_seats INTEGER NOT NULL,
class VARCHAR(20) NOT NULL, -- "eksekutif", "bisnis", "ekonomi"
created_at TIMESTAMPTZ DEFAULT NOW(),
-- Constraint: origin != destination
CONSTRAINT different_stations CHECK (origin_id != destination_id)
);
-- Indexes untuk filter
CREATE INDEX idx_schedules_origin ON schedules(origin_id);
CREATE INDEX idx_schedules_destination ON schedules(destination_id);
CREATE INDEX idx_schedules_class ON schedules(class);
-- =============================================
-- BOOKINGS TABLE
-- =============================================
CREATE TABLE bookings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
booking_code VARCHAR(10) UNIQUE NOT NULL, -- "TKT-ABC123"
schedule_id UUID NOT NULL REFERENCES schedules(id),
passenger_name VARCHAR(100) NOT NULL,
passenger_id_number VARCHAR(16) NOT NULL, -- NIK (16 digit)
passenger_phone VARCHAR(15) NOT NULL,
seat_count INTEGER NOT NULL DEFAULT 1,
total_price INTEGER NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
-- Constraint: valid status
CONSTRAINT valid_status CHECK (status IN ('pending', 'confirmed', 'cancelled')),
-- Constraint: seat count
CONSTRAINT valid_seat_count CHECK (seat_count BETWEEN 1 AND 4)
);
-- Index untuk lookup by booking code
CREATE INDEX idx_bookings_code ON bookings(booking_code);
CREATE INDEX idx_bookings_status ON bookings(status);
Insert Sample Data
-- =============================================
-- SAMPLE STATIONS
-- =============================================
INSERT INTO stations (code, name, city) VALUES
('GMR', 'Gambir', 'Jakarta'),
('BD', 'Bandung', 'Bandung'),
('YK', 'Tugu Yogyakarta', 'Yogyakarta'),
('SB', 'Surabaya Gubeng', 'Surabaya'),
('SM', 'Semarang Tawang', 'Semarang'),
('CRB', 'Cirebon', 'Cirebon'),
('PSE', 'Pasar Senen', 'Jakarta'),
('MLG', 'Malang', 'Malang');
-- =============================================
-- SAMPLE SCHEDULES
-- =============================================
INSERT INTO schedules (train_name, train_number, origin_id, destination_id, departure_time, arrival_time, price, available_seats, class) VALUES
-- Jakarta - Bandung
('Argo Parahyangan', '77A',
(SELECT id FROM stations WHERE code='GMR'),
(SELECT id FROM stations WHERE code='BD'),
'06:00', '09:00', 150000, 50, 'eksekutif'),
('Argo Parahyangan', '79A',
(SELECT id FROM stations WHERE code='GMR'),
(SELECT id FROM stations WHERE code='BD'),
'12:00', '15:00', 150000, 50, 'eksekutif'),
('Pangandaran', '157',
(SELECT id FROM stations WHERE code='GMR'),
(SELECT id FROM stations WHERE code='BD'),
'08:00', '11:30', 80000, 80, 'ekonomi'),
-- Jakarta - Yogyakarta
('Taksaka', '33',
(SELECT id FROM stations WHERE code='GMR'),
(SELECT id FROM stations WHERE code='YK'),
'07:30', '15:30', 350000, 60, 'bisnis'),
('Taksaka', '35',
(SELECT id FROM stations WHERE code='GMR'),
(SELECT id FROM stations WHERE code='YK'),
'20:00', '04:00', 350000, 60, 'bisnis'),
-- Jakarta - Surabaya
('Argo Wilis', '15',
(SELECT id FROM stations WHERE code='GMR'),
(SELECT id FROM stations WHERE code='SB'),
'08:00', '18:00', 450000, 40, 'eksekutif'),
('Sembrani', '85',
(SELECT id FROM stations WHERE code='PSE'),
(SELECT id FROM stations WHERE code='SB'),
'16:00', '04:30', 280000, 100, 'ekonomi'),
-- Bandung - Yogyakarta
('Lodaya', '17',
(SELECT id FROM stations WHERE code='BD'),
(SELECT id FROM stations WHERE code='YK'),
'06:30', '14:00', 200000, 70, 'bisnis');
Entity Relationship
stations
├── id (PK)
├── code (unique)
├── name
└── city
│
▼
schedules
├── id (PK)
├── train_name
├── origin_id (FK → stations.id)
├── destination_id (FK → stations.id)
├── departure_time
├── arrival_time
├── price
├── available_seats
└── class
│
▼
bookings
├── id (PK)
├── booking_code (unique)
├── schedule_id (FK → schedules.id)
├── passenger_name
├── passenger_id_number
├── passenger_phone
├── seat_count
├── total_price
└── status
Setup Project & Supabase Client
# Create project
bun create elysia tiket-kereta
cd tiket-kereta
# Install dependencies
bun add @supabase/supabase-js @elysiajs/cors @elysiajs/swagger
Buat file .env:
# .env
SUPABASE_URL=https://xxxxx.supabase.co
SUPABASE_SERVICE_ROLE=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Buat Supabase client:
// src/lib/supabase.ts
import { createClient } from '@supabase/supabase-js'
const { SUPABASE_URL, SUPABASE_SERVICE_ROLE } = process.env
if (!SUPABASE_URL || !SUPABASE_SERVICE_ROLE) {
throw new Error('Missing Supabase environment variables')
}
export const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE)
Project Structure
tiket-kereta/
├── src/
│ ├── index.ts # Entry point
│ ├── lib/
│ │ └── supabase.ts # Supabase client
│ └── routes/
│ ├── stations.ts # Station endpoints
│ ├── schedules.ts # Schedule endpoints
│ └── bookings.ts # Booking endpoints
├── Dockerfile
├── docker-compose.yml
├── .env
├── .dockerignore
├── package.json
└── tsconfig.json
Verify Database
Cek data sudah masuk dengan query di Supabase SQL Editor:
-- Check stations
SELECT * FROM stations ORDER BY name;
-- Check schedules with joins
SELECT
s.train_name,
s.train_number,
o.name as origin,
d.name as destination,
s.departure_time,
s.arrival_time,
s.price,
s.class
FROM schedules s
JOIN stations o ON s.origin_id = o.id
JOIN stations d ON s.destination_id = d.id
ORDER BY s.departure_time;
Harusnya ada 8 stasiun dan 8 jadwal kereta.
💡 Mini Tips: Gunakan service_role key untuk backend karena bypass Row Level Security (RLS). Untuk client-side apps (frontend), gunakan anon key dan enable RLS untuk security. Jangan pernah expose service_role ke frontend!
Di bagian selanjutnya, kita akan implementasi routes untuk Stations dan Schedules.
Bagian 3: Routes — Stations & Schedules
Sekarang kita implementasi routes untuk Stations dan Schedules. Kedua route ini read-only karena data stasiun dan jadwal biasanya dikelola oleh admin, bukan user biasa.
Stations Routes
// src/routes/stations.ts
import { Elysia, t } from 'elysia'
import { supabase } from '../lib/supabase'
export const stationRoutes = new Elysia({ prefix: '/stations' })
// ============================================
// GET /stations - List semua stasiun
// ============================================
.get('/', async ({ query }) => {
let queryBuilder = supabase
.from('stations')
.select('*')
// Search by name or city
if (query.search) {
queryBuilder = queryBuilder.or(
`name.ilike.%${query.search}%,city.ilike.%${query.search}%`
)
}
const { data, error } = await queryBuilder.order('name')
if (error) {
throw new Error(error.message)
}
return {
data,
total: data?.length || 0
}
}, {
query: t.Object({
search: t.Optional(t.String())
}),
detail: {
tags: ['Stations'],
summary: 'Get all stations',
description: 'List semua stasiun. Bisa filter dengan search by name atau city.'
}
})
// ============================================
// GET /stations/:code - Detail stasiun by code
// ============================================
.get('/:code', async ({ params, set }) => {
const { data, error } = await supabase
.from('stations')
.select('*')
.eq('code', params.code.toUpperCase())
.single()
if (error || !data) {
set.status = 404
return {
error: 'NOT_FOUND',
message: `Stasiun dengan kode ${params.code.toUpperCase()} tidak ditemukan`
}
}
return { data }
}, {
params: t.Object({
code: t.String({ minLength: 2, maxLength: 5 })
}),
detail: {
tags: ['Stations'],
summary: 'Get station by code',
description: 'Detail stasiun berdasarkan kode (contoh: GMR, BD, YK)'
}
})
Schedules Routes
// src/routes/schedules.ts
import { Elysia, t } from 'elysia'
import { supabase } from '../lib/supabase'
export const scheduleRoutes = new Elysia({ prefix: '/schedules' })
// ============================================
// GET /schedules - Search jadwal kereta
// ============================================
.get('/', async ({ query, set }) => {
// Base query dengan JOIN ke stations
let queryBuilder = supabase
.from('schedules')
.select(`
id,
train_name,
train_number,
departure_time,
arrival_time,
price,
available_seats,
class,
origin:stations!origin_id(id, code, name, city),
destination:stations!destination_id(id, code, name, city)
`)
// Filter by origin station code
if (query.from) {
const { data: originStation } = await supabase
.from('stations')
.select('id')
.eq('code', query.from.toUpperCase())
.single()
if (!originStation) {
set.status = 400
return {
error: 'INVALID_ORIGIN',
message: `Stasiun asal ${query.from.toUpperCase()} tidak ditemukan`
}
}
queryBuilder = queryBuilder.eq('origin_id', originStation.id)
}
// Filter by destination station code
if (query.to) {
const { data: destStation } = await supabase
.from('stations')
.select('id')
.eq('code', query.to.toUpperCase())
.single()
if (!destStation) {
set.status = 400
return {
error: 'INVALID_DESTINATION',
message: `Stasiun tujuan ${query.to.toUpperCase()} tidak ditemukan`
}
}
queryBuilder = queryBuilder.eq('destination_id', destStation.id)
}
// Filter by class
if (query.class) {
queryBuilder = queryBuilder.eq('class', query.class.toLowerCase())
}
// Filter by available seats
if (query.minSeats) {
queryBuilder = queryBuilder.gte('available_seats', Number(query.minSeats))
}
// Filter by max price
if (query.maxPrice) {
queryBuilder = queryBuilder.lte('price', Number(query.maxPrice))
}
const { data, error } = await queryBuilder.order('departure_time')
if (error) {
throw new Error(error.message)
}
return {
data,
total: data?.length || 0,
filters: {
from: query.from?.toUpperCase() || null,
to: query.to?.toUpperCase() || null,
class: query.class || null
}
}
}, {
query: t.Object({
from: t.Optional(t.String()),
to: t.Optional(t.String()),
class: t.Optional(t.Union([
t.Literal('eksekutif'),
t.Literal('bisnis'),
t.Literal('ekonomi')
])),
minSeats: t.Optional(t.String()),
maxPrice: t.Optional(t.String())
}),
detail: {
tags: ['Schedules'],
summary: 'Search train schedules',
description: 'Cari jadwal kereta. Filter by: from (kode stasiun asal), to (kode stasiun tujuan), class, minSeats, maxPrice'
}
})
// ============================================
// GET /schedules/:id - Detail jadwal
// ============================================
.get('/:id', async ({ params, set }) => {
const { data, error } = await supabase
.from('schedules')
.select(`
id,
train_name,
train_number,
departure_time,
arrival_time,
price,
available_seats,
class,
created_at,
origin:stations!origin_id(id, code, name, city),
destination:stations!destination_id(id, code, name, city)
`)
.eq('id', params.id)
.single()
if (error || !data) {
set.status = 404
return {
error: 'NOT_FOUND',
message: 'Jadwal tidak ditemukan'
}
}
// Calculate duration
const [depHour, depMin] = data.departure_time.split(':').map(Number)
const [arrHour, arrMin] = data.arrival_time.split(':').map(Number)
let durationMinutes = (arrHour * 60 + arrMin) - (depHour * 60 + depMin)
if (durationMinutes < 0) durationMinutes += 24 * 60 // Handle overnight
const durationHours = Math.floor(durationMinutes / 60)
const durationMins = durationMinutes % 60
return {
data: {
...data,
duration: `${durationHours}j ${durationMins}m`,
duration_minutes: durationMinutes
}
}
}, {
params: t.Object({
id: t.String({ format: 'uuid' })
}),
detail: {
tags: ['Schedules'],
summary: 'Get schedule by ID',
description: 'Detail jadwal kereta dengan informasi durasi perjalanan'
}
})
Endpoints Summary
| Method | Endpoint | Description | Filters |
|---|---|---|---|
| GET | /stations | List stasiun | search |
| GET | /stations/:code | Detail stasiun | - |
| GET | /schedules | Search jadwal | from, to, class, minSeats, maxPrice |
| GET | /schedules/:id | Detail jadwal | - |
Test dengan cURL
# List semua stasiun
curl <http://localhost:3000/stations> | jq
# Search stasiun
curl "<http://localhost:3000/stations?search=jakarta>" | jq
# Detail stasiun
curl <http://localhost:3000/stations/GMR> | jq
# Search jadwal Jakarta-Bandung
curl "<http://localhost:3000/schedules?from=GMR&to=BD>" | jq
# Search jadwal kelas eksekutif
curl "<http://localhost:3000/schedules?class=eksekutif>" | jq
# Search dengan multiple filter
curl "<http://localhost:3000/schedules?from=GMR&class=bisnis&maxPrice=400000>" | jq
# Detail jadwal (ganti dengan ID yang valid)
curl <http://localhost:3000/schedules/uuid-jadwal-disini> | jq
Response Examples
GET /stations:
{
"data": [
{ "id": "uuid", "code": "BD", "name": "Bandung", "city": "Bandung" },
{ "id": "uuid", "code": "GMR", "name": "Gambir", "city": "Jakarta" }
],
"total": 8
}
GET /schedules?from=GMR&to=BD:
{
"data": [
{
"id": "uuid",
"train_name": "Argo Parahyangan",
"train_number": "77A",
"departure_time": "06:00:00",
"arrival_time": "09:00:00",
"price": 150000,
"available_seats": 50,
"class": "eksekutif",
"origin": { "code": "GMR", "name": "Gambir", "city": "Jakarta" },
"destination": { "code": "BD", "name": "Bandung", "city": "Bandung" }
}
],
"total": 3,
"filters": { "from": "GMR", "to": "BD", "class": null }
}
💡 Mini Tips: Supabase select() dengan relasi menggunakan syntax table!foreign_key(columns). Ini lebih powerful dari simple JOIN karena hasilnya sudah nested sebagai object, bukan flat columns.
Di bagian selanjutnya, kita implementasi Bookings routes — yang paling complex karena ada business logic validasi seat dan status management.
Bagian 4: Routes — Bookings & Business Logic
Bookings adalah jantung dari sistem tiket kereta. Di sini ada business logic penting: validasi seat availability, generate booking code, kalkulasi harga, dan status management.
Booking Routes Implementation
// src/routes/bookings.ts
import { Elysia, t } from 'elysia'
import { supabase } from '../lib/supabase'
// Generate unique booking code: TKT-XXXXXX
const generateBookingCode = (): string => {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
let code = 'TKT-'
for (let i = 0; i < 6; i++) {
code += chars.charAt(Math.floor(Math.random() * chars.length))
}
return code
}
// Format price to Rupiah
const formatRupiah = (price: number): string => {
return `Rp ${price.toLocaleString('id-ID')}`
}
export const bookingRoutes = new Elysia({ prefix: '/bookings' })
// ============================================
// POST /bookings - Buat booking baru
// ============================================
.post('/', async ({ body, set }) => {
// 1. Get schedule details
const { data: schedule, error: scheduleError } = await supabase
.from('schedules')
.select(`
*,
origin:stations!origin_id(code, name, city),
destination:stations!destination_id(code, name, city)
`)
.eq('id', body.schedule_id)
.single()
if (scheduleError || !schedule) {
set.status = 400
return {
error: 'SCHEDULE_NOT_FOUND',
message: 'Jadwal kereta tidak ditemukan'
}
}
// 2. Check seat availability
if (schedule.available_seats < body.seat_count) {
set.status = 400
return {
error: 'INSUFFICIENT_SEATS',
message: `Kursi tidak cukup. Tersedia: ${schedule.available_seats}, diminta: ${body.seat_count}`,
available_seats: schedule.available_seats
}
}
// 3. Validate NIK (16 digits)
if (!/^\\d{16}$/.test(body.passenger_id_number)) {
set.status = 400
return {
error: 'INVALID_NIK',
message: 'NIK harus 16 digit angka'
}
}
// 4. Calculate total price
const totalPrice = schedule.price * body.seat_count
// 5. Generate unique booking code
let bookingCode = generateBookingCode()
let attempts = 0
// Ensure unique code (retry if exists)
while (attempts < 5) {
const { data: existing } = await supabase
.from('bookings')
.select('id')
.eq('booking_code', bookingCode)
.single()
if (!existing) break
bookingCode = generateBookingCode()
attempts++
}
// 6. Create booking
const { data: booking, error: bookingError } = await supabase
.from('bookings')
.insert({
booking_code: bookingCode,
schedule_id: body.schedule_id,
passenger_name: body.passenger_name.trim(),
passenger_id_number: body.passenger_id_number,
passenger_phone: body.passenger_phone,
seat_count: body.seat_count,
total_price: totalPrice,
status: 'pending'
})
.select()
.single()
if (bookingError) {
set.status = 500
return {
error: 'BOOKING_FAILED',
message: 'Gagal membuat booking. Silakan coba lagi.'
}
}
// 7. Update available seats
const { error: updateError } = await supabase
.from('schedules')
.update({
available_seats: schedule.available_seats - body.seat_count
})
.eq('id', body.schedule_id)
if (updateError) {
// Rollback: delete booking if seat update fails
await supabase.from('bookings').delete().eq('id', booking.id)
set.status = 500
return {
error: 'BOOKING_FAILED',
message: 'Gagal mengupdate kursi. Booking dibatalkan.'
}
}
set.status = 201
return {
message: 'Booking berhasil dibuat',
data: {
booking_code: bookingCode,
status: 'pending',
train: {
name: schedule.train_name,
number: schedule.train_number,
class: schedule.class
},
route: {
origin: schedule.origin,
destination: schedule.destination
},
schedule: {
departure: schedule.departure_time,
arrival: schedule.arrival_time
},
passenger: {
name: body.passenger_name,
id_number: body.passenger_id_number,
phone: body.passenger_phone
},
seats: body.seat_count,
price_per_seat: schedule.price,
total_price: totalPrice,
total_price_formatted: formatRupiah(totalPrice),
created_at: booking.created_at,
note: 'Silakan konfirmasi pembayaran dalam 1 jam'
}
}
}, {
body: t.Object({
schedule_id: t.String({ format: 'uuid' }),
passenger_name: t.String({ minLength: 3, maxLength: 100 }),
passenger_id_number: t.String({ minLength: 16, maxLength: 16 }),
passenger_phone: t.String({ minLength: 10, maxLength: 15 }),
seat_count: t.Number({ minimum: 1, maximum: 4 })
}),
detail: {
tags: ['Bookings'],
summary: 'Create new booking',
description: 'Buat booking baru. Maksimal 4 kursi per booking.'
}
})
// ============================================
// GET /bookings/:code - Get booking by code
// ============================================
.get('/:code', async ({ params, set }) => {
const { data, error } = await supabase
.from('bookings')
.select(`
*,
schedule:schedules(
train_name,
train_number,
departure_time,
arrival_time,
price,
class,
origin:stations!origin_id(code, name, city),
destination:stations!destination_id(code, name, city)
)
`)
.eq('booking_code', params.code.toUpperCase())
.single()
if (error || !data) {
set.status = 404
return {
error: 'NOT_FOUND',
message: `Booking dengan kode ${params.code.toUpperCase()} tidak ditemukan`
}
}
return {
data: {
...data,
total_price_formatted: formatRupiah(data.total_price)
}
}
}, {
params: t.Object({
code: t.String({ minLength: 10, maxLength: 10 })
}),
detail: {
tags: ['Bookings'],
summary: 'Get booking by code',
description: 'Cek status booking dengan kode (contoh: TKT-ABC123)'
}
})
// ============================================
// PATCH /bookings/:code/confirm - Konfirmasi booking
// ============================================
.patch('/:code/confirm', async ({ params, set }) => {
// Check current status
const { data: booking } = await supabase
.from('bookings')
.select('*')
.eq('booking_code', params.code.toUpperCase())
.single()
if (!booking) {
set.status = 404
return {
error: 'NOT_FOUND',
message: 'Booking tidak ditemukan'
}
}
if (booking.status === 'confirmed') {
set.status = 400
return {
error: 'ALREADY_CONFIRMED',
message: 'Booking sudah dikonfirmasi sebelumnya'
}
}
if (booking.status === 'cancelled') {
set.status = 400
return {
error: 'BOOKING_CANCELLED',
message: 'Tidak bisa konfirmasi booking yang sudah dibatalkan'
}
}
// Update status
const { data, error } = await supabase
.from('bookings')
.update({
status: 'confirmed',
updated_at: new Date().toISOString()
})
.eq('booking_code', params.code.toUpperCase())
.select()
.single()
if (error) {
set.status = 500
return {
error: 'UPDATE_FAILED',
message: 'Gagal mengkonfirmasi booking'
}
}
return {
message: 'Booking berhasil dikonfirmasi',
data: {
booking_code: data.booking_code,
status: data.status,
confirmed_at: data.updated_at
}
}
}, {
params: t.Object({ code: t.String() }),
detail: {
tags: ['Bookings'],
summary: 'Confirm booking',
description: 'Konfirmasi booking setelah pembayaran'
}
})
// ============================================
// DELETE /bookings/:code - Batalkan booking
// ============================================
.delete('/:code', async ({ params, set }) => {
// Get booking with schedule
const { data: booking } = await supabase
.from('bookings')
.select('*, schedule:schedules(*)')
.eq('booking_code', params.code.toUpperCase())
.single()
if (!booking) {
set.status = 404
return {
error: 'NOT_FOUND',
message: 'Booking tidak ditemukan'
}
}
if (booking.status === 'cancelled') {
set.status = 400
return {
error: 'ALREADY_CANCELLED',
message: 'Booking sudah dibatalkan sebelumnya'
}
}
// Update status to cancelled
const { error: updateError } = await supabase
.from('bookings')
.update({
status: 'cancelled',
updated_at: new Date().toISOString()
})
.eq('booking_code', params.code.toUpperCase())
if (updateError) {
set.status = 500
return { error: 'CANCEL_FAILED', message: 'Gagal membatalkan booking' }
}
// Restore seats (only if was not cancelled before)
if (booking.status !== 'cancelled') {
await supabase
.from('schedules')
.update({
available_seats: booking.schedule.available_seats + booking.seat_count
})
.eq('id', booking.schedule_id)
}
return {
message: 'Booking berhasil dibatalkan',
data: {
booking_code: booking.booking_code,
status: 'cancelled',
seats_restored: booking.seat_count
}
}
}, {
params: t.Object({ code: t.String() }),
detail: {
tags: ['Bookings'],
summary: 'Cancel booking',
description: 'Batalkan booking. Kursi akan dikembalikan ke jadwal.'
}
})
Booking Status Flow
┌─────────────────────────────────────────────────────────────┐
│ │
│ POST /bookings │
│ │ │
│ ▼ │
│ ┌─────────┐ PATCH /confirm ┌───────────┐ │
│ │ pending │ ──────────────────▶ │ confirmed │ │
│ └─────────┘ └───────────┘ │
│ │ │
│ │ DELETE /:code │
│ ▼ │
│ ┌───────────┐ │
│ │ cancelled │ ◀── (seats restored) │
│ └───────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
Business Logic Summary
| Validation | Rule |
|---|---|
| Schedule exists | Jadwal harus ada di database |
| Seat availability | Kursi tersedia >= kursi diminta |
| NIK format | Harus 16 digit angka |
| Seat limit | Maksimal 4 kursi per booking |
| Unique booking code | Generate ulang jika sudah ada |
| Status transition | pending → confirmed / cancelled |
| Seat restoration | Kembalikan kursi saat cancel |
Test dengan cURL
# 1. Cari jadwal dulu
curl "<http://localhost:3000/schedules?from=GMR&to=BD>" | jq
# Catat schedule ID
# 2. Buat booking
curl -X POST <http://localhost:3000/bookings> \\
-H "Content-Type: application/json" \\
-d '{
"schedule_id": "uuid-schedule-disini",
"passenger_name": "Budi Santoso",
"passenger_id_number": "3201234567890001",
"passenger_phone": "081234567890",
"seat_count": 2
}' | jq
# Catat booking_code dari response
# 3. Cek booking
curl <http://localhost:3000/bookings/TKT-XXXXXX> | jq
# 4. Konfirmasi booking
curl -X PATCH <http://localhost:3000/bookings/TKT-XXXXXX/confirm> | jq
# 5. Atau batalkan booking
curl -X DELETE <http://localhost:3000/bookings/TKT-XXXXXX> | jq
# 6. Verifikasi seat dikembalikan
curl "<http://localhost:3000/schedules?from=GMR&to=BD>" | jq
Response Example
POST /bookings:
{
"message": "Booking berhasil dibuat",
"data": {
"booking_code": "TKT-A3B7X9",
"status": "pending",
"train": {
"name": "Argo Parahyangan",
"number": "77A",
"class": "eksekutif"
},
"route": {
"origin": { "code": "GMR", "name": "Gambir", "city": "Jakarta" },
"destination": { "code": "BD", "name": "Bandung", "city": "Bandung" }
},
"seats": 2,
"price_per_seat": 150000,
"total_price": 300000,
"total_price_formatted": "Rp 300.000",
"note": "Silakan konfirmasi pembayaran dalam 1 jam"
}
}
💡 Mini Tips: Untuk production, gunakan Supabase RPC (stored procedure) atau database transaction untuk operasi atomic. Contoh: create booking + update seats harus dalam satu transaction untuk mencegah race condition saat traffic tinggi.
Di bagian selanjutnya, kita akan mengenal Docker dan cara instalasi di berbagai sistem operasi.
Bagian 5: Mengenal Docker & Instalasi
Sebelum kita containerize API, mari pahami dulu apa itu Docker dan cara instalasinya di berbagai sistem operasi.
Apa itu Docker?
Docker adalah platform untuk membuat, menjalankan, dan mendistribusikan aplikasi dalam container. Bayangkan container seperti "kotak" yang berisi aplikasi beserta semua dependencies-nya.
Masalah yang Docker Selesaikan:
Developer: "Works on my machine!"
Ops: "But it doesn't work on the server..."
Dengan Docker, environment di laptop kamu = environment di server. Tidak ada lagi masalah "versi Node berbeda", "library tidak terinstall", atau "konfigurasi OS berbeda".
Docker vs Virtual Machine
| Aspect | Docker Container | Virtual Machine |
|---|---|---|
| Size | Megabytes (ringan) | Gigabytes (berat) |
| Startup | Detik | Menit |
| OS | Share kernel dengan host | Full OS sendiri |
| Resource | Sangat efisien | Resource-heavy |
| Isolation | Process-level | Hardware-level |
| Portability | Sangat portable | Kurang portable |
Container jauh lebih ringan karena tidak perlu boot full OS — mereka share kernel dengan host machine.
Konsep Dasar Docker
Image Template read-only yang berisi OS, aplikasi, dan dependencies. Seperti "snapshot" atau "blueprint".
docker images # List semua images
Container Instance yang berjalan dari sebuah image. Satu image bisa jadi banyak container.
docker ps # List running containers
docker ps -a # List semua containers
Dockerfile Script yang berisi instruksi untuk membuat image.
FROM oven/bun:1
COPY . .
CMD ["bun", "start"]
Registry Tempat menyimpan dan berbagi images. Docker Hub adalah registry publik terbesar.
docker pull oven/bun # Download image dari registry
Volume Persistent storage untuk data yang perlu disimpan di luar container.
docker volume create mydata
Install Docker di Windows
Prasyarat:
- Windows 10/11 64-bit (Pro, Enterprise, atau Education untuk Hyper-V)
- Windows 10 Home bisa pakai WSL 2 backend
- Minimal 4GB RAM
- Virtualization enabled di BIOS
Step 1: Enable WSL 2
Buka PowerShell sebagai Administrator:
# Install WSL
wsl --install
# Set WSL 2 sebagai default
wsl --set-default-version 2
# Restart komputer
Step 2: Download Docker Desktop
Buka https://www.docker.com/products/docker-desktop/ dan download installer untuk Windows.
Step 3: Install Docker Desktop
- Double-click installer yang sudah didownload
- Pastikan "Use WSL 2 instead of Hyper-V" tercentang
- Ikuti wizard sampai selesai
- Restart komputer jika diminta
Step 4: Verify Installation
Buka PowerShell atau Command Prompt:
# Check version
docker --version
# Output: Docker version 24.x.x, build xxxxx
# Test dengan hello-world
docker run hello-world
Jika muncul pesan "Hello from Docker!", instalasi berhasil!
Install Docker di macOS
Option 1: Docker Desktop (Recommended)
- Buka https://www.docker.com/products/docker-desktop/
- Download untuk Mac (pilih Apple Silicon atau Intel sesuai Mac kamu)
- Double-click file .dmg
- Drag Docker ke folder Applications
- Buka Docker dari Applications
- Ikuti setup wizard
Option 2: Via Homebrew
# Install Docker Desktop via Homebrew
brew install --cask docker
# Buka Docker Desktop
open /Applications/Docker.app
Verify Installation:
docker --version
docker run hello-world
Install Docker di Linux (Ubuntu/Debian)
Step 1: Update & Install Prerequisites
# Update package index
sudo apt update
# Install prerequisites
sudo apt install -y \\
apt-transport-https \\
ca-certificates \\
curl \\
gnupg \\
lsb-release
Step 2: Add Docker Repository
# Add Docker's official GPG key
curl -fsSL <https://download.docker.com/linux/ubuntu/gpg> | sudo gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg
# Add repository
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] <https://download.docker.com/linux/ubuntu> $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
Step 3: Install Docker Engine
# Update package index (again, with new repo)
sudo apt update
# Install Docker
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
Step 4: Post-Installation (Run Docker tanpa sudo)
# Add user ke docker group
sudo usermod -aG docker $USER
# Logout dan login kembali, atau jalankan:
newgrp docker
Step 5: Verify Installation
docker --version
docker run hello-world
Docker Commands Cheat Sheet
# === IMAGES ===
docker images # List images
docker pull <image> # Download image
docker rmi <image> # Remove image
docker build -t <name> . # Build image dari Dockerfile
# === CONTAINERS ===
docker ps # List running containers
docker ps -a # List semua containers
docker run -d -p 3000:3000 <image> # Run container
docker stop <container> # Stop container
docker start <container> # Start container
docker rm <container> # Remove container
docker logs <container> # View logs
docker logs -f <container> # Follow logs (realtime)
# === EXEC ===
docker exec -it <container> /bin/sh # Masuk ke container
# === CLEANUP ===
docker system prune # Remove unused data
docker container prune # Remove stopped containers
docker image prune # Remove unused images
Troubleshooting
| Problem | Solution |
|---|---|
| "permission denied" | Run sudo usermod -aG docker $USER lalu logout/login |
| "Cannot connect to Docker daemon" | Start Docker Desktop atau sudo systemctl start docker |
| "port already in use" | Stop service yang pakai port atau ganti port mapping |
| "no space left on device" | Run docker system prune untuk cleanup |
💡 Mini Tips: Di Windows, pastikan WSL 2 sudah terinstall sebelum Docker Desktop. Docker Desktop menggunakan WSL 2 sebagai backend untuk performa optimal. Tanpa WSL 2, kamu harus pakai Hyper-V yang lebih berat.
Di bagian selanjutnya, kita akan buat Dockerfile dan docker-compose untuk API kita.
Bagian 6: Dockerfile & Docker Compose untuk ElysiaJS
Sekarang kita akan containerize API tiket kereta dengan Docker. Kita akan buat Dockerfile dan docker-compose untuk development dan production.
Memahami Dockerfile
Dockerfile adalah script yang berisi instruksi untuk membuat Docker image. Setiap instruksi membuat satu "layer".
FROM oven/bun:1 # Base image
WORKDIR /app # Set working directory
COPY . . # Copy files ke container
RUN bun install # Execute command (build time)
CMD ["bun", "start"] # Default command (run time)
EXPOSE 3000 # Document port yang digunakan
Instruksi Dockerfile yang Sering Dipakai:
| Instruction | Purpose |
|---|---|
| FROM | Base image |
| WORKDIR | Set working directory |
| COPY | Copy files dari host ke container |
| RUN | Execute command saat build |
| CMD | Default command saat container run |
| ENV | Set environment variable |
| EXPOSE | Document port (tidak publish) |
| ARG | Build-time variable |
Dockerfile Simple untuk ElysiaJS
# Dockerfile
FROM oven/bun:1
WORKDIR /app
# Copy package files dulu (layer caching)
COPY package.json bun.lockb ./
# Install dependencies
RUN bun install --frozen-lockfile --production
# Copy source code
COPY src ./src
COPY tsconfig.json ./
# Environment
ENV NODE_ENV=production
# Expose port
EXPOSE 3000
# Start server
CMD ["bun", "src/index.ts"]
Kenapa COPY package.json dulu?
Docker caching bekerja per-layer. Jika package.json tidak berubah, Docker akan pakai cache untuk layer bun install. Ini mempercepat build karena tidak perlu reinstall dependencies setiap kali code berubah.
Dockerfile Multi-Stage (Production)
Multi-stage build menghasilkan image yang lebih kecil dengan memisahkan build dan runtime.
# Dockerfile
# ================================
# Stage 1: Dependencies
# ================================
FROM oven/bun:1 AS deps
WORKDIR /app
COPY package.json bun.lockb ./
RUN bun install --frozen-lockfile --production
# ================================
# Stage 2: Builder (optional)
# ================================
FROM oven/bun:1 AS builder
WORKDIR /app
# Copy dependencies dari stage sebelumnya
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Jika perlu build/compile TypeScript
# RUN bun run build
# ================================
# Stage 3: Runner (Final Image)
# ================================
FROM oven/bun:1-slim AS runner
WORKDIR /app
# Copy hanya yang dibutuhkan
COPY --from=deps /app/node_modules ./node_modules
COPY --from=builder /app/src ./src
COPY --from=builder /app/tsconfig.json ./
# Environment
ENV NODE_ENV=production
# Security: run sebagai non-root user
USER bun
# Expose port
EXPOSE 3000
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \\
CMD curl -f <http://localhost:3000/health> || exit 1
# Start server
CMD ["bun", "src/index.ts"]
Keuntungan Multi-Stage:
- Image size lebih kecil (hanya final stage yang di-ship)
- Build tools tidak masuk production image
- Lebih secure (less attack surface)
- Faster deployment
docker-compose.yml
Docker Compose memudahkan running multiple containers atau container dengan konfigurasi kompleks.
# docker-compose.yml
version: '3.8'
services:
api:
build:
context: .
dockerfile: Dockerfile
container_name: tiket-kereta-api
ports:
- "3000:3000"
environment:
- NODE_ENV=production
env_file:
- .env
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "<http://localhost:3000/health>"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
# Optional: resource limits
deploy:
resources:
limits:
cpus: '1'
memory: 512M
reservations:
cpus: '0.25'
memory: 128M
docker-compose untuk Development
# docker-compose.dev.yml
version: '3.8'
services:
api:
image: oven/bun:1
container_name: tiket-kereta-dev
working_dir: /app
ports:
- "3000:3000"
volumes:
# Mount source code (hot reload)
- ./src:/app/src
- ./package.json:/app/package.json
- ./bun.lockb:/app/bun.lockb
- ./tsconfig.json:/app/tsconfig.json
env_file:
- .env
command: sh -c "bun install && bun run --watch src/index.ts"
Keuntungan dev compose:
- Hot reload dengan
-watch - Source code di-mount (tidak perlu rebuild)
- Perubahan langsung terlihat
.dockerignore
File ini mencegah file yang tidak perlu masuk ke image.
# .dockerignore
node_modules
.git
.gitignore
# Environment files
.env.local
.env.development
.env.*.local
# Logs
*.log
npm-debug.log*
# Docker files
Dockerfile*
docker-compose*
.dockerignore
# IDE
.vscode
.idea
# Test & coverage
coverage
*.test.ts
# Documentation
*.md
LICENSE
# OS files
.DS_Store
Thumbs.db
Main Entry Point dengan Health Check
Update src/index.ts untuk menambah health check endpoint:
// src/index.ts
import { Elysia } from 'elysia'
import { cors } from '@elysiajs/cors'
import { swagger } from '@elysiajs/swagger'
import { stationRoutes } from './routes/stations'
import { scheduleRoutes } from './routes/schedules'
import { bookingRoutes } from './routes/bookings'
const app = new Elysia()
.use(cors())
.use(swagger({
path: '/docs',
documentation: {
info: {
title: 'Tiket Kereta API',
version: '1.0.0',
description: 'REST API untuk booking tiket kereta - MVP'
},
tags: [
{ name: 'Health', description: 'Health check' },
{ name: 'Stations', description: 'Data stasiun' },
{ name: 'Schedules', description: 'Jadwal kereta' },
{ name: 'Bookings', description: 'Pemesanan tiket' }
]
}
}))
// Health check endpoint (untuk Docker healthcheck)
.get('/health', () => ({
status: 'ok',
timestamp: new Date().toISOString(),
uptime: process.uptime()
}), {
detail: { tags: ['Health'], summary: 'Health check' }
})
// Mount routes
.use(stationRoutes)
.use(scheduleRoutes)
.use(bookingRoutes)
// Global error handler
.onError(({ code, error, set }) => {
console.error(`[${new Date().toISOString()}] ${code}:`, error.message)
if (code === 'VALIDATION') {
set.status = 400
return { error: 'VALIDATION_ERROR', message: error.message }
}
if (code === 'NOT_FOUND') {
set.status = 404
return { error: 'NOT_FOUND', message: 'Endpoint tidak ditemukan' }
}
set.status = 500
return { error: 'SERVER_ERROR', message: 'Terjadi kesalahan server' }
})
.listen(3000)
console.log('🚂 Tiket Kereta API')
console.log(`📍 Server: http://localhost:3000`)
console.log(`📚 Docs: <http://localhost:3000/docs`>)
console.log(`💚 Health: <http://localhost:3000/health`>)
Build & Run Commands
# === BUILD ===
# Build image
docker build -t tiket-kereta-api .
# Build dengan tag version
docker build -t tiket-kereta-api:1.0.0 .
# === RUN (tanpa compose) ===
docker run -d \\
--name tiket-kereta \\
-p 3000:3000 \\
--env-file .env \\
tiket-kereta-api
# === RUN (dengan compose) ===
# Production
docker-compose up -d
# Development (dengan hot reload)
docker-compose -f docker-compose.dev.yml up
# === LOGS ===
docker-compose logs -f
# === STOP ===
docker-compose down
# === REBUILD ===
docker-compose up -d --build
# === CLEANUP ===
docker-compose down --rmi all --volumes
Verify Container
# Check running containers
docker ps
# Check health
curl <http://localhost:3000/health>
# Check logs
docker logs -f tiket-kereta-api
# Masuk ke container
docker exec -it tiket-kereta-api /bin/sh
Common Issues & Solutions
| Issue | Solution |
|---|---|
| "port already in use" | Ganti port di compose: "3001:3000" |
| "env file not found" | Pastikan .env ada di root folder |
| ".env not loaded" | Check path di env_file compose |
| "image too large" | Gunakan multi-stage build + .dockerignore |
| "permission denied" | Add USER bun di Dockerfile |
| "health check failing" | Pastikan /health endpoint exists |
💡 Mini Tips: Selalu gunakan --frozen-lockfile saat install di Docker. Ini memastikan exact same dependencies seperti di development, mencegah "works on my machine" problems.
Di bagian terakhir, kita akan load test API dengan Bombardier untuk memastikan performa siap production.
Bagian 7: Load Testing dengan Bombardier & Penutup
Sebelum deploy ke production, kita perlu tahu seberapa kuat API kita. Load testing membantu menemukan bottleneck dan memastikan API bisa handle traffic yang diharapkan.
Apa itu Bombardier?
Bombardier adalah HTTP load testing tool yang ditulis dalam Go. Ringan, cepat, dan mudah digunakan.
Kenapa Bombardier?
- Sangat cepat (ditulis dalam Go)
- Simple CLI interface
- Output yang informatif
- Cross-platform
Install Bombardier
macOS:
brew install bombardier
Linux:
# Via Go
go install github.com/codesenberg/bombardier@latest
# Atau download binary
wget <https://github.com/codesenberg/bombardier/releases/download/v1.2.6/bombardier-linux-amd64>
chmod +x bombardier-linux-amd64
sudo mv bombardier-linux-amd64 /usr/local/bin/bombardier
Windows:
# Download dari GitHub releases
# <https://github.com/codesenberg/bombardier/releases>
# Atau via Scoop
scoop install bombardier
Verify:
bombardier --version
Bombardier Options
bombardier [options] <url>
# Common options:
-c, --connections # Jumlah concurrent connections (default: 125)
-d, --duration # Duration of test (contoh: 10s, 1m)
-n, --requests # Total number of requests
-m, --method # HTTP method (GET, POST, etc)
-H, --header # HTTP header
-b, --body # Request body
-f, --body-file # Request body from file
-t, --timeout # Request timeout (default: 2s)
-l, --latencies # Print latency statistics
Test Scenarios
Pastikan container sudah running:
docker-compose up -d
Test 1: Health Check (Baseline)
bombardier -c 100 -d 10s <http://localhost:3000/health>
Expected output:
Statistics Avg Stdev Max
Reqs/sec 45231.12 2341.56 52341.89
Latency 2.21ms 1.23ms 45.67ms
HTTP codes:
1xx - 0, 2xx - 452311, 3xx - 0, 4xx - 0, 5xx - 0
Throughput: 12.34MB/s
Test 2: GET Stations (Read, Simple Query)
bombardier -c 100 -d 10s <http://localhost:3000/stations>
Test 3: GET Schedules dengan Filter (Read, JOIN Query)
bombardier -c 100 -d 10s "<http://localhost:3000/schedules?from=GMR&to=BD>"
Test 4: GET Schedule by ID (Read, Single Record)
# Get schedule ID dulu
SCHEDULE_ID=$(curl -s <http://localhost:3000/schedules> | jq -r '.data[0].id')
bombardier -c 100 -d 10s "<http://localhost:3000/schedules/$SCHEDULE_ID>"
Test 5: POST Booking (Write Operation)
# Get schedule ID
SCHEDULE_ID=$(curl -s <http://localhost:3000/schedules> | jq -r '.data[0].id')
# Test POST (gunakan -n untuk limit requests, bukan -d)
bombardier -c 20 -n 100 -m POST \\
-H "Content-Type: application/json" \\
-b "{
\\"schedule_id\\": \\"$SCHEDULE_ID\\",
\\"passenger_name\\": \\"Test User\\",
\\"passenger_id_number\\": \\"1234567890123456\\",
\\"passenger_phone\\": \\"081234567890\\",
\\"seat_count\\": 1
}" \\
<http://localhost:3000/bookings>
Expected Performance
| Endpoint | Type | Connections | Expected RPS | Latency (p99) |
|---|---|---|---|---|
| /health | Read | 100 | 40,000+ | <10ms |
| /stations | Read | 100 | 8,000+ | <30ms |
| /schedules | Read+JOIN | 100 | 3,000+ | <50ms |
| /schedules/:id | Read | 100 | 5,000+ | <40ms |
| POST /bookings | Write | 20 | 500+ | <100ms |
Catatan:
- Angka di atas untuk local testing
- Production akan berbeda tergantung network latency ke Supabase
- Write operations lebih lambat karena ada multiple queries
Analyzing Results
Statistics Avg Stdev Max
Reqs/sec 12543.21 1234.56 15678.90
Latency 7.89ms 2.34ms 45.67ms
HTTP codes:
1xx - 0, 2xx - 125432, 3xx - 0, 4xx - 0, 5xx - 0
Throughput: 8.45MB/s
Key Metrics:
| Metric | Meaning | Target |
|---|---|---|
| Reqs/sec | Throughput (higher is better) | Depends on use case |
| Latency Avg | Average response time | <100ms |
| Latency p99 | 99th percentile latency | <500ms |
| Stdev | Consistency (lower is better) | Low |
| Error rate | 4xx + 5xx responses | 0% |
Optimization Tips
Jika performa tidak sesuai harapan:
1. Add Database Indexes
-- Supabase SQL Editor
CREATE INDEX idx_schedules_origin_dest
ON schedules(origin_id, destination_id);
2. Connection Pooling Supabase sudah handle ini, tapi untuk self-hosted PostgreSQL gunakan PgBouncer.
3. Caching Cache data yang jarang berubah (stations, schedules):
// Simple in-memory cache
const stationCache = new Map()
4. Limit Concurrent Bookings Prevent overselling dengan rate limiting atau queue.
5. Use Read Replicas Untuk high-traffic, pisahkan read dan write operations.
API Endpoints Summary
| Method | Endpoint | Description | Auth |
|---|---|---|---|
| GET | /health | Health check | No |
| GET | /stations | List stations | No |
| GET | /stations/:code | Station detail | No |
| GET | /schedules | Search schedules | No |
| GET | /schedules/:id | Schedule detail | No |
| POST | /bookings | Create booking | No |
| GET | /bookings/:code | Get booking | No |
| PATCH | /bookings/:code/confirm | Confirm booking | No |
| DELETE | /bookings/:code | Cancel booking | No |
Total: 9 endpoints
What We Built
✅ MVP Tiket Kereta dengan fitur essential ✅ 3 database tables dengan proper relations ✅ 9 REST API endpoints ✅ Input validation dengan TypeBox ✅ Business logic (seat management, status flow) ✅ Docker containerization ✅ Health check untuk monitoring ✅ Load tested dengan Bombardier
Next Steps
Untuk membuat API ini production-ready:
Authentication
bun add better-auth
Tambahkan login untuk staff dan customer.
Rate Limiting
import { rateLimit } from 'elysia-rate-limit'
app.use(rateLimit({ max: 100, duration: 60000 }))
Payment Integration Integrate dengan Midtrans atau Xendit untuk pembayaran.
Notification Email atau SMS konfirmasi booking.
Monitoring Setup logging dan alerting dengan tools seperti Grafana/Prometheus.
Penutup
Kamu sekarang punya REST API Tiket Kereta yang:
- Type-safe dengan ElysiaJS dan TypeBox
- Scalable dengan PostgreSQL via Supabase
- Portable dengan Docker containerization
- Tested dengan Bombardier load testing
Project ini bisa langsung kamu gunakan sebagai:
- Portfolio untuk showcase backend skills
- Template untuk project booking/reservation
- Learning untuk eksperimen fitur baru
Resources
Untuk memperdalam skill backend development dengan database dan Docker, explore kelas gratis di BuildWithAngga. Ada track lengkap dari database design, API development, sampai deployment yang akan membantu kamu membangun aplikasi production-ready.
Butuh template UI untuk aplikasi tiket atau booking? Download HTML template gratis di shaynakit.com — tersedia berbagai template travel booking, ticket reservation, dan dashboard yang bisa langsung kamu integrate dengan API ini.
Documentation:
- ElysiaJS: elysiajs.com
- Supabase: supabase.com/docs
- Docker: docs.docker.com
- Bombardier: github.com/codesenberg/bombardier
Happy coding! 🚂🐳
💡 Mini Tips: Load testing adalah skill yang sering diabaikan tapi sangat penting untuk backend developer. Knowing your API limits sebelum production launch bisa mencegah downtime saat traffic spike. Jadikan load testing sebagai bagian dari development workflow, bukan afterthought.