Handler Development Guide
Handlers are the core of your Gati application - they process HTTP requests and generate responses. This guide covers everything you need to know about creating effective handlers.
Table of Contents
- Handler Anatomy
- Handler Signature
- Request Object
- Response Object
- HTTP Methods
- Path Parameters
- Query Parameters
- Request Body
- Error Handling
- Context Usage
- Best Practices
- Common Patterns
Handler Anatomy
A handler is a function that processes an HTTP request and generates a response. Gati uses file-based routing with explicit exports:
import type { Handler } from '@gati-framework/runtime';
// HTTP method (optional, defaults to GET)
export const METHOD = 'GET';
// Custom route (optional, auto-generated from file path)
export const ROUTE = '/users/:id';
// Handler function (required)
export const handler: Handler = (req, res, gctx, lctx) => {
// Process request
// Generate response
};Basic Structure
// src/handlers/users/[id].ts
export const METHOD = 'GET';
export const handler: Handler = async (req, res, gctx, lctx) => {
// 1. Extract data from request
const userId = req.params.id;
// 2. Use modules from global context
const db = gctx.modules.db;
// 3. Perform business logic
const user = await db.findUser(userId);
// 4. Send response
res.json({ user });
};
// Automatically available at GET /api/users/:idHandler Signature
Every handler follows this signature:
type Handler = (
req: Request, // HTTP request
res: Response, // HTTP response
gctx: GlobalContext, // Global context (shared)
lctx: LocalContext // Local context (request-scoped)
) => void | Promise<void>;Parameters
req - Request Object
- HTTP method, path, headers
- URL parameters and query strings
- Request body
- Raw Node.js IncomingMessage
res - Response Object
- Methods to send responses
- Set status codes and headers
- JSON, text, or raw responses
- Raw Node.js ServerResponse
gctx - Global Context
- Shared modules (database, cache, etc.)
- Application configuration
- Global state
- Lifecycle hooks
lctx - Local Context
- Unique request ID
- Request timestamp
- Request-scoped state
- Cleanup hooks
Return Value
Handlers can be synchronous or asynchronous:
// Synchronous
const syncHandler: Handler = (req, res) => {
res.json({ message: 'Hello' });
};
// Asynchronous
const asyncHandler: Handler = async (req, res, gctx) => {
const data = await gctx.modules['db'].fetch();
res.json({ data });
};Request Object
The request object (req) provides access to all HTTP request data.
Request Properties
interface Request {
method: HttpMethod; // GET, POST, PUT, PATCH, DELETE, etc.
path: string; // Request URL path
query: QueryParams; // Query string parameters
params: PathParams; // Route path parameters
headers: HttpHeaders; // HTTP headers
body: unknown; // Parsed request body
rawBody?: string | Buffer; // Raw (unparsed) body
raw: IncomingMessage; // Original Node.js request
}HTTP Method
const handler: Handler = (req, res) => {
console.log(req.method); // 'GET', 'POST', etc.
if (req.method === 'POST') {
// Handle POST request
}
};Request Path
const handler: Handler = (req, res) => {
console.log(req.path); // '/users/123'
};HTTP Headers
Access request headers:
const handler: Handler = (req, res) => {
const contentType = req.headers['content-type'];
const authHeader = req.headers['authorization'];
const customHeader = req.headers['x-custom-header'];
console.log({ contentType, authHeader, customHeader });
};Common headers:
content-type- Request body formatauthorization- Authentication tokenuser-agent- Client informationaccept- Accepted response formats
Raw Request
Access the underlying Node.js IncomingMessage:
const handler: Handler = (req, res) => {
const rawReq = req.raw;
console.log(rawReq.socket.remoteAddress);
};Response Object
The response object (res) provides methods to send HTTP responses.
Response Methods
interface Response {
status(code: number): Response; // Set status code
header(name: string, value: string): Response; // Set single header
headers(headers: HttpHeaders): Response; // Set multiple headers
json(data: unknown): void; // Send JSON response
text(data: string): void; // Send text response
send(data: string | Buffer): void; // Send raw response
end(): void; // End response without body
isSent(): boolean; // Check if sent
headersSent: boolean; // Check if headers sent
raw: ServerResponse; // Raw Node.js response
}JSON Response
Most common response type:
const handler: Handler = (req, res) => {
res.json({
message: 'Success',
data: { id: 1, name: 'Alice' },
});
};Text Response
Send plain text:
const handler: Handler = (req, res) => {
res.text('Hello, World!');
};Status Code
Set HTTP status codes:
const handler: Handler = (req, res) => {
// Method 1: Chain with response
res.status(201).json({ message: 'Created' });
// Method 2: Set before sending
res.status(404);
res.json({ error: 'Not found' });
};Common status codes:
200- OK (default)201- Created204- No Content400- Bad Request401- Unauthorized403- Forbidden404- Not Found500- Internal Server Error
Custom Headers
Set response headers:
const handler: Handler = (req, res) => {
// Single header
res.header('X-Custom-Header', 'value');
// Multiple headers
res.headers({
'Content-Type': 'application/json',
'Cache-Control': 'no-cache',
'X-Request-ID': '123',
});
res.json({ data: 'response' });
};Raw Response
Send binary data or custom formats:
const handler: Handler = (req, res) => {
const buffer = Buffer.from('binary data');
res.send(buffer);
};Empty Response
Send response with no body:
const handler: Handler = (req, res) => {
res.status(204).end();
};HTTP Methods
Handle different HTTP methods with dedicated handlers:
GET - Retrieve Data
// GET /users
const listUsersHandler: Handler = async (req, res, gctx) => {
const db = gctx.modules['db'];
const users = await db.findAll();
res.json({ users });
};
// GET /users/:id
const getUserHandler: Handler = async (req, res, gctx) => {
const userId = req.params.id;
const db = gctx.modules['db'];
const user = await db.findById(userId);
res.json({ user });
};POST - Create Data
// POST /users
const createUserHandler: Handler = async (req, res, gctx) => {
const { name, email } = req.body as { name: string; email: string };
const db = gctx.modules['db'];
const newUser = await db.create({ name, email });
res.status(201).json({ user: newUser });
};PUT - Update Data (Full)
// PUT /users/:id
const updateUserHandler: Handler = async (req, res, gctx) => {
const userId = req.params.id;
const { name, email } = req.body as { name: string; email: string };
const db = gctx.modules['db'];
const updated = await db.update(userId, { name, email });
res.json({ user: updated });
};PATCH - Update Data (Partial)
// PATCH /users/:id
const patchUserHandler: Handler = async (req, res, gctx) => {
const userId = req.params.id;
const updates = req.body as Partial<{ name: string; email: string }>;
const db = gctx.modules['db'];
const updated = await db.patch(userId, updates);
res.json({ user: updated });
};DELETE - Remove Data
// DELETE /users/:id
const deleteUserHandler: Handler = async (req, res, gctx) => {
const userId = req.params.id;
const db = gctx.modules['db'];
await db.delete(userId);
res.status(204).end();
};Path Parameters
Extract dynamic segments from URL paths:
Basic Path Parameters
// Route: /users/:id
const handler: Handler = (req, res) => {
const userId = req.params.id; // Extract :id
console.log(userId); // '123' from /users/123
};Multiple Parameters
// Route: /users/:userId/posts/:postId
const handler: Handler = (req, res) => {
const { userId, postId } = req.params;
console.log({ userId, postId }); // { userId: '1', postId: '42' }
};Type Safety
Use type assertions for better type safety:
interface UserParams {
id: string;
}
const handler: Handler = (req, res) => {
const { id } = req.params as UserParams;
// 'id' is now typed as string
};Example: User Profile
// GET /users/:id/profile
const getUserProfileHandler: Handler = async (req, res, gctx) => {
const userId = req.params.id;
const db = gctx.modules['db'];
const user = await db.findById(userId);
const posts = await db.findUserPosts(userId);
res.json({
user,
posts,
profileUrl: `/users/${userId}/profile`,
});
};Query Parameters
Access URL query string parameters:
Basic Query Parameters
// URL: /users?name=Alice&active=true
const handler: Handler = (req, res) => {
const name = req.query.name; // 'Alice'
const active = req.query.active; // 'true' (string!)
};Type Conversion
Query parameters are always strings:
const handler: Handler = (req, res) => {
// String to number
const page = parseInt(req.query.page as string) || 1;
const limit = parseInt(req.query.limit as string) || 10;
// String to boolean
const active = req.query.active === 'true';
console.log({ page, limit, active });
};Optional Parameters
Handle missing query parameters:
const handler: Handler = (req, res) => {
const search = req.query.search as string | undefined;
const filter = req.query.filter as string | undefined;
if (search) {
// Use search parameter
}
if (filter) {
// Use filter parameter
}
};Example: Pagination and Filtering
// GET /users?page=2&limit=20&name=alice&active=true
const listUsersHandler: Handler = async (req, res, gctx) => {
// Pagination
const page = parseInt(req.query.page as string) || 1;
const limit = parseInt(req.query.limit as string) || 10;
const offset = (page - 1) * limit;
// Filtering
const nameFilter = req.query.name as string | undefined;
const activeFilter = req.query.active === 'true';
// Build query
const db = gctx.modules['db'];
const users = await db.findUsers({
limit,
offset,
name: nameFilter,
active: activeFilter,
});
res.json({
users,
pagination: {
page,
limit,
total: users.length,
},
});
};Array Query Parameters
// URL: /users?tags=javascript&tags=typescript
const handler: Handler = (req, res) => {
const tags = req.query.tags;
// tags can be string or string[]
const tagsArray = Array.isArray(tags) ? tags : [tags];
console.log(tagsArray); // ['javascript', 'typescript']
};Request Body
Access parsed request body data:
JSON Body
interface CreateUserBody {
name: string;
email: string;
age: number;
}
const handler: Handler = async (req, res, gctx) => {
const body = req.body as CreateUserBody;
const db = gctx.modules['db'];
const user = await db.create(body);
res.status(201).json({ user });
};Validation with Zod
Recommended approach for production:
import { z } from 'zod';
const UserSchema = z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
age: z.number().int().min(0).max(120),
});
const createUserHandler: Handler = async (req, res, gctx) => {
// Validate request body
const result = UserSchema.safeParse(req.body);
if (!result.success) {
throw new HandlerError(
'Invalid request body',
400,
{ errors: result.error.errors }
);
}
// Use validated data
const { name, email, age } = result.data;
const db = gctx.modules['db'];
const user = await db.create({ name, email, age });
res.status(201).json({ user });
};Raw Body
Access unparsed body data:
const handler: Handler = (req, res) => {
const rawBody = req.rawBody; // string | Buffer | undefined
if (rawBody) {
console.log('Raw body:', rawBody);
}
};Error Handling
Gati provides built-in error handling with the HandlerError class:
HandlerError Class
class HandlerError extends Error {
constructor(
message: string, // Error message
statusCode: number = 500, // HTTP status code
context?: Record<string, unknown> // Additional context
)
}Basic Error Handling
import { HandlerError } from 'gati';
const getUserHandler: Handler = async (req, res, gctx) => {
const userId = req.params.id;
const db = gctx.modules['db'];
const user = await db.findById(userId);
if (!user) {
throw new HandlerError('User not found', 404, { userId });
}
res.json({ user });
};Error Response Format
When a HandlerError is thrown:
{
"error": "User not found",
"context": {
"userId": "123"
}
}Status code: 404
Common Error Patterns
Not Found (404)
if (!resource) {
throw new HandlerError('Resource not found', 404);
}Bad Request (400)
if (!isValid(input)) {
throw new HandlerError('Invalid input', 400, { field: 'email' });
}Unauthorized (401)
if (!isAuthenticated) {
throw new HandlerError('Unauthorized', 401);
}Forbidden (403)
if (!hasPermission) {
throw new HandlerError('Forbidden', 403);
}Internal Server Error (500)
try {
await dangerousOperation();
} catch (error) {
throw new HandlerError('Operation failed', 500);
}Try-Catch for External Errors
const handler: Handler = async (req, res, gctx) => {
try {
const db = gctx.modules['db'];
const data = await db.query('SELECT * FROM users');
res.json({ data });
} catch (error) {
// Convert database error to HandlerError
throw new HandlerError(
'Database query failed',
500,
{ originalError: error instanceof Error ? error.message : 'Unknown' }
);
}
};Validation Errors
const createUserHandler: Handler = async (req, res) => {
const { name, email } = req.body as { name?: string; email?: string };
if (!name || !email) {
throw new HandlerError(
'Missing required fields',
400,
{ required: ['name', 'email'] }
);
}
if (!email.includes('@')) {
throw new HandlerError(
'Invalid email format',
400,
{ field: 'email' }
);
}
// Create user...
};Context Usage
Global Context (gctx)
Shared resources across all requests:
const handler: Handler = async (req, res, gctx, lctx) => {
// Access modules
const db = gctx.modules['db'];
const cache = gctx.modules['cache'];
const logger = gctx.modules['logger'];
// Access config
const apiKey = gctx.config.apiKey;
// Access shared state
const appState = gctx.state.someValue;
// Register shutdown hook
gctx.lifecycle.onShutdown(async () => {
await cleanup();
});
};Local Context (lctx)
Request-specific data:
const handler: Handler = async (req, res, gctx, lctx) => {
// Access request metadata
console.log('Request ID:', lctx.requestId);
console.log('Timestamp:', lctx.timestamp);
// Store request-scoped data
lctx.state.userId = req.params.id;
lctx.state.startTime = Date.now();
// Register cleanup hook
lctx.lifecycle.onCleanup(async () => {
const duration = Date.now() - (lctx.state.startTime as number);
console.log(`Request completed in ${duration}ms`);
});
// Process request...
};Sharing Data Between Handlers
Use lctx.state for request-scoped data:
// Middleware handler
const authHandler: Handler = async (req, res, gctx, lctx) => {
const token = req.headers['authorization'];
const user = await verifyToken(token);
// Store user in local context
lctx.state.user = user;
};
// Route handler
const getUserHandler: Handler = async (req, res, gctx, lctx) => {
// Access user from local context
const currentUser = lctx.state.user;
res.json({ user: currentUser });
};Best Practices
1. Keep Handlers Focused
Each handler should do one thing:
// ✅ Good - Single responsibility
const getUserHandler: Handler = async (req, res, gctx) => {
const user = await gctx.modules['db'].findUser(req.params.id);
res.json({ user });
};
// ❌ Bad - Too many responsibilities
const complexHandler: Handler = async (req, res, gctx) => {
const user = await gctx.modules['db'].findUser(req.params.id);
const posts = await gctx.modules['db'].findPosts(user.id);
const comments = await gctx.modules['db'].findComments(user.id);
// ... too much logic
};2. Use Type Safety
Leverage TypeScript for better code quality:
interface User {
id: string;
name: string;
email: string;
}
interface CreateUserBody {
name: string;
email: string;
}
const createUserHandler: Handler = async (req, res, gctx) => {
const body = req.body as CreateUserBody;
const db = gctx.modules['db'] as Database;
const user: User = await db.create(body);
res.status(201).json({ user });
};3. Validate Input
Always validate user input:
import { z } from 'zod';
const UserSchema = z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
});
const handler: Handler = async (req, res) => {
const result = UserSchema.safeParse(req.body);
if (!result.success) {
throw new HandlerError('Invalid input', 400, {
errors: result.error.errors,
});
}
// Use validated data
const { name, email } = result.data;
};4. Use Async/Await
Modern async patterns are clearer:
// ✅ Good - async/await
const handler: Handler = async (req, res, gctx) => {
const user = await gctx.modules['db'].findUser(req.params.id);
res.json({ user });
};
// ❌ Avoid - promise chains
const handler: Handler = (req, res, gctx) => {
gctx.modules['db']
.findUser(req.params.id)
.then(user => res.json({ user }))
.catch(error => console.error(error));
};5. Handle Errors Gracefully
Always provide meaningful error messages:
const handler: Handler = async (req, res, gctx) => {
try {
const result = await gctx.modules['db'].query(/* ... */);
res.json({ result });
} catch (error) {
// Log for debugging
console.error('Database error:', error);
// Return user-friendly error
throw new HandlerError(
'Failed to fetch data',
500,
{ requestId: lctx.requestId }
);
}
};6. Use Modules for Shared Logic
Don't duplicate code in handlers:
// ✅ Good - use module
const handler: Handler = async (req, res, gctx) => {
const db = gctx.modules['db'];
const user = await db.findUser(req.params.id);
res.json({ user });
};
// ❌ Bad - duplicate database logic
const handler: Handler = async (req, res) => {
const connection = await createConnection(/* ... */);
const user = await connection.query(/* ... */);
await connection.close();
res.json({ user });
};Common Patterns
Pattern 1: CRUD Operations
Complete CRUD example:
// List all
const listHandler: Handler = async (req, res, gctx) => {
const items = await gctx.modules['db'].findAll();
res.json({ items });
};
// Get one
const getHandler: Handler = async (req, res, gctx) => {
const item = await gctx.modules['db'].findById(req.params.id);
if (!item) throw new HandlerError('Not found', 404);
res.json({ item });
};
// Create
const createHandler: Handler = async (req, res, gctx) => {
const item = await gctx.modules['db'].create(req.body);
res.status(201).json({ item });
};
// Update
const updateHandler: Handler = async (req, res, gctx) => {
const item = await gctx.modules['db'].update(req.params.id, req.body);
res.json({ item });
};
// Delete
const deleteHandler: Handler = async (req, res, gctx) => {
await gctx.modules['db'].delete(req.params.id);
res.status(204).end();
};Pattern 2: Pagination
const paginatedHandler: Handler = async (req, res, gctx) => {
const page = parseInt(req.query.page as string) || 1;
const limit = parseInt(req.query.limit as string) || 10;
const offset = (page - 1) * limit;
const db = gctx.modules['db'];
const [items, total] = await Promise.all([
db.findMany({ limit, offset }),
db.count(),
]);
res.json({
items,
pagination: {
page,
limit,
total,
pages: Math.ceil(total / limit),
},
});
};Pattern 3: Search and Filter
const searchHandler: Handler = async (req, res, gctx) => {
const query = req.query.q as string;
const category = req.query.category as string | undefined;
const sortBy = req.query.sort as string || 'createdAt';
const db = gctx.modules['db'];
const results = await db.search({
query,
category,
sortBy,
});
res.json({ results, count: results.length });
};Pattern 4: File Upload
const uploadHandler: Handler = async (req, res, gctx) => {
const file = req.body as { name: string; content: Buffer };
const storage = gctx.modules['storage'];
const fileUrl = await storage.upload(file);
res.status(201).json({ fileUrl });
};Pattern 5: Batch Operations
const batchHandler: Handler = async (req, res, gctx) => {
const { ids } = req.body as { ids: string[] };
const db = gctx.modules['db'];
const results = await Promise.all(
ids.map(id => db.findById(id))
);
res.json({ results });
};Next: Module Creation Guide →
See Also: