ADR-0005: API-first architecture; business logic lives in the API, not the client
Status: Accepted Date: ~2026-02 (estimate, post-FIT-17 / WhatsApp planning) Context owner: Owner
Context
The web app is the only shipped client today, but the roadmap commits to:
- A WhatsApp bot (FIT-17 / FIT-140) that exposes the same actions (booking a class, signing a form, listing schedule) without a browser.
- A native mobile app (FIT-170 introduces FCM/APNs push; the mobile shell follows).
- A Spotter agent (FIT-161 shipped) that automates coach workflows via tool calls — the agent is itself a “client” that needs structured access to every action.
If business logic lives in the web client (or worse, is split between client and API), every new client re-implements it and drifts from the others. We’ve seen this fail in adjacent products.
Decision
All business logic lives in the API. The web is a renderer.
- API responses carry explicit state fields (enums > booleans > inference). Example: a workout response includes
status: "draft" | "published" | "archived"rather than letting the client infer it frompublishedAt != null. - API endpoints expose the full surface needed for any client. The Spotter agent’s tool layer (
apps/api/src/ai/agent/tools/) is roughly a 1:1 mirror of the public API surface, calling the same services. - Validation lives in Zod schemas under
libs/shared/src/lib/schemas/(shared) andlibs/shared/src/lib/agent-schemas/(Spotter-specific). Both clients and Spotter route through the same validators. - The web app is allowed to optimistically update UI and re-fetch, but never to compute derived business state that other clients couldn’t.
Consequences
Positive
- The WhatsApp bot can be implemented as a thin shell over the API. Same for native, same for Spotter.
- Tests at the API layer cover all clients simultaneously.
- Multi-client behavior stays consistent (e.g., a member sees the same “membership expired” message whether they’re on web or get an automated WhatsApp).
- AI agents (Spotter) compose well — their tools call services, not API HTTP endpoints, and the services are the same code path as HTTP requests.
Negative
- More API endpoints than a thin-API approach.
- Some quick UX iterations require an API change (slower than client-only).
- Aggregation endpoints (returning denormalized “view” responses) proliferate to keep the client fast.
Discipline
- A web component must never reach into the database directly. The only data access is
serverFetch(server components) oruseApi(client) — both auto-attach Clerk bearer tokens. - If you’re tempted to add a
useEffectthat fetches three endpoints and joins them in the client: stop, add an aggregation endpoint instead. - New API endpoints must accept the same auth model (bearer token, AuthGuard) — no “internal-only” endpoints. The Spotter agent goes through the same auth path.
Related
- ADR-0002: Clerk auth
- features/spotter-agent/README.md
- CLAUDE.md “Key Patterns → API-first”