API Layer Pattern

Typed API communication layer between frontend and backend.

Frontend API Organization

All API modules live in frontend/src/lib/api/ (53 modules excluding tests, as of 2026-06).

Core Instance

frontend/src/lib/api/core.ts provides the shared Axios instance with:

  • Cookie credentials (withCredentials: true) plus an in-memory access token store (setAccessToken) — the request interceptor attaches the Bearer token
  • Response envelope unwrapper (registered first), slow-API friction events (>5s), and 5xx Sentry capture
  • Single 401 handler (registered second): refresh + retry, or redirect to /login if refresh fails — never add a second 401 redirect, it races the refresh
  • Base URL configuration per environment (VITE_API_URL, default /api)

Domain Modules

Each entity has a dedicated API module with typed request/response:

ModuleEntity
buildings.tsBuildings
projects.tsProjects
contacts.tsContacts
quotes.tsQuotes
invoices.tsInvoices
files.ts / files-api.tsFiles
funding.tsFunding Programs / Funding Applications
portal.tsPortal
workflow.tsWorkflows
document-obtaining.tsDocument Obtaining

Module Structure

// frontend/src/lib/api/buildings.ts
export const buildingsApi = {
  list: (projectId: string) =>
    api.get<Building[]>(`/api/projects/${projectId}/buildings`),
  get: (id: string) =>
    api.get<Building>(`/api/buildings/${id}`),
  create: (data: CreateBuildingInput) =>
    api.post<Building>('/api/buildings', data),
};

Backend Route Validation

Routes are plain Hono with zValidator (used across 120+ route files, as of 2026-06). All inputs are validated with Zod schemas before reaching services:

app.post('/', zValidator('json', createBuildingSchema), async (c) => {
  const validated = c.req.valid('json'); // Zod-validated
  const { buildings } = c.var.services;
  return c.json(await buildings.create(validated));
});

OpenAPIHono is used only for health.ts and version.ts; full OpenAPI coverage was deliberately declined (I#1955, docs/decisions/1955-deliberate-passes.md).

Shared Types

Request/response types shared between frontend and backend live in Shared Layer:

  • shared/api-types.tsApiResponse<T> (success/data/error envelope + pagination), ApiError (code, message, details?), isApiError()
  • shared/types.ts — Entity types, LocalizedText
  • shared/validation-constants.tsVALIDATION_LIMITS field length limits
  • shared/money.ts (@shared/money) — money values cross the wire as decimal strings, parsed with fromString() — see Validation Pattern

See Also