Skip to main content

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 layered pattern solves this by giving each concern a home. It’s not the only way to structure a backend, but it’s predictable and easy to enforce.

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()
Routes never import from @folksbase/db. They never contain business logic beyond basic input parsing.
// ✅ Route handler — thin, delegates to service
.get("/:id", zValidator("param", z.object({ id: z.string().uuid() })),
  async (c) => {
    const { id } = c.req.valid("param");
    const { workspaceId } = c.get("user");
    const contact = await contactsService.findById(workspaceId, id);
    return c.json(contact);
  }
)

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
Services never construct HTTP responses. They return data or throw errors — the route handler decides how to format the response.

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
// ✅ Repository — SQL only, no business logic
export async function findById(workspaceId: string, id: string) {
  const result = await db.query.contacts.findFirst({
    where: and(eq(contacts.id, id), eq(contacts.workspace_id, workspaceId)),
    with: { tags: true },
  });
  return result ?? null;
}

Layer Rules

These rules are enforced in code review. Violating them means a rejected PR.
RuleWhy
Routes never import from @folksbase/dbForces all data access through repositories
Services never construct HTTP responsesKeeps services reusable in background jobs
Repositories never contain business logicKeeps queries simple and testable
Routes never contain business logicKeeps route handlers thin and readable

Middleware Stack

Middleware runs in a specific order for every request:
  1. Error Handler (app.onError) — catches all unhandled errors and returns consistent { code, message } responses. Handles Zod validation errors and auth errors specifically.
  2. CORS — validates the request origin against a configurable allowlist (supports wildcard subdomains).
  3. Hono Logger — logs every request with method, path, and response time.
  4. 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 the Authorization header and attaches the user context:
c.set('user', { userId: user.id, workspaceId })
// Access in route handlers:
const { userId, workspaceId } = c.get('user')

OpenAPI Documentation

Every API route is self-documenting. The API auto-generates an OpenAPI 3.1 spec from route definitions using hono-openapi:
  • GET /api/openapi.json — machine-readable OpenAPI spec
  • GET /api/docs — interactive Scalar API reference UI
Routes use 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:
  1. TypeScript-native. Hono was built for TypeScript from day one. Route handlers, middleware, and context are all fully typed.
  2. Lightweight. No heavy dependency tree. The core is tiny and fast.
  3. Portable. Hono runs on Node, Bun, Deno, and Cloudflare Workers. If the deployment target changes, the code doesn’t.
  4. Built-in OpenAPI support. The hono-openapi integration 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

What’s Next?