Backend Patterns
Repository Pattern
Every database query lives in a repository file. Repositories are the only layer that imports from@folksbase/db — no other code constructs SQL.
contacts, imports, exports, tags, stats, settings, workspaces.
Service Layer
Services sit between routes and repositories. They contain business logic — validation, orchestration across multiple repositories, external API calls — but never touch HTTP concerns.c.json() or set HTTP headers. This makes them reusable in background jobs, which have no HTTP context.
Middleware Pipeline
Hono middleware forms a chain that every request passes through in order. Each middleware handles one concern and callsnext() to pass control to the next layer.
userId when available, falling back to IP.
Facade Pattern
The email service exposes a clean interface for sending emails, hiding the complexity of template rendering, Resend API calls, and error handling behind simple async functions.{ success, error? } result. Callers never deal with Resend directly.
Graceful Degradation
All external API calls — Anthropic AI, Resend email, Gravatar — follow the same pattern: try the call, cache the result, and fall back silently on failure. AI failures never break the CSV import flow. Email failures are logged but don’t prevent the operation from completing.csv-ai.service.ts), AI import summary generation (process-csv.ts), and Gravatar URL fetching (contacts.service.ts).
Cache-Aside (Redis)
Data is fetched from the database, then cached in Redis with a TTL. Subsequent reads hit the cache. Writes invalidate the cache so the next read fetches fresh data.redis.set() call includes a TTL — no exceptions. Contact counts cache for 5 minutes, AI column mapping results for 1 hour, and CSV chunk data for 1 hour.
Event-Driven Jobs (Step Orchestration)
Background jobs use Inngest’s event-driven model. Route handlers emit events, and Inngest functions subscribe to them. Each logical unit of work is wrapped instep.run() for isolated retries.
step.run().
Idempotent Upserts
CSV imports can contain duplicate emails, and imports can be retried. TheonConflictDoUpdate pattern ensures that inserting the same contact twice updates the existing record instead of failing.
onConflictDoNothing is used instead, because Drizzle throws if set receives an empty object. The settings repository demonstrates this pattern.
Streaming (Constant Memory)
Large file operations use streaming to avoid buffering entire files in memory. Uploads split the stream withtee(), exports fetch contacts in cursor-based batches and pipe through csv-stringify, and downloads proxy the blob stream directly to the HTTP response.
This pattern is covered in depth on the Streaming Architecture page.
Frontend Patterns
Server Components by Default
Every component is a React Server Component unless it genuinely needs client-side interactivity. When both data fetching and interactivity are needed, the component is split into two: a Server Component wrapper that fetches data, and a Client Component leaf that handles interaction.'use client' is only added when required: event handlers, browser APIs (localStorage, window), SWR hooks, or state that changes without navigation.
Stale-While-Revalidate (SWR)
Client-side data fetching uses SWR for automatic caching, revalidation, and optimistic updates. Server-fetched data is passed asfallback to SWRConfig, so the first render uses server data and subsequent interactions fetch fresh data from the API.
Custom Hooks for Domain Logic
Reusable client-side logic is extracted into custom hooks that encapsulate state management, API calls, and derived values. Components stay focused on rendering.| Hook | What it encapsulates |
|---|---|
useContacts() | Cursor-based pagination, search, tag filtering, page size preference |
useSession() | Auth state, token refresh |
useDebouncedCallback() | Debounced search input (300ms) |
useStats() | Dashboard stats fetching |
useLogout() | Logout flow |
Retry with Backoff
The frontend API client retries failed mutation requests (POST, PUT, DELETE) once with a 1-second delay when the server returns 502, 503, or 504. GET requests are not retried — SWR handles revalidation for reads.fetch) are also retried, since they typically indicate a transient connectivity issue.
Cross-Cutting Patterns
Schema-First Validation (Zod)
Zod schemas are the single source of truth for data shapes. TypeScript types are derived from schemas viaz.infer<>, request validation uses zValidator(), and OpenAPI documentation uses resolver() — all from the same schema definition.
Shared Type Derivation
Types in@folksbase/types are derived from the Drizzle database schema, not defined manually. This means the TypeScript types always match the database columns.
Structured Logging
All logging goes through a structured logger that outputs JSON with consistent fields. Noconsole.log anywhere in production code.
Consistent Error Shape
Every error response from the API follows the same structure, whether it’s a validation error, an auth failure, or an unhandled exception:Encryption at Rest
Sensitive data (like Resend API keys) is encrypted before storage using AES-256-GCM. The settings repository transparently encrypts on write and decrypts on read — callers never deal with ciphertext.Cursor-Based Pagination
Every paginated query uses cursor-based pagination. OFFSET is never used — it causes performance degradation at scale and inconsistent results during concurrent writes.useContacts() hook maintains a cursor stack for back/forward navigation without OFFSET.
Pattern Summary
| Pattern | Where | Why |
|---|---|---|
| Repository | repositories/*.ts | Isolate SQL, make queries reusable and testable |
| Service Layer | services/*.ts | Separate business logic from HTTP and SQL concerns |
| Middleware Pipeline | middleware/*.ts | Compose cross-cutting concerns (auth, rate limiting, error handling) |
| Facade | email.service.ts | Hide Resend complexity behind a clean interface |
| Graceful Degradation | AI and email calls | External failures never break core functionality |
| Cache-Aside | Redis + repositories | Fast reads with TTL-based expiry |
| Event-Driven Jobs | Inngest step.run() | Isolated retries, decoupled from HTTP handlers |
| Idempotent Upserts | CSV import | Safe retries, no duplicate contacts |
| Streaming | Import/export pipelines | Constant memory regardless of file size |
| RSC by Default | apps/web | Minimize client JavaScript, fast initial render |
| SWR Fallback | Client components | Server data for first render, live updates after |
| Schema-First (Zod) | Validation, types, OpenAPI | One source of truth, no drift |
| Shared Types | @folksbase/types | Frontend and backend always agree on data shapes |
| Cursor Pagination | All paginated queries | Consistent performance at any scale |
What’s Next?
Backend Architecture
The layered architecture in detail.
Streaming Architecture
How large files are handled without running out of memory.
Frontend Architecture
RSC-first approach and component structure.
Database Schema
Tables, relationships, and naming conventions.