Lewati ke konten

Multi-Service

Deserve menjalankan beberapa server dari satu proses Deno. Setiap Router adalah server mandiri dengan rute, middleware, file watcher, dan port miliknya sendiri. Masing-masing terisolasi di tingkat request, jadi kesalahan di satu tidak pernah merembet ke yang lain, namun keduanya berbagi memori proses yang sama, yang membuat mereka bisa berbagi kode, state, dan infrastruktur tanpa overhead jaringan.

Secara tradisional, menjalankan 5 service berarti 5 proses, 5 deployment, dan 5 salinan kode bersama. Dengan Deserve, satu main.ts menjalankan sebanyak router yang muat di memori, dan masing-masing listen di port sendiri serta memantau direktorinya sendiri, sementara kesalahan di satu tetap terkurung alih-alih menjatuhkan sisanya.

Satu proses Deno menjalankan router API, Auth, dan Web, masing-masing di port sendiri dengan satu klien terhubung ke tiap port

Setup Dasar

Satu Router per service, satu port per router, satu Promise.all untuk menjalankan semuanya:

typescript
import { 
Router
} from '@neabyte/deserve'
// Satu Router per service const
api
= new
Router
({
routes
: {
directory
: './services/api/routes' }
}) const
auth
= new
Router
({
routes
: {
directory
: './services/auth/routes' }
}) const
web
= new
Router
({
routes
: {
directory
: './services/web/routes' },
views
: {
directory
: './services/web/views' }
}) // Jalankan setiap service bersama await
Promise
.
all
([
api
.
serve
(3001),
auth
.
serve
(3002),
web
.
serve
(3003)
])

Itu seluruh entry point-nya.

Isolasi Router

Setiap Router berjalan dalam isolasi tingkat request. Masing-masing punya radix-tree router, middleware stack, file watcher, dan template engine opsional sendiri. Mereka tidak berbagi state internal kecuali dihubungkan secara eksplisit, sementara proses di bawahnya tetap dibagi, dan itulah yang membuat berbagi kode dan state di bawah ini bisa dilakukan.

Kesalahan terkurung di dua tingkat. Sebuah throw di dalam satu handler menjadi error response untuk satu request itu, jadi sisa service itu dan setiap service lain tetap melayani. Kesalahan yang lebih dalam yang lolos dari handler, seperti unhandled rejection atau upaya keluar dari proses, dijebak di seluruh proses oleh proteksi proses dan dimunculkan sebagai event alih-alih shutdown, jadi tidak ada service yang mati.

Setiap router punya tabel rute, middleware, dan watcher sendiri secara terisolasi, dengan router Web juga memegang DVE engine

Jika sebuah rute di API melempar, hanya request itu yang mendapat 500. Auth dan Web, serta setiap request API lain, tetap melayani normal.

Struktur Direktori

Setiap service mengikuti konvensi folder yang sama. Anggota tim baru melihat layout ini dan langsung tahu di mana rute, view, dan kode bersama berada. Tanpa menebak, tanpa konvensi khusus proyek untuk dipelajari.

project/
├── main.ts
├── shared/
│   ├── utils.ts
│   ├── sessions.ts
│   ├── bus.ts
│   ├── cache.ts
│   ├── logger.ts
│   └── errors.ts
└── services/
    ├── api/
    │   └── routes/
    │       ├── health.ts          # GET  :3001/health
    │       ├── me.ts              # GET  :3001/me
    │       └── users/
    │           ├── index.ts       # GET  :3001/users
    │           └── [id].ts        # GET  :3001/users/:id
    ├── auth/
    │   └── routes/
    │       ├── login.ts           # POST :3002/login
    │       ├── logout.ts          # POST :3002/logout
    │       └── verify.ts          # GET  :3002/verify
    └── web/
        ├── routes/
        │   └── index.ts           # GET  :3003/
        └── views/
            └── home.dve
  • Rute disimpan di services/<nama>/routes/
  • Kode bersama disimpan di shared/
  • main.ts menghubungkan semuanya

Berbagi Kode dan State

