Composition over configuration
Small APIs are easy. One file, a few routes, done.
Large APIs need structure. But structure doesn’t mean configuration files, decorators, or magic conventions.
In Hectoday HTTP, structure comes from composition, building larger pieces from smaller ones.
Building larger APIs
As your API grows, you need to organize routes, share guards, and reuse validation logic.
Composing handlers
Start simple:
// routes/users.ts
import { route } from "@hectoday/http";
export const getUsers = route.get("/users", {
resolve: async () => {
const users = await db.users.getAll();
return Response.json(users);
}
});
export const getUser = route.get("/users/:id", {
resolve: async (c) => {
const user = await db.users.get(c.raw.params.id);
if (!user) {
return Response.json({ error: "Not found" }, { status: 404 });
}
return Response.json(user);
}
});
export const createUser = route.post("/users", {
request: {
body: z.object({
name: z.string(),
email: z.string().email()
})
},
resolve: async (c) => {
if (!c.input.ok) {
return Response.json({ error: c.input.issues }, { status: 400 });
}
const user = await db.users.create(c.input.body);
return Response.json(user, { status: 201 });
}
});Collect into an array:
// routes/users.ts
export const userRoutes = [
getUsers,
getUser,
createUser
];Compose in main file:
// main.ts
import { setup } from "@hectoday/http";
import { userRoutes } from "./routes/users.ts";
import { postRoutes } from "./routes/posts.ts";
import { commentRoutes } from "./routes/comments.ts";
const app = setup({
handlers: [
...userRoutes,
...postRoutes,
...commentRoutes
]
});
Deno.serve(app.fetch);That’s it. Routes are just arrays. Composition is just spread operators. No magic.
Grouping with shared guards
Use group() to apply guards to multiple routes:
// routes/admin.ts
import { group, route } from "@hectoday/http";
import { requireAuth, requireAdmin } from "../guards.ts";
const adminUsers = route.get("/admin/users", {
resolve: async () => {
const users = await db.users.getAll();
return Response.json(users);
}
});
const deleteUser = route.delete("/admin/users/:id", {
resolve: async (c) => {
await db.users.delete(c.raw.params.id);
return new Response(null, { status: 204 });
}
});
const updateSettings = route.put("/admin/settings", {
request: {
body: settingsSchema
},
resolve: async (c) => {
if (!c.input.ok) {
return Response.json({ error: c.input.issues }, { status: 400 });
}
await db.settings.update(c.input.body);
return Response.json({ updated: true });
}
});
// Apply guards to all routes
export const adminRoutes = group({
guards: [requireAuth, requireAdmin],
handlers: [
adminUsers,
deleteUser,
updateSettings
]
});What group() does:
// Each route in the group gets the guards prepended
adminUsers.guards = [requireAuth, requireAdmin, ...adminUsers.guards]
deleteUser.guards = [requireAuth, requireAdmin, ...deleteUser.guards]
updateSettings.guards = [requireAuth, requireAdmin, ...updateSettings.guards]It’s build-time composition. No runtime overhead. No hidden behavior.
Nested groups
Groups can contain groups:
// routes/api.ts
import { group } from "@hectoday/http";
import { requireAuth } from "../guards.ts";
import { adminRoutes } from "./admin.ts";
import { userRoutes } from "./users.ts";
// All authenticated routes
export const apiRoutes = group({
guards: [requireAuth],
handlers: [
...userRoutes, // Gets [requireAuth, ...userRoutes.guards]
...adminRoutes // Gets [requireAuth, requireAdmin, ...adminRoutes.guards]
]
});Guard order: Outer group guards run first, then inner group guards, then route-specific guards.
// For a route in adminRoutes:
// 1. requireAuth (from apiRoutes)
// 2. requireAdmin (from adminRoutes)
// 3. Route-specific guards (if any)Reusing guards
Guards are just functions. Export and reuse them:
// guards/auth.ts
import type { GuardFn } from "@hectoday/http";
export const requireAuth: GuardFn = (c) => {
const token = c.request.headers.get("authorization");
if (!token) {
return { deny: Response.json({ error: "Unauthorized" }, { status: 401 }) };
}
const user = verifyToken(token);
if (!user) {
return { deny: Response.json({ error: "Invalid token" }, { status: 401 }) };
}
return { allow: true, locals: { user } };
};
export const requireAdmin: GuardFn = (c) => {
const user = c.locals.user;
if (!user || user.role !== "admin") {
return { deny: Response.json({ error: "Forbidden" }, { status: 403 }) };
}
return { allow: true };
};
export const requireEmailVerified: GuardFn = (c) => {
const user = c.locals.user;
if (!user?.emailVerified) {
return { deny: Response.json({ error: "Email not verified" }, { status: 403 }) };
}
return { allow: true };
};Use across routes:
// routes/profile.ts
import { requireAuth, requireEmailVerified } from "../guards/auth.ts";
export const profileRoutes = [
route.get("/profile", {
guards: [requireAuth],
resolve: (c) => Response.json(c.locals.user)
}),
route.put("/profile", {
guards: [requireAuth, requireEmailVerified],
resolve: async (c) => {
// Update profile
}
})
];// routes/admin.ts
import { requireAuth, requireAdmin } from "../guards/auth.ts";
export const adminRoutes = group({
guards: [requireAuth, requireAdmin],
handlers: [/* admin routes */]
});Same guards, different contexts. Pure functions, no magic.
Parameterized guards
Create guard factories for flexible reuse:
// guards/permissions.ts
import type { GuardFn } from "@hectoday/http";
export const requireRole = (role: string): GuardFn => {
return (c) => {
const user = c.locals.user;
if (!user || user.role !== role) {
return {
deny: Response.json(
{ error: `${role} role required` },
{ status: 403 }
)
};
}
return { allow: true };
};
};
export const requirePermission = (permission: string): GuardFn => {
return (c) => {
const user = c.locals.user;
if (!user?.permissions?.includes(permission)) {
return {
deny: Response.json(
{ error: `Missing permission: ${permission}` },
{ status: 403 }
)
};
}
return { allow: true };
};
};
export const requireOwnership = (
resourceGetter: (c: Context) => Promise<{ ownerId: string } | null>
): GuardFn => {
return async (c) => {
const user = c.locals.user;
const resource = await resourceGetter(c);
if (!resource) {
return { deny: Response.json({ error: "Not found" }, { status: 404 }) };
}
if (resource.ownerId !== user.id) {
return { deny: Response.json({ error: "Forbidden" }, { status: 403 }) };
}
return { allow: true, locals: { resource } };
};
};Use with different parameters:
route.get("/admin", {
guards: [requireAuth, requireRole("admin")],
resolve: (c) => Response.json({ data: "admin data" })
});
route.get("/moderator", {
guards: [requireAuth, requireRole("moderator")],
resolve: (c) => Response.json({ data: "mod data" })
});
route.delete("/posts/:id", {
guards: [
requireAuth,
requirePermission("posts:delete"),
requireOwnership(async (c) => {
return await db.posts.get(c.raw.params.id);
})
],
resolve: async (c) => {
await db.posts.delete(c.raw.params.id);
return new Response(null, { status: 204 });
}
});Still explicit. You see the parameters in the route definition.
Reusing validators
Validators are just schemas. Share them:
// schemas/user.ts
import { z } from "zod";
export const nameSchema = z.string().min(1).max(100);
export const emailSchema = z.string().email();
export const passwordSchema = z.string().min(8).max(100);
export const userIdSchema = z.string().uuid();
export const createUserSchema = z.object({
name: nameSchema,
email: emailSchema,
password: passwordSchema
});
export const updateUserSchema = z.object({
name: nameSchema.optional(),
email: emailSchema.optional()
});
export const loginSchema = z.object({
email: emailSchema,
password: passwordSchema
});Use across routes:
// routes/users.ts
import { createUserSchema, updateUserSchema } from "../schemas/user.ts";
export const userRoutes = [
route.post("/users", {
request: { body: createUserSchema },
resolve: async (c) => {
if (!c.input.ok) {
return Response.json({ error: c.input.issues }, { status: 400 });
}
const user = await db.users.create(c.input.body);
return Response.json(user, { status: 201 });
}
}),
route.patch("/users/:id", {
request: { body: updateUserSchema },
resolve: async (c) => {
if (!c.input.ok) {
return Response.json({ error: c.input.issues }, { status: 400 });
}
const user = await db.users.update(c.raw.params.id, c.input.body);
return Response.json(user);
}
})
];// routes/auth.ts
import { loginSchema } from "../schemas/user.ts";
export const authRoutes = [
route.post("/login", {
request: { body: loginSchema },
resolve: async (c) => {
if (!c.input.ok) {
return Response.json({ error: c.input.issues }, { status: 400 });
}
const token = await authenticate(c.input.body);
return Response.json({ token });
}
})
];Same schemas, different routes. Data structures, not configuration.
Composition patterns
By feature:
src/
features/
users/
routes.ts
guards.ts
schemas.ts
posts/
routes.ts
guards.ts
schemas.ts
comments/
routes.ts
schemas.ts
main.tsBy layer:
src/
routes/
users.ts
posts.ts
comments.ts
guards/
auth.ts
permissions.ts
schemas/
user.ts
post.ts
main.tsHybrid:
src/
api/
users/
routes.ts
schemas.ts
posts/
routes.ts
schemas.ts
guards/
auth.ts # Shared across features
permissions.ts
main.tsChoose what works for your team. Hectoday HTTP doesn’t enforce structure.
Helpers as copy-paste recipes
Hectoday HTTP has a minimal core. Everything else is helpers, copy-paste recipes you can use, modify, or ignore.
Why helpers are documentation, not dependencies
Core framework:
- Route matching
- Validation integration
- Guard composition
- Context threading
That’s it. ~500 lines of code.
Everything else is in the docs as recipes:
- Zod Validator - Validator adapter for Zod schemas
- Body size limits -
maxBodyBytesguard - CORS headers -
corsHeadersresponse helper - Request ID tracking - Request ID generation and headers
- Rate limiting - Rate limit guards
These are recipes, not packages. Copy the code, paste it into your project, modify it for your needs.
Why not a package?
- No dependency to maintain
- No version conflicts
- No “tree-shaking” concerns
- You own the code
- Modify without forking
- Copy only what you need
Example: maxBodyBytes helper
See the full documentation for the complete code.
Quick version:
// helpers/maxBodyBytes.ts - copied from docs
import type { GuardFn } from "@hectoday/http";
const SIZES = {
KB: 1024,
MB: 1024 * 1024,
GB: 1024 * 1024 * 1024
};
function maxBodyBytes(limit: number): GuardFn {
return (c) => {
const contentLength = c.request.headers.get("content-length");
if (!contentLength) return { allow: true };
const size = parseInt(contentLength, 10);
if (size > limit) {
return {
deny: Response.json(
{ error: "Request body too large", limit, received: size },
{ status: 413 }
)
};
}
return { allow: true };
};
}Use it:
// Import from YOUR project (you copied it)
import { maxBodyBytes, SIZES } from "./helpers/maxBodyBytes.ts";
route.post("/upload", {
guards: [maxBodyBytes(10 * SIZES.MB)],
resolve: async (c) => {
const data = await c.request.arrayBuffer();
return Response.json({ size: data.byteLength });
}
});Modify it for your needs:
// Your custom version
const maxBodyBytesCustom = (limit: number): GuardFn => {
return async (c) => {
const contentLength = c.request.headers.get("content-length");
if (!contentLength) {
// Different behavior: reject missing Content-Length
return {
deny: Response.json(
{ error: "Content-Length header required" },
{ status: 411 }
)
};
}
const size = parseInt(contentLength, 10);
if (size > limit) {
// Different error format
return {
deny: new Response("Body too large", { status: 413 })
};
}
return { allow: true };
};
};Or don’t use it at all. It’s optional.
More helper examples
CORS Headers - See full docs
// Copy from docs, use in onResponse
onResponse: ({ response }) => {
const headers = new Headers(response.headers);
headers.set("Access-Control-Allow-Origin", "*");
headers.set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE");
return new Response(response.body, { status: response.status, headers });
}Request ID Tracking - See full docs
// Copy from docs, use in onRequest + onResponse
onRequest: ({ request }) => ({
requestId: crypto.randomUUID()
}),
onResponse: ({ context, response }) => {
const headers = new Headers(response.headers);
headers.set("X-Request-ID", context.locals.requestId);
return new Response(response.body, { status: response.status, headers });
}Rate Limiting - See full docs
// Copy from docs, use as guard
guards: [rateLimit({ maxRequests: 100, windowMs: 60_000 })]Why copy-paste?
No dependency bloat:
// Your project
import { maxBodyBytes } from "./helpers/maxBodyBytes.ts";
// You copied only what you need
// No external dependencies
// No version conflicts
// No tree-shaking concernsCompare to monolithic frameworks:
import { Framework } from "big-framework";
// Bundle includes:
// - CORS middleware (you don't use)
// - Session middleware (you don't use)
// - Cookie parser (you don't use)
// - Static file server (you don't use)
// - 50 other things you don't useWith copy-paste helpers:
- You own the code
- Modify without forking
- No dependency updates to track
- No breaking changes from maintainers
- Copy only what you need
Hectoday HTTP: core is ~500 lines, helpers are documentation.
Writing your own helpers
Helpers are just functions. Write your own:
// helpers/rateLimit.ts
import type { GuardFn } from "@hectoday/http";
const rateLimits = new Map<string, { count: number; resetAt: number }>();
export const rateLimit = (
maxRequests: number,
windowMs: number,
keyFn: (c: Context) => string = (c) => c.request.headers.get("x-forwarded-for") || "unknown"
): GuardFn => {
return (c) => {
const key = keyFn(c);
const now = Date.now();
const record = rateLimits.get(key);
if (!record || now > record.resetAt) {
rateLimits.set(key, { count: 1, resetAt: now + windowMs });
return { allow: true };
}
if (record.count >= maxRequests) {
return {
deny: Response.json(
{
error: "Rate limit exceeded",
limit: maxRequests,
resetAt: new Date(record.resetAt).toISOString()
},
{ status: 429 }
)
};
}
record.count++;
return { allow: true };
};
};Use it:
route.post("/api/search", {
guards: [
rateLimit(100, 60_000) // 100 requests per minute
],
resolve: async (c) => {
// Handle search
}
});It’s just a guard. Nothing special about helpers, they use the same primitives you use.
The philosophy
Core: Minimal, stable, never changes
Helpers: Optional, composable, evolve based on needs
Your code: Builds on both, owns the decisions
Your API
↓
Helpers (optional recipes)
↓
Core (minimal primitives)
↓
Web Standards (Request/Response)Small core, infinite flexibility.
Next: Security as a first-class concept - designing secure APIs with explicit controls.