Backend Architecture
The folksbase API (apps/api) is built with Hono v4 and follows a strict layered architecture. Every request flows through the same path: route → service → repository → database. No shortcuts, no exceptions.
Why Layers?
Without clear boundaries, codebases tend to accumulate “god functions” — route handlers that parse requests, run business logic, construct SQL queries, and format responses all in one place. That works for a while, until:- You need to reuse business logic in a background job (but it’s tangled with HTTP response formatting)
- You need to test a query (but it’s buried inside a 200-line route handler)
- A new developer can’t tell where to put their code
The Layers
Route Handlers (routes/*.ts)
Routes handle HTTP concerns and nothing else:
- Parse and validate the request (using Zod via
zValidator) - Call the appropriate service method
- Return the HTTP response with
c.json()
@folksbase/db. They never contain business logic beyond basic input parsing.
Service Layer (services/*.ts)
Services contain business logic and orchestration:
- Validate business rules (e.g., “can this user perform this action?”)
- Coordinate between multiple repositories
- Transform data between layers
Repository Layer (repositories/*.ts)
Repositories are the only layer that talks to the database:
- Construct SQL queries using the Drizzle query builder
- Return typed results
- No business logic — just data access
Layer Rules
These rules are enforced in code review. Violating them means a rejected PR.| Rule | Why |
|---|---|
Routes never import from @folksbase/db | Forces all data access through repositories |
| Services never construct HTTP responses | Keeps services reusable in background jobs |
| Repositories never contain business logic | Keeps queries simple and testable |
| Routes never contain business logic | Keeps route handlers thin and readable |
Middleware Stack
Middleware runs in a specific order for every request:- Error Handler (
app.onError) — catches all unhandled errors and returns consistent{ code, message }responses. Handles Zod validation errors and auth errors specifically. - CORS — validates the request origin against a configurable allowlist (supports wildcard subdomains).
- Hono Logger — logs every request with method, path, and response time.
- Rate Limiter — applied to all
/api/*routes. 100 requests per 60 seconds per user. A stricter upload limiter (5 per 10 minutes) applies to CSV uploads.
Auth Middleware
Auth is applied per-route, not globally. This is intentional — some routes (health check, OpenAPI spec, webhooks) don’t need authentication. When applied, the auth middleware validates the Supabase JWT from theAuthorization header and attaches the user context:
OpenAPI Documentation
Every API route is self-documenting. The API auto-generates an OpenAPI 3.1 spec from route definitions usinghono-openapi:
GET /api/openapi.json— machine-readable OpenAPI specGET /api/docs— interactive Scalar API reference UI
describeRoute() for response schemas and zValidator() for request validation. This means the documentation is always in sync with the actual code — there’s no separate spec file to maintain.
Why Hono?
A few reasons folksbase uses Hono instead of Express or Fastify:- TypeScript-native. Hono was built for TypeScript from day one. Route handlers, middleware, and context are all fully typed.
- Lightweight. No heavy dependency tree. The core is tiny and fast.
- Portable. Hono runs on Node, Bun, Deno, and Cloudflare Workers. If the deployment target changes, the code doesn’t.
- Built-in OpenAPI support. The
hono-openapiintegration generates specs directly from route definitions — no decorators or separate config files.
Why Neon with HTTP Driver?
The database package uses Neon’s HTTP driver (drizzle-orm/neon-http) instead of the WebSocket driver. This was a deliberate choice:
- The API runs on Render.com, where persistent WebSocket connections to Neon were unreliable
- The HTTP driver works over standard HTTPS requests — simpler, more compatible with Render’s infrastructure
- For a request-response API (not a long-running connection pool), HTTP is the right fit