Berbagi satu proses adalah tempat model multi-service menunjukkan nilainya. Alih-alih Redis, HTTP call, atau message broker, service berbagi state lewat object biasa di memori secepat pemanggilan fungsi.

Service mengimpor modul shared dan berkomunikasi lewat session store, event bus, dan cache dalam proses

Modul Bersama

Fungsi utilitas, koneksi database, konfigurasi, skema validasi - tulis sekali di shared/, impor dari service mana pun:

typescript
// shared/utils.ts
// Helper dan konstanta bersama
export function 
formatDate
(
date
: Date): string {
return
date
.
toISOString
().
split
('T')[0]!
} export const
APP_NAME
= 'MyApp'
typescript
// services/api/routes/index.ts
// Impor kode bersama, tanpa lompatan HTTP
import type { Context } from '@neabyte/deserve'
import { APP_NAME } from '../../../shared/utils.ts'

// Pakai konstanta bersama di sini
export function GET(ctx: Context): Response {
  return ctx.send.json({
    app: APP_NAME,
    service: 'api'
  })
}

Session Store

Satu Map berfungsi sebagai session store untuk semua service. Auth menulis session saat login, API membacanya untuk autentikasi request. Tanpa Redis, tanpa HTTP call antar service:

typescript
// shared/sessions.ts
// Store in-memory yang dibagi antar service
export const 
sessions
= new
Map
<string,
Record
<string, unknown>>()
typescript
// services/auth/routes/login.ts
import type { Context } from '@neabyte/deserve'
import { sessions } from '../../../shared/sessions.ts'

// Auth menyimpan session saat login
export async function POST(ctx: Context): Promise<Response> {
  // Baca body JSON dari request
  const body = (await ctx.get.json()) as { username?: string }
  const id = crypto.randomUUID()
  sessions.set(id, {
    username: body?.username,
    loggedInAt: Date.now()
  })
  return ctx.send.json({
    sessionId: id
  })
}
typescript
// services/api/routes/me.ts
import type { Context } from '@neabyte/deserve'
import { sessions } from '../../../shared/sessions.ts'

// API membaca store yang sama langsung
export function GET(ctx: Context): Response {
  // Baca session ID dari header
  const id = ctx.get.header('x-session-id')
  const session = id ? sessions.get(id) : undefined
  if (!session) {
    return ctx.send.json(
      { error: 'Not authenticated' },
      { status: 401 }
    )
  }
  return ctx.send.json({
    user: session
  })
}

Event Bus

Ketika API membuat user, Auth dan Web bisa mengetahuinya seketika, tanpa message queue dan tanpa polling, hanya pemanggilan fungsi langsung antar service. Bus ini membawa fakta aplikasi seperti user:created. Untuk aktivitas framework seperti request, rute, dan kesalahan, pakai observability events bawaan sebagai gantinya.

Service API memancarkan event ke EventBus, yang memberi tahu service Auth dan Web

typescript
// shared/bus.ts
// Event bus in-process minimal
type 
Listener
= (...
args
: unknown[]) => void
const
listeners
= new
Map
<string,
Set
<
Listener
>>()
export function
emit
(
event
: string, ...
args
: unknown[]): void {
for (const
fn
of
listeners
.
get
(
event
) ?? []) {
fn
(...
args
)
} } export function
on
(
event
: string,
fn
:
Listener
): void {
if (!
listeners
.
has
(
event
)) {
listeners
.
set
(
event
, new
Set
())
}
listeners
.
get
(
event
)!.
add
(
fn
)
}
typescript
// services/api/routes/users/index.ts
import type { Context } from '@neabyte/deserve'
import { emit } from '../../../../shared/bus.ts'

// Pancarkan event setelah membuat user
export async function POST(ctx: Context): Promise<Response> {
  // Baca body JSON dari request
  const user = await ctx.get.json()
  emit('user:created', user)
  return ctx.send.json({
    created: true
  })
}

Service mana pun bisa listen dengan on('user:created', ...) di main.ts atau di dalam rutenya sendiri.

Cache

Map bersama dengan TTL menghilangkan pekerjaan duplikat. API menghitung dan menyimpan cache, Web membaca hasil yang di-cache. Tanpa biaya jaringan:

typescript
// shared/cache.ts
// Cache bersama dengan kedaluwarsa per entri
const 
store
= new
Map
<string, {
value
: unknown;
expires
: number }>()
export function
get
<
T
>(
key
: string):
T
| undefined {
const
entry
=
store
.
get
(
key
)
if (!
entry
||
entry
.
expires
<
Date
.
now
()) {
store
.
delete
(
key
)
return
undefined
} return
entry
.
value
as
T
} export function
set
(
key
: string,
value
: unknown,
ttlMs
: number): void {
store
.
set
(
key
, {
value
,
expires
:
Date
.
now
() +
ttlMs
}) }

HTTP Antar Service

Ketika satu service perlu memanggil endpoint HTTP service lain (bukan hanya kode bersama), pakai fetch. Kedua service berada di proses yang sama, jadi panggilan tetap di localhost:

typescript
// services/web/routes/dashboard.ts
import type { 
Context
} from '@neabyte/deserve'
// Panggil service API, lalu render template export async function
GET
(
ctx
:
Context
):
Promise
<Response> {
const
users
= await
fetch
('http://localhost:3001/users').
then
((
r
) =>
r
.
json
())
return await
ctx
.
render
('dashboard.dve', {
users
})
}

Trade-off-nya

Shared state adalah fitur, bukan tanpa konsekuensi. Isolasi router menjaga kesalahan di dalam satu service, namun Map bersama justru kebalikan dari isolasi secara desain, karena setiap service membaca dan menulis object yang sama. Satu service yang menulis data buruk ke store menyerahkan data buruk yang sama ke setiap pembaca lain, jadi keterikatan bergeser dari lapisan jaringan turun ke lapisan data. Kesalahan tetap terkurung, tapi data tidak. Jaga dampak kesalahan tetap kecil dengan membiarkan satu modul memiliki tiap store dan memvalidasi penulisan di tepinya, seperti shared/sessions.ts yang jadi satu-satunya pintu ke session map. Pakai shared state saat kecepatan penting dan service memang seharusnya bersama, dan kembali ke HTTP antar service saat batas yang lebih bersih sepadan dengan biaya jaringannya.

Middleware

Setiap router punya middleware stack sendiri, jadi service dikonfigurasi independen dengan middleware berbeda masing-masing, atau berbagi middleware yang sama di semua. Di sinilah model satu-proses menunjukkan nilainya, karena satu logger, satu error handler, dan satu auth check berlaku di mana pun dibutuhkan. Mekanik mendaftarkan middleware ada di Global Middleware dan Route-specific Middleware, dan bagian ini fokus pada menerapkannya di banyak service.

Konfigurasi Per Service

Satu service bisa punya CORS dan body limit, yang lain bisa punya security headers, dan yang ketiga bisa berjalan tanpa middleware sama sekali:

Setiap service menyusun rantai middleware sendiri sebelum rutenya: API menjalankan CORS lalu BodyLimit, Auth menjalankan SecHeaders, Web menjalankan rute dengan DVE

typescript
import { 
Mware
,
Router
} from '@neabyte/deserve'
// API mendapat CORS dan body limit const
api
= new
Router
({
routes
: {
directory
: './services/api/routes' }
})
api
.
use
(
Mware
.
cors
({
origin
: '*'
}))
api
.
use
(
Mware
.
bodyLimit
({
limit
: 5 * 1024 * 1024
})) // Auth mendapat security headers const
auth
= new
Router
({
routes
: {
directory
: './services/auth/routes' }
})
auth
.
use
(
Mware
.
securityHeaders
({
xFrameOptions
: 'DENY'
})) // Web berjalan tanpa middleware const
web
= new
Router
({
routes
: {
directory
: './services/web/routes' },
views
: {
directory
: './services/web/views' }
}) // Jalankan setiap service bersama await
Promise
.
all
([
api
.
serve
(3001),
auth
.
serve
(3002),
web
.
serve
(3003)
])

Logger Bersama

Tulis satu logger, terapkan ke setiap service. Semua request dari semua port mengalir lewat fungsi yang sama, ditandai dengan nama service. Satu console, satu format, satu tempat untuk dicari saat ada yang salah:

typescript
// shared/logger.ts
// Satu logger dipakai ulang oleh setiap service
import type { 
MiddlewareFn
} from '@neabyte/deserve'
export function
logger
(
service
: string):
MiddlewareFn
{
return async (
ctx
,
next
) => {
const
start
=
Date
.
now
()
const
response
= await
next
()
const
duration
=
Date
.
now
() -
start
const
status
=
response
?.
status
?? 0
// Baca method dan path dari ctx.get
console
.
log
(`[${
service
}] ${
ctx
.
get
.
method
()} ${
ctx
.
get
.
pathname
()} ${
status
} ${
duration
}ms`)
return
response
} }

Output dari semua service dalam satu stream:

[API]  GET  /users     200 3ms
[Auth] POST /login     200 12ms
[Web]  GET  /          200 5ms
[API]  GET  /users/99  404 1ms

Error Handler Bersama

Satu error handler berlaku dengan router.catch(), jadi setiap error yang dilempar, 404, atau 500 di semua service menghasilkan bentuk error yang sama, dan response tetap dapat ditebak terlepas dari service mana yang mengembalikannya:

typescript
// shared/errors.ts
// Satu bentuk error handler untuk semua
import type { 
Context
, ErrorInfo,
ErrorMiddleware
,
HttpStatusCode
} from '@neabyte/deserve'
export function
errorHandler
(
service
: string):
ErrorMiddleware
{
return (
ctx
:
Context
,
info
: ErrorInfo): Response | null => {
console
.
error
(
`[${
service
}] ${
info
.
method
} ${
info
.
pathname
} ${
info
.
statusCode
} - ${
info
.
error
?.
message
}`
) return
ctx
.
send
.
json
(
{
service
,
error
:
info
.
error
?.
message
?? 'Unknown error',
statusCode
:
info
.
statusCode
,
path
:
info
.
pathname
}, {
status
:
info
.
statusCode
as
HttpStatusCode
}
) } }

Membungkus Middleware dengan Label

Wrap.apply menandai middleware individual dengan label, jadi saat middleware itu melempar, log error menyertakan label dan menunjuk langsung middleware mana di service mana yang gagal. Signature dan perilaku dasarnya dibahas di Global Middleware, dan ia bertindak sebagai satu lapisan dalam Defense in Depth:

typescript
// main.ts
import { Router, Wrap } from '@neabyte/deserve'
import { logger } from './shared/logger.ts'
import { errorHandler } from './shared/errors.ts'

// Beri label tiap middleware untuk log error
const apiAuth = Wrap.apply('APIAuth', async (ctx, next) => {
  // Baca header authorization
  if (!ctx.get.header('authorization')) {
    throw new Error('Missing API key')
  }
  return await next()
})

const authRateLimit = Wrap.apply('AuthRateLimit', async (ctx, next) => {
  // logika rate limit
  return await next()
})

const webCache = Wrap.apply('WebCache', async (ctx, next) => {
  // logika cache
  return await next()
})

// Sambungkan logger, middleware, error handler
const api = new Router({
  routes: { directory: './services/api/routes' }
})
api.use(logger('API'))
api.use(apiAuth)
api.catch(errorHandler('API'))

const auth = new Router({
  routes: { directory: './services/auth/routes' }
})
auth.use(logger('Auth'))
auth.use(authRateLimit)
auth.catch(errorHandler('Auth'))

const web = new Router({
  routes: { directory: './services/web/routes' },
  views: { directory: './services/web/views' }
})
web.use(logger('Web'))
web.use(webCache)
web.catch(errorHandler('Web'))

// Jalankan setiap service bersama
await Promise.all([
  api.serve(3001),
  auth.serve(3002),
  web.serve(3003)
])

Ketika apiAuth melempar, log terbaca [API] GET /users 500 - APIAuth - Missing API key. Ketika authRateLimit melempar, terbaca [Auth] POST /login 500 - AuthRateLimit - Too many requests. Nama service, rute, dan label middleware - semua dalam satu baris.

OpenTelemetry

Karena setiap request sudah mengalir lewat middleware bersama, memasang OpenTelemetry mengikuti pola yang sama. Satu middleware OTel berlaku ke setiap service, jadi semua span dari semua port menuju satu collector, yang memberi distributed tracing, dashboard latensi, dan metrik error rate di seluruh sistem tanpa menginstrumentasi tiap service secara terpisah:

Satu middleware OTel mengumpulkan span dari setiap service dan mengekspornya ke OTel Collector, lalu ke Jaeger, Grafana, atau Datadog

typescript
// shared/otel.ts
// Satu middleware OTel untuk semua service
import type { 
MiddlewareFn
} from '@neabyte/deserve'
export function
otelMiddleware
(
service
: string):
MiddlewareFn
{
return async (
ctx
,
next
) => {
const
start
=
performance
.
now
()
const
response
= await
next
()
const
duration
=
performance
.
now
() -
start
const
status
=
response
?.
status
?? 0
// Pancarkan span, ganti dengan OTel SDK
console
.
log
(
JSON
.
stringify
({
traceId
:
crypto
.
randomUUID
(),
service
,
method
:
ctx
.
get
.
method
(),
path
:
ctx
.
get
.
pathname
(),
status
,
durationMs
:
Math
.
round
(
duration
* 100) / 100,
timestamp
: new
Date
().
toISOString
()
})) return
response
} }

Hot Reload

Setiap service punya file watcher sendiri, jadi menyimpan file me-reload hanya service yang memiliki direktori itu sementara service lain tetap melayani request tanpa gangguan. Untuk detail lengkap cara kerja hot reload, lihat Hot Reload.

  • Edit services/api/routes/users/index.ts (hanya :3001 yang reload rute)
  • Tambah services/auth/routes/reset.ts (hanya :3002 yang mendeteksi rute baru)
  • Edit services/web/views/home.dve (hanya :3003 yang membersihkan cache template)

Tim bisa bekerja di service berbeda pada saat bersamaan, dengan satu orang merefaktor rute API, yang lain memperbaiki logika Auth, dan yang ketiga memperbarui template Web, semua tanpa saling mengganggu.

Deployment

Docker

Semua service berjalan di satu container. Satu image, satu proses, semua port:

dockerfile
FROM denoland/deno:2.8.3

WORKDIR /app
COPY . .

RUN deno cache main.ts

EXPOSE 3001 3002 3003
CMD ["deno", "run", "-A", "main.ts"]

Reverse Proxy

Letakkan Nginx atau Caddy di depan untuk mengarahkan domain ke setiap port service:

Reverse proxy seperti Nginx atau Caddy memetakan tiap hostname ke port per-service: api.example.com ke 3001, auth.example.com ke 3002, dan example.com ke 3003

nginx
# Service API
server {
    server_name api.example.com;
    location / { proxy_pass http://127.0.0.1:3001; }
}

# Service Auth
server {
    server_name auth.example.com;
    location / { proxy_pass http://127.0.0.1:3002; }
}

# Service Web
server {
    server_name example.com;
    location / { proxy_pass http://127.0.0.1:3003; }
}

Scaling Out

Ketika sebuah service tumbuh melampaui monolit, service itu diekstrak ke prosesnya sendiri. Salin foldernya, tambahkan main.ts, dan deploy secara independen. File rute tidak berubah, karena API Router sama baik satu service berjalan maupun sepuluh:

Mengekstrak service dari satu proses menjadi proses API, Auth, dan Web yang terpisah

  • Salin services/api/ ke repositori baru
  • Tambahkan main.ts sendiri dengan satu Router
  • Deploy secara independen

Mulai dengan semuanya dalam satu proses, dan pisahkan saat kebutuhannya muncul.

Dirilis di bawah Lisensi MIT.