2025-11-24 01:00:50 +01:00
# API Route Development Guide
## Overview
This guide outlines the standard practices for creating new API routes in ClassroomIO. **All routes should be designed with the assumption they may become public API endpoints** , requiring careful attention to consistency, security, validation, and documentation.
---
## Architecture Layers
Our API follows a four-layer architecture with shared validation:
```txt
┌─────────────────────────────────────┐
│ Frontend (Svelte/SvelteKit) │
│ - UI Components │
│ - API Client (RPC with Hono) │
│ - Client-side Validation │
└──────────────┬──────────────────────┘
│
│ ┌────────────────────────────┐
│ │ Shared Validation Layer │
├──┤ (@cio/utils/validation) │
│ │ - Zod Schemas │
│ │ - Type Definitions │
│ └────────────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ Route Layer (Hono) │
│ - HTTP Request/Response │
│ - Input Validation (Zod) │
│ - Authentication Check │
│ - Calls Services │
└──────────────┬──────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ Service Layer │
│ - Business Logic │
│ - Transaction Orchestration │
│ - Error Handling │
│ - Calls Queries │
└──────────────┬──────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ Query Layer (Drizzle ORM) │
│ - Database Operations │
│ - Pure Functions │
│ - Type-Safe Queries │
└─────────────────────────────────────┘
```
**Key Principles** :
- **Shared Validation**: Zod schemas in `@cio/utils/validation` used by both API and frontend
- **Routes**: Handle HTTP concerns only (validation, auth, serialization)
- **Services**: Contain all business logic and orchestrate queries
- **Queries**: Pure database operations with no business logic
---
## Type Generation
**Location** : `packages/db/src/types.ts` (auto-generated)
### How Types Are Generated
Types are automatically generated from your Drizzle schema using Drizzle's built-in type inference:
```typescript
// packages/db/src/types.ts (auto-generated - DO NOT EDIT)
import * as schema from './schema' ;
// For each table, two types are generated:
2025-12-02 15:27:41 +01:00
export type TUser = typeof schema . user . $inferSelect ; // For reading
export type TNewUser = typeof schema . user . $inferInsert ; // For creating
2025-11-24 01:00:50 +01:00
export type TOrganization = typeof schema . organization . $inferSelect ;
export type TNewOrganization = typeof schema . organization . $inferInsert ;
```
**To regenerate types after schema changes:**
```bash
cd packages/db
pnpm generate-types
```
This script:
1. Reads `src/schema.ts` to find all tables and enums
2. Generates type definitions using Drizzle's `$inferSelect` and `$inferInsert`
3. Outputs to `src/types.ts`
4. Formats the file with Prettier
**When to regenerate:**
- ✏️ After adding/removing tables in `schema.ts`
- ✏️ After adding/removing columns in existing tables
- ✏️ After changing column types or constraints
- ℹ ️ **Not needed** for data changes (migrations) - only for schema definition changes
---
## Step 1: Create Reusable Queries
**Location** : `packages/db/src/queries/{domain}/{entity}.ts`
### Principles
1. **Pure Functions** : Queries should be pure database operations with no business logic
2. **Single Responsibility** : Each query does one thing well
3. **Type Safety** : Leverage Drizzle's type inference for automatic types
4. **Reusability** : Queries can be used across multiple routes and services
### Example: Organization Queries
```typescript
// packages/db/src/queries/organization/organization.ts
import * as schema from '@db/schema' ;
import { db } from '@db/drizzle' ;
import { eq } from 'drizzle-orm' ;
import type { TNewOrganization , TOrganization } from '@db/types' ;
/**
* Creates a new organization
* @param data Organization creation data
* @returns Created organization record
*/
export const createOrganization = async ( data : TNewOrganization ) => {
2025-12-02 15:27:41 +01:00
const [ organization ] = await db . insert ( schema . organization ). values ( data ). returning ();
2025-11-24 01:00:50 +01:00
return organization ;
};
/**
* Updates an organization
* @param id Organization ID
* @param data Partial organization data
* @returns Updated organization record
*/
export const updateOrganization = async ( id : string , data : TOrganization ) => {
const [ organization ] = await db
. update ( schema . organization )
. set ( data )
. where ( eq ( schema . organization . id , id ))
. returning ();
2025-12-02 15:27:41 +01:00
2025-11-24 01:00:50 +01:00
return organization ;
};
```
**Type System:**
- Types are auto-generated from Drizzle schema using built-in inference
- `TNewOrganization` = `typeof schema.organization.$inferInsert` (for creates)
- `TOrganization` = `typeof schema.organization.$inferSelect` (for reads/updates)
- Input validation happens at the API route layer (not in queries)
- Queries are pure database operations with compile-time type safety
### Query Best Practices
#### ✅ DO
- **Use exported types from `@cio/db/types` ** (e.g., `TNewOrganization` , `TOrganization` )
- **Use `TNew{Table}` types** for create operations (e.g., `TNewOrganization` )
- **Use `T{Table}` types** for read operations (e.g., `TOrganization` )
- **Use `Partial<T{Table}>` types** for update operations (e.g., `Partial<TOrganization>` )
- Use descriptive names (`getOrganizationByProfileId` , not `getOrg` )
- Add JSDoc comments for all exported functions
- Return inferred types from Drizzle
- Use `.returning()` for insert/update operations to get the created/updated record
- Use `.limit(1)` for single-record queries
- Handle null/undefined cases explicitly
- Rely on Drizzle's compile-time type safety
#### ❌ DON'T
- Define ad-hoc types when `@cio/db/types` exports them
- Add runtime validation in queries (validation belongs in API routes)
- Include business logic in queries (authorization, side effects)
- Make HTTP calls or external API calls from queries
- Access environment variables directly
- Handle transactions within individual queries (do this at service layer)
- Throw custom `AppError` (let database errors bubble up)
- Mix concerns (e.g., sending emails from a query function)
### Export Queries
```typescript
// packages/db/src/queries/organization/index.ts
export * from './organization' ;
export * from './types' ;
// packages/db/src/queries/index.ts
export * from './organization' ;
export * from './auth' ;
export * from './course' ;
```
---
## Step 2: Create Services (Business Logic)
**Location** : `apps/api/src/services/{domain}.ts`
### Principles
1. **Business Logic Only** : Services contain business rules, validations, and orchestration
2. **Transaction Management** : Services handle database transactions
3. **Error Handling** : Services throw meaningful errors that routes can handle
4. **Testable** : Services should be easy to unit test
5. **No HTTP Concerns** : Services don't know about requests/responses
### Example: Organization Service
```typescript
// apps/api/src/services/onboarding.ts
import { db } from '@cio/db/drizzle' ;
import {
createOrganization ,
createOrganizationMember ,
checkSiteNameExists ,
getOrganizationByProfileId
} from '@cio/db/queries/organization' ;
import { updateProfile } from '@cio/db/queries/auth' ;
import { AppError , ErrorCodes } from '@api/utils/errors' ;
import type { TProfile } from '@cio/db/types' ;
/**
* Creates a new organization with the user as owner
*/
export async function createOrganizationWithOwner (
profileId : string ,
input : { orgName : string ; siteName : string ; fullname? : string }
) {
// Business Logic: Check sitename availability
const exists = await checkSiteNameExists ( input . siteName );
if ( exists ) {
2025-12-02 15:27:41 +01:00
throw new AppError ( `Site name ' ${ input . siteName } ' already exists` , ErrorCodes . SITENAME_EXISTS , 409 );
2025-11-24 01:00:50 +01:00
}
// Business Logic: Create org and member in a transaction
try {
const result = await db . transaction ( async ( tx ) => {
const organization = await createOrganization ({
name : input.orgName ,
siteName : input.siteName
});
const member = await createOrganizationMember ({
organizationId : organization.id ,
profileId ,
roleId : 1 , // Owner role
verified : true
});
return { organization , member };
});
// Fetch updated organizations list
const organizations = await getOrganizationByProfileId ( profileId );
return {
organization : result.organization ,
member : result.member ,
organizations
};
} catch ( error ) {
// Handle database constraint violations
if ( error . code === '23505' ) {
2025-12-02 15:27:41 +01:00
throw new AppError ( `Site name ' ${ input . siteName } ' already exists` , ErrorCodes . SITENAME_EXISTS , 409 );
2025-11-24 01:00:50 +01:00
}
throw new AppError ( 'Failed to create organization' , ErrorCodes . ORG_CREATE_FAILED , 500 );
}
}
```
### Error Handling in Services
Use `AppError` for consistent error handling across services:
```typescript
// apps/api/src/utils/errors.ts
import type { ContentfulStatusCode } from 'hono/utils/http-status' ;
export class AppError extends Error {
constructor (
message : string ,
public code : string ,
public statusCode : ContentfulStatusCode = 500 ,
2025-12-02 15:27:41 +01:00
public field? : string // Optional: for field-specific validation errors
2025-11-24 01:00:50 +01:00
) {
super ( message );
this . name = 'AppError' ;
}
}
export const ErrorCodes = {
SITENAME_EXISTS : 'SITENAME_EXISTS' ,
ORG_CREATE_FAILED : 'ORG_CREATE_FAILED' ,
2025-12-02 15:27:41 +01:00
PROFILE_NOT_FOUND : 'PROFILE_NOT_FOUND'
2025-11-24 01:00:50 +01:00
// ... add more as needed
} as const ;
```
**Why?** One reusable class scales infinitely vs creating custom error classes for every scenario.
### Error Handling in Routes
Use the `handleError` utility for consistent error responses across all routes:
```typescript
// apps/api/src/utils/errors.ts
export const handleError = (
c : Context ,
error : unknown ,
fallbackMessage : string = 'An unexpected error occurred' ,
fallbackCode : string = ErrorCodes . INTERNAL_ERROR
) => {
console . error ( 'Error in route:' , error );
if ( error instanceof AppError ) {
2025-12-02 15:27:41 +01:00
return c . json (
{
success : false ,
error : error.message ,
code : error.code ,
field : error.field
},
error . statusCode
);
2025-11-24 01:00:50 +01:00
}
2025-12-02 15:27:41 +01:00
return c . json (
{
success : false ,
error : fallbackMessage ,
code : fallbackCode
},
500
);
2025-11-24 01:00:50 +01:00
};
```
**Usage in routes:**
```typescript
// Simple usage with default message
try {
const data = await someService ();
return c . json ({ success : true , data });
} catch ( error ) {
return handleError ( c , error );
}
// With custom fallback message
try {
const data = await fetchAccount ( userId );
return c . json ({ success : true , data });
} catch ( error ) {
return handleError ( c , error , 'Failed to fetch account data' );
}
// With custom fallback message AND error code
try {
await deliverEmail ( data );
return c . json ({ success : true });
} catch ( error ) {
return handleError ( c , error , 'Failed to send email' , 'EMAIL_SEND_FAILED' );
}
```
**Benefits:**
- ✅ **Consistency** : All routes return the same error format
- ✅ **DRY** : No repetitive error handling code
- ✅ **Type-safe** : Proper TypeScript types for responses
- ✅ **Flexible** : Custom fallback messages per route
- ✅ **Field-aware** : Supports field-specific validation errors
### Service Best Practices
#### ✅ DO
- **Use descriptive function names** that explain the business operation
- **Throw `AppError` ** with appropriate error codes and status codes
- **Handle transactions** within services
- **Validate business rules** (e.g., checking if sitename exists)
- **Return structured data** that routes can easily serialize
- **Add JSDoc comments** explaining the business logic
- **Keep services focused** on a specific domain
#### ❌ DON'T
- Return HTTP responses (status codes, headers)
- Access request/response objects directly
- Handle HTTP-specific concerns (CORS, cookies, etc.)
- Mix multiple domains in one service (keep focused)
- Catch errors just to log them (let them bubble up)
- Create custom error classes for every error type (use `AppError` )
### Export Services
```typescript
// apps/api/src/services/onboarding.ts
export * from './onboarding' ;
// apps/api/src/services/index.ts
export * from './onboarding' ;
export * from './account' ;
export * from './course' ;
```
---
## Step 3: Create API Route
**Location** : `apps/api/src/routes/{domain}/{entity}.ts`
### Public API Standards
Since routes may be exposed as public APIs, follow these standards rigorously:
#### 1. **Consistent Response Format**
All API responses should follow a consistent structure:
```typescript
// Success Response
{
"success" : true ,
"data" : { ... }, // The actual response data
"message" : "Optional" // Optional human-readable message
}
// Error Response
{
"success" : false ,
"error" : "Human-readable error message" ,
"code" : "ERROR_CODE" , // Optional: machine-readable error code
"field" : "fieldName" // Optional: for validation errors, indicates which field failed
}
// List Response (with pagination)
{
"success" : true ,
"data" : [...],
"pagination" : {
"page" : 1 ,
"limit" : 20 ,
"total" : 100 ,
"totalPages" : 5
}
}
```
#### 2. **HTTP Status Codes**
Use semantically correct HTTP status codes:
2025-12-02 15:27:41 +01:00
| Code | Meaning | Usage |
| --------------------------- | ------------ | -------------------------------------------------------- |
| `200 OK` | Success | Successful GET, PUT, PATCH requests |
| `201 Created` | Created | Successful POST creating a new resource |
| `204 No Content` | Success | Successful DELETE or update with no response body |
| `400 Bad Request` | Client Error | Malformed request, validation errors |
| `401 Unauthorized` | Auth Error | Missing or invalid authentication token |
| `403 Forbidden` | Auth Error | Authenticated but not authorized for this action |
| `404 Not Found` | Client Error | Resource doesn't exist |
| `409 Conflict` | Client Error | Duplicate resource (e.g., email/sitename already exists) |
| `422 Unprocessable Entity` | Client Error | Valid syntax but semantic validation failed |
| `429 Too Many Requests` | Client Error | Rate limit exceeded |
| `500 Internal Server Error` | Server Error | Unexpected server error |
| `503 Service Unavailable` | Server Error | Temporary service outage |
2025-11-24 01:00:50 +01:00
#### 3. **Input Validation with Zod**
**Always validate all input** using Zod schemas. Define ALL validation schemas in `packages/utils/src/validation/` organized by entity.
**Why in utils package?**
- ✅ Shared between API (server-side validation) and Dashboard (client-side validation)
- ✅ Single source of truth - no schema duplication
- ✅ Type-safe across the entire monorepo
- ✅ Convention-based translations (clean schemas without hardcoded messages)
**File Structure** :
```txt
packages/utils/src/validation/
├── course/
│ ├── course.ts ← All course validation schemas
│ └── index.ts ← Re-exports
├── mail/
│ ├── mail.ts ← Mail validation schemas
│ └── index.ts ← Re-exports
├── constants.ts ← Shared constants
├── index.ts ← Main exports
└── README.md ← Documentation
```
2025-12-02 09:54:38 +01:00
**File Naming Convention** :
All file names should use **lowercase with hyphens** (kebab-case):
- ✅ `setup-progress.svelte` (component files)
- ✅ `org.svelte.ts` (API service files)
- ✅ `course.ts` (validation/query files)
- ✅ `user-profile.svelte` (multi-word component files)
- ❌ `SetupProgress.svelte` (PascalCase - reserved for component names in imports)
- ❌ `Org.svelte.ts` (PascalCase)
- ❌ `userProfile.svelte` (camelCase)
- ❌ `user_profile.svelte` (snake_case)
**Note** : Component names in imports can still use PascalCase (e.g., `import SetupProgress from './setup-progress.svelte'` ), but the actual file names must be lowercase with hyphens.
2025-11-24 01:00:50 +01:00
**Example: Course Validations.**
Schemas should be **clean** - only validation rules, no error messages:
```typescript
// packages/utils/src/validation/course/course.ts
import * as z from 'zod' ;
import { ALLOWED_CONTENT_TYPES } from '../constants' ;
export const ZCourseClone = z . object ({
2025-12-02 15:27:41 +01:00
id : z.string (). min ( 1 ), // No hardcoded message
title : z.string (). min ( 1 ), // No hardcoded message
2025-11-24 01:00:50 +01:00
description : z.string (). optional (),
slug : z.string (). min ( 1 ),
organizationId : z.string (). min ( 1 )
});
export type TCourseClone = z . infer < typeof ZCourseClone >;
// packages/utils/src/validation/course/index.ts
export * from './course' ;
```
**Using in API Routes** :
```typescript
// apps/api/src/routes/course/clone.ts
import { ZCourseClone } from '@cio/utils/validation/course' ;
import { zValidator } from '@hono/zod-validator' ;
2025-12-02 15:27:41 +01:00
export const cloneRouter = new Hono (). post ( '/' , authMiddleware , zValidator ( 'json' , ZCourseClone ), async ( c ) => {
const data = c . req . valid ( 'json' ); // Fully typed!
// ... route handler
});
2025-11-24 01:00:50 +01:00
```
**Using in Frontend Forms** :
```typescript
// apps/dashboard/src/components/CourseForm.svelte
import { ZCourseClone } from '@cio/utils/validation/course' ;
import { mapZodErrorsToTranslations } from '$lib/utils/validation' ;
const result = ZCourseClone . safeParse ( formData );
if ( ! result . success ) {
// Maps Zod errors to translated messages using convention
// Pass 'course' as entity name for entity-specific translations
errors = mapZodErrorsToTranslations ( result . error , 'course' );
// errors.title = "Course title is required" (translated)
}
```
**Convention-Based Translations** :
Frontend maps validation errors to translations using a **smart fallback system** :
1. **Entity-specific** : `validations.{entityName}.{fieldName}.{errorCode}` (most specific)
2. **Generic field** : `validations.{fieldName}.{errorCode}` (fallback)
3. **Generic type** : `validations.generic.{errorType}` (last resort)
This allows different messages for shared field names (e.g., "Course title is required" vs "Lesson title is required"):
```json
// apps/dashboard/src/lib/utils/translations/en.json
{
"validations" : {
// Entity-specific translations (preferred)
"course" : {
"title" : { "too_small" : "Course title is required" },
"slug" : { "too_small" : "Course slug is required" }
},
"lesson" : {
"title" : { "too_small" : "Lesson title is required" },
"duration" : { "too_small" : "Lesson duration must be at least 1 minute" }
},
2025-12-02 15:27:41 +01:00
2025-11-24 01:00:50 +01:00
// Generic field translations (fallback for fields used across entities)
"title" : { "too_small" : "Title is required" },
"email" : { "invalid_email" : "Please enter a valid email address" },
2025-12-02 15:27:41 +01:00
2025-11-24 01:00:50 +01:00
// Generic error type translations (last resort)
"generic" : {
"required" : "This field is required" ,
"invalid_email" : "Invalid email address" ,
"too_small_string" : "Must be at least {min} characters"
}
}
}
```
**Entity Name Usage** :
```typescript
// With entity name (entity-specific messages)
2025-12-02 15:27:41 +01:00
mapZodErrorsToTranslations ( error , 'course' ); // "Course title is required"
mapZodErrorsToTranslations ( error , 'lesson' ); // "Lesson title is required"
2025-11-24 01:00:50 +01:00
// Without entity name (generic messages)
2025-12-02 15:27:41 +01:00
mapZodErrorsToTranslations ( error ); // "Title is required"
2025-11-24 01:00:50 +01:00
```
**Key Principles** :
- **Keep schemas clean**: No hardcoded error messages (translations handled in frontend)
- **Single source of truth**: One schema used by both API and frontend
- **Entity-specific translations**: Use entity name parameter for context-specific error messages
- **Smart fallbacks**: Entity-specific → Generic field → Generic type → Hardcoded
- **Follow naming convention**: Schemas with `Z` prefix, types with `T` prefix
- **Organize by entity**: Each entity gets its own folder
---
#### 4. **Translation Strategy**
**Overview** : Our validation system uses a **convention-based translation approach** with smart fallbacks to provide flexible, maintainable error messages.
##### Translation Lookup Order
When a validation error occurs, the system tries translation keys in this order:
1. **Entity-specific** (most specific): `validations.course.title.too_small`
2. **Generic field** (fallback): `validations.title.too_small`
3. **Generic type** (last resort): `validations.generic.too_small_string`
4. **Hardcoded fallback** : "Must be at least 1 character"
##### When to Use Entity-Specific Translations
Use entity names when:
✅ **Shared field names** across entities
- Example: Both Course and Lesson have `title` , but need different context
- Solution: `mapZodErrorsToTranslations(error, 'course')` vs `mapZodErrorsToTranslations(error, 'lesson')`
✅ **Entity-specific context** matters
- Example: "Course description helps students understand..." vs "Lesson description explains..."
- Better UX with contextual messaging
✅ **Business domain clarity**
- Makes error messages more professional and domain-specific
Skip entity names when:
❌ **Universal fields** that mean the same everywhere
- Example: `email` , `password` , `firstName` - same validation message across all entities
❌ **Unique field names** that only exist in one entity
- Example: `certificateTemplate` only in Course entity
❌ **Generic utility forms** where entity context isn't relevant
##### Translation File Organization
```json
{
"validations" : {
// 1. Entity-specific (for shared field names)
"course" : {
"title" : {
"too_small" : "Course title is required" ,
"too_big" : "Course title must be less than 100 characters"
},
"description" : {
"too_small" : "Please add a course description to help students"
}
},
"lesson" : {
"title" : {
"too_small" : "Lesson title is required" ,
"too_big" : "Lesson title must be less than 100 characters"
},
"duration" : {
"too_small" : "Lesson must be at least 1 minute long"
}
},
2025-12-02 15:27:41 +01:00
2025-11-24 01:00:50 +01:00
// 2. Generic field-level (fallback for common fields)
"title" : {
"too_small" : "Title is required"
},
"email" : {
"invalid_email" : "Please enter a valid email address"
},
"password" : {
"too_small" : "Password must be at least 8 characters"
},
2025-12-02 15:27:41 +01:00
2025-11-24 01:00:50 +01:00
// 3. Generic error types (last resort fallback)
"generic" : {
"required" : "This field is required" ,
"invalid_email" : "Invalid email address" ,
"invalid_url" : "Invalid URL format" ,
"too_small_string" : "Must be at least {min} characters" ,
"too_big_string" : "Must be at most {max} characters" ,
"invalid_type" : "Invalid value type" ,
"invalid_enum" : "Invalid selection"
}
}
}
```
##### Implementation Examples
**Example 1: Entity-specific (Course form).**
```typescript
import { ZCourseCreate } from '@cio/utils/validation/course' ;
import { mapZodErrorsToTranslations } from '$lib/utils/validation' ;
const result = ZCourseCreate . safeParse ( formData );
if ( ! result . success ) {
errors = mapZodErrorsToTranslations ( result . error , 'course' );
// Result: "Course title is required" (entity-specific)
}
```
**Example 2: Generic (User profile form).**
```typescript
import { ZUserProfile } from '@cio/utils/validation/user' ;
import { mapZodErrorsToTranslations } from '$lib/utils/validation' ;
const result = ZUserProfile . safeParse ( formData );
if ( ! result . success ) {
errors = mapZodErrorsToTranslations ( result . error );
// Result: "Please enter a valid email address" (generic)
}
```
**Example 3: Mixed fields.**
```typescript
// Course has both shared fields (title) and unique fields (certificateTemplate)
const result = ZCourseUpdate . safeParse ( formData );
if ( ! result . success ) {
errors = mapZodErrorsToTranslations ( result . error , 'course' );
// errors.title = "Course title is required" (entity-specific)
// errors.email = "Please enter a valid email address" (generic fallback)
}
```
##### Benefits of This Approach
✅ **Flexible** : Use entity-specific messages only where needed
✅ **Scalable** : Add new entities without updating all translations
✅ **Maintainable** : Clear fallback hierarchy reduces duplication
✅ **Developer-friendly** : Works with or without entity name parameter
✅ **Multi-language** : Same system works across all locales
2025-12-02 15:27:41 +01:00
✅ **Professional UX** : Context-specific messages improve user experience
2025-11-24 01:00:50 +01:00
---
#### 5. **Authentication & Authorization**
```typescript
import { authMiddleware } from '@api/middlewares/auth' ;
2025-12-02 09:54:38 +01:00
import { apiKeyMiddleware } from '@api/middlewares/api-key' ;
import { authOrApiKeyMiddleware } from '@api/middlewares/auth-or-api-key' ;
2025-11-24 01:00:50 +01:00
2025-12-02 09:54:38 +01:00
// Protected route - requires user authentication (session cookies)
2025-11-24 01:00:50 +01:00
. post ( '/create' , authMiddleware , async ( c ) => {
const user = c . get ( 'user' ); // Always available after authMiddleware
const session = c . get ( 'session' );
// ...
});
2025-12-02 09:54:38 +01:00
// Protected route - requires API key (server-to-server)
. post ( '/webhook' , apiKeyMiddleware , async ( c ) => {
// API key validated, proceed
// ...
});
// Protected route - accepts either user session OR API key
. post ( '/plan' , authOrApiKeyMiddleware , async ( c ) => {
// Can be called by:
// 1. Authenticated users (via session cookies)
// 2. Server-to-server (via API key: Authorization: Bearer <key>)
// ...
});
2025-11-24 01:00:50 +01:00
// Public endpoint - no authentication required
. get ( '/public-courses' , async ( c ) => {
// Publicly accessible data
});
// Optional authentication - checks if user is logged in but doesn't require it
. get ( '/courses' , async ( c ) => {
const user = c . get ( 'user' ); // May be null
if ( user ) {
// Show personalized content
} else {
// Show public content
}
});
```
2025-12-02 09:54:38 +01:00
**API Key Authentication:**
- Add `API_KEY` to `apps/api/src/config/env.ts` environment schema
- Use `Authorization: Bearer <api-key>` header format (standard across popular APIs)
- Use `apiKeyMiddleware` for API-key-only endpoints
- Use `authOrApiKeyMiddleware` for endpoints that accept both user sessions and API keys
2025-11-24 01:00:50 +01:00
### Complete Route Example
Routes should be thin - they handle HTTP concerns and delegate to services:
```typescript
// apps/api/src/routes/organization/organization.ts
import { Hono } from '@api/utils/hono' ;
import { authMiddleware } from '@api/middlewares/auth' ;
import { zValidator } from '@hono/zod-validator' ;
import { createOrganizationWithOwner } from '@api/services/organization' ;
import { handleError } from '@api/utils/errors' ;
import { ZCreateOrg } from '@cio/utils/validation/organization' ;
2025-12-02 15:27:41 +01:00
export const organizationRouter = new Hono (). post (
'/create' ,
authMiddleware ,
zValidator ( 'json' , ZCreateOrg ),
async ( c ) => {
2025-11-24 01:00:50 +01:00
try {
const user = c . get ( 'user' );
const body = c . req . valid ( 'json' );
const result = await createOrganizationWithOwner ( user . id , body );
return c . json ({ success : true , data : result }, 201 );
} catch ( error ) {
return handleError ( c , error , 'Failed to create organization' );
}
2025-12-02 15:27:41 +01:00
}
);
2025-11-24 01:00:50 +01:00
```
**Key Points:**
- Route is ~20 lines - clean and focused
- Single line error handling using `handleError` utility
- Automatically handles `AppError` instances with correct status codes
- No error code → status code mapping needed
- Easy to test service independently
- Route only handles HTTP concerns (validation, auth, response formatting)
### Route Best Practices
#### ✅ DO
- **Validate all inputs** with Zod schemas
- **Call services** for all business logic
- **Use `handleError` utility** in all catch blocks for consistent error responses
- **Provide custom fallback messages** to `handleError` for better error context
- **Include field names** in validation errors (via `AppError` field parameter)
- **Use consistent response format** across all routes
- **Document routes** with JSDoc comments
- **Keep routes thin** - only HTTP concerns (validation, auth, response formatting)
#### ❌ DON'T
- Put business logic in routes (use services)
- Return raw database errors to clients
- Write repetitive error handling code (use `handleError` utility)
- Use `200 OK` for all responses regardless of outcome
- Leave sensitive information in error messages
- Perform heavy computation in route handlers (use background jobs or services)
- Skip input validation "because the frontend validates"
- Use generic error messages like "Something went wrong" (provide context)
- Expose internal implementation details in responses
- Call queries directly from routes (use services)
---
## Creating a New Route: Complete Walkthrough
This section provides a complete end-to-end example of creating a new API route, based on the user profile update implementation.
### Example: User Profile Update Route
We'll create a `PUT /account/user` route that updates a user's profile information.
---
### Step 1: Create Validation Schema
**Location** : `packages/utils/src/validation/{domain}/{entity}.ts`
```typescript
import * as z from 'zod' ;
export const ZUpdateProfile = z . object ({
fullname : z.string (). min ( 5 ). optional (),
username : z.string (). min ( 1 ). optional (),
locale : z.enum ([ 'en' , 'hi' , 'fr' , 'pt' , 'de' , 'vi' , 'ru' , 'es' , 'pl' , 'da' ]). optional ()
});
export type TUpdateProfile = z . infer < typeof ZUpdateProfile >;
```
**Export** : Add to `packages/utils/src/validation/{domain}/index.ts` and `packages/utils/src/validation/index.ts`
---
### Step 2: Create Service Function
**Location** : `apps/api/src/services/{domain}.ts`
```typescript
import { AppError , ErrorCodes } from '@api/utils/errors' ;
import { updateProfile } from '@cio/db/queries/auth' ;
export async function updateUser ( userId : string , data : Partial < TProfile >) {
try {
const updatedProfile = await updateProfile ( userId , data );
if ( ! updatedProfile ) {
throw new AppError ( 'Profile not found' , ErrorCodes . PROFILE_NOT_FOUND , 404 );
}
return updatedProfile ;
} catch ( error ) {
if ( error instanceof AppError ) throw error ;
2025-12-02 15:27:41 +01:00
2025-11-24 01:00:50 +01:00
// Handle constraint violations
if ( error instanceof Error && error . message . includes ( 'profile_username_key' )) {
throw new AppError ( 'Username already exists' , ErrorCodes . VALIDATION_ERROR , 400 , 'username' );
}
2025-12-02 15:27:41 +01:00
2025-11-24 01:00:50 +01:00
throw new AppError ( 'Failed to update profile' , ErrorCodes . PROFILE_UPDATE_FAILED , 500 );
}
}
```
---
### Step 3: Create Route
**Location** : `apps/api/src/routes/{domain}/{entity}.ts`
```typescript
import { updateUser } from '@api/services/account' ;
import { Hono } from '@api/utils/hono' ;
import { ZUpdateProfile } from '@cio/utils/validation/account' ;
import { authMiddleware } from '@api/middlewares/auth' ;
import { handleError } from '@api/utils/errors' ;
import { zValidator } from '@hono/zod-validator' ;
2025-12-02 15:27:41 +01:00
export const accountRouter = new Hono (). put ( '/user' , authMiddleware , zValidator ( 'json' , ZUpdateProfile ), async ( c ) => {
try {
const user = c . get ( 'user' );
const validatedData = c . req . valid ( 'json' );
const updatedProfile = await updateUser ( user . id , validatedData );
2025-11-24 01:00:50 +01:00
2025-12-02 15:27:41 +01:00
return c . json ({ success : true , profile : updatedProfile }, 200 );
} catch ( error ) {
return handleError ( c , error , 'Failed to update profile' );
}
});
2025-11-24 01:00:50 +01:00
```
---
### Step 4: Export and Register Route
```typescript
// apps/api/src/routes/account/index.ts
export * from './account' ;
// apps/api/src/app.ts
import { accountRouter } from '@api/routes/account' ;
2025-12-02 15:27:41 +01:00
export const app = new Hono (). route ( '/account' , accountRouter ); // Route available at PUT /account/user
2025-11-24 01:00:50 +01:00
```
---
2025-12-02 15:27:41 +01:00
### Step 5: Run the api build command (at the root directory)
```
sudo pnpm --filter @cio/api build
```
---
### Step 6: Use in Frontend
2025-11-24 01:00:50 +01:00
**Location** : `apps/dashboard/src/lib/features/{domain}/api/{entity}.svelte.ts`
```typescript
import { ZProfileUpdateForm } from '@cio/utils/validation/account' ;
import { classroomio } from '$lib/utils/services/api' ;
import { mapZodErrorsToTranslations } from '$lib/utils/validation' ;
export class ProfileApi {
isLoading = $state ( false );
errors = $state < Record < string , string >>({});
async submit ( fields : TProfileUpdateForm ) {
// Client-side validation
const result = ZProfileUpdateForm . safeParse ( fields );
if ( ! result . success ) {
this . errors = mapZodErrorsToTranslations ( result . error );
return ;
}
try {
this . isLoading = true ;
2025-12-02 15:27:41 +01:00
2025-11-24 01:00:50 +01:00
// Call API using RPC client (matches route path)
const response = await classroomio . account . user . $put ({
json : result.data
});
2025-12-02 15:27:41 +01:00
2025-11-24 01:00:50 +01:00
const data = await response . json ();
if ( ! data . success ) throw new Error ( data . error );
2025-12-02 15:27:41 +01:00
2025-11-24 01:00:50 +01:00
// Update local state, show success message, etc.
} catch ( error ) {
this . errors . general = error . message ;
} finally {
this . isLoading = false ;
}
}
}
```
---
2025-12-02 09:54:38 +01:00
### Example: Creating an Organization (POST Route)
We'll create a `POST /organization` route that creates a new organization with the current user as owner.
---
### Step 1: Create Validation Schema
**Location** : `packages/utils/src/validation/organization/organization.ts`
```typescript
import * as z from 'zod' ;
export const ZCreateOrganization = z . object ({
name : z
. string ()
. min ( 5 )
. refine (( val ) => ! /^[-]|[-]$/ . test ( val ), {
message : 'Organization name cannot start or end with a hyphen'
}),
siteName : z
. string ()
. min ( 5 )
. refine (( val ) => ! /^[-]|[-]$/ . test ( val ), {
message : 'Site name cannot start or end with a hyphen'
})
});
export type TCreateOrganization = z . infer < typeof ZCreateOrganization >;
```
**Export** : Add to `packages/utils/src/validation/organization/index.ts` and `packages/utils/src/validation/index.ts`
---
### Step 2: Create or Reuse Service Function
**Location** : `apps/api/src/services/organization.ts`
**Option A: Reuse Existing Service** (if function exists in another service)
```typescript
import { createOrganizationWithOwner } from '@api/services/onboarding' ;
export async function createOrg ( profileId : string , data : { name : string ; siteName : string }) {
// Reuse existing function from onboarding service
return await createOrganizationWithOwner ( profileId , {
orgName : data.name ,
siteName : data.siteName
});
}
```
**Option B: Create New Service** (if you need different logic)
```typescript
import { AppError , ErrorCodes } from '@api/utils/errors' ;
import { db } from '@cio/db/drizzle' ;
import {
checkSiteNameExists ,
createOrganization ,
createOrganizationMember ,
getOrganizationByProfileId
} from '@cio/db/queries/organization' ;
import { ROLE } from '@cio/utils/constants' ;
export async function createOrg ( profileId : string , data : { name : string ; siteName : string }) {
// Business Logic: Check sitename availability
const exists = await checkSiteNameExists ( data . siteName );
if ( exists ) {
throw new AppError (
`Site name ' ${ data . siteName } ' already exists` ,
ErrorCodes . SITENAME_EXISTS ,
409 ,
'siteName'
);
}
// Business Logic: Create org and member in a transaction
try {
const result = await db . transaction ( async ( tx ) => {
const organization = await createOrganization ({
name : data.name ,
siteName : data.siteName
});
const member = await createOrganizationMember ({
organizationId : organization.id ,
profileId ,
roleId : ROLE.ADMIN ,
verified : true
});
return { organization , member };
});
// Fetch updated organizations list
const organizations = await getOrganizationByProfileId ( profileId );
return {
organization : result.organization ,
member : result.member ,
organizations
};
} catch ( error ) {
// Handle database constraint violations
if ( error . code === '23505' ) {
throw new AppError (
`Site name ' ${ data . siteName } ' already exists` ,
ErrorCodes . SITENAME_EXISTS ,
409 ,
'siteName'
);
}
throw new AppError ( 'Failed to create organization' , ErrorCodes . ORG_CREATE_FAILED , 500 );
}
}
```
---
### Step 3: Create Route
**Location** : `apps/api/src/routes/organization/organization.ts`
```typescript
import { createOrg } from '@api/services/organization' ;
import { Hono } from '@api/utils/hono' ;
import { ZCreateOrganization } from '@cio/utils/validation/organization' ;
import { authMiddleware } from '@api/middlewares/auth' ;
import { handleError } from '@api/utils/errors' ;
import { zValidator } from '@hono/zod-validator' ;
export const organizationRouter = new Hono ()
. post ( '/' , authMiddleware , zValidator ( 'json' , ZCreateOrganization ), async ( c ) => {
try {
const user = c . get ( 'user' );
const data = c . req . valid ( 'json' );
const result = await createOrg ( user . id , data );
return c . json ({ success : true , data : result }, 201 );
} catch ( error ) {
return handleError ( c , error , 'Failed to create organization' );
}
});
```
**Note** : Use `201 Created` status code for successful POST requests that create resources.
---
### Step 4: Export and Register Route
```typescript
// apps/api/src/routes/organization/index.ts
export * from './organization' ;
// apps/api/src/app.ts
import { organizationRouter } from '@api/routes/organization' ;
export const app = new Hono ()
. route ( '/organization' , organizationRouter ); // Route available at POST /organization
```
---
### Step 5: Use in Frontend
**Location** : `apps/dashboard/src/lib/features/org/api/org.svelte.ts`
```typescript
import { BaseApiWithErrors , classroomio } from '$lib/utils/services/api' ;
import { ZCreateOrganization } from '@cio/utils/validation/organization' ;
import { mapZodErrorsToTranslations } from '$lib/utils/validation' ;
import { orgs , currentOrg } from '$lib/utils/store/org' ;
import { snackbar } from '$lib/components/Snackbar/store' ;
class OrgApi extends BaseApiWithErrors {
async create ( fields : { name : string ; siteName : string }) {
// Client-side validation
const result = ZCreateOrganization . safeParse ( fields );
if ( ! result . success ) {
this . errors = mapZodErrorsToTranslations ( result . error , 'organization' );
return ;
}
await this . execute < typeof classroomio.organization. $ post >({
requestFn : () =>
classroomio . organization . $post ({
json : result.data
}),
logContext : 'creating organization' ,
onSuccess : ( response ) => {
if ( ! response . data ) return ;
const { organization , organizations } = response . data ;
// Update stores
orgs . set ( organizations );
currentOrg . set ( organization );
snackbar . success ( 'Organization created successfully' );
this . success = true ;
this . errors = {};
},
onError : ( result ) => {
if ( typeof result === 'string' ) {
snackbar . error ( result );
return ;
}
if ( 'error' in result && 'field' in result ) {
// Field-specific error automatically mapped to this.errors[field]
this . errors [ result . field as string ] = result . error ;
}
}
});
}
}
export const orgApi = new OrgApi ();
```
**Key Points:**
- Use `this.execute()` from `BaseApiWithErrors` for automatic loading/error state
- Field-specific errors are automatically mapped to `this.errors[field]`
- Update stores in `onSuccess` callback
- Handle both validation and API errors
---
### Reusing Existing Service Functions
Sometimes a service function exists in a different context (e.g., `createOrganizationWithOwner` in onboarding service).
**When to Reuse:**
- ✅ The function does exactly what you need
- ✅ It's in the same domain (organization operations)
- ✅ You can call it from your new route without modification
**When to Create New:**
- ❌ The existing function has domain-specific logic you don't want (e.g., onboarding-specific)
- ❌ You need different parameters or return values
- ❌ The function is in a different domain and mixing concerns would be inappropriate
**Example: Reusing a Service**
```typescript
// apps/api/src/services/organization.ts
import { createOrganizationWithOwner } from '@api/services/onboarding' ;
export async function createOrg ( profileId : string , data : { name : string ; siteName : string }) {
// Reuse existing function, just adapt the parameters
return await createOrganizationWithOwner ( profileId , {
orgName : data.name ,
siteName : data.siteName
});
}
```
---
2025-11-24 01:00:50 +01:00
### Route Path Convention
When adding routes to an existing router:
- **GET `/` ** - Fetch/list resources (e.g., `GET /account` - get account data)
- **GET `/{id}` ** - Fetch single resource (e.g., `GET /course/{id}` )
- **POST `/` ** - Create resource (e.g., `POST /course` - create course)
- **PUT `/{resource}` ** - Update resource (e.g., `PUT /account/user` - update user)
- **DELETE `/{id}` ** - Delete resource (e.g., `DELETE /course/{id}` )
**Example router with multiple routes** :
```typescript
export const accountRouter = new Hono ()
. get ( '/' , authMiddleware , async ( c ) => {
// GET /account - get account data
})
. put ( '/user' , authMiddleware , zValidator ( 'json' , ZUpdateProfile ), async ( c ) => {
// PUT /account/user - update user profile
})
. put ( '/password' , authMiddleware , zValidator ( 'json' , ZChangePassword ), async ( c ) => {
// PUT /account/password - change password
});
```
---
### Quick Reference Checklist
When creating a new route, follow this checklist:
1. **Validation Schema** (`packages/utils/src/validation/` )
- [ ] Create Zod schema with validation rules
- [ ] Export schema and types
- [ ] Add to validation index
2. **Service** (`apps/api/src/services/` )
- [ ] Create service function with business logic
- [ ] Use `AppError` for structured errors
- [ ] Handle database constraint violations
- [ ] Add JSDoc comments
3. **Route** (`apps/api/src/routes/` )
- [ ] Create route handler
- [ ] Use `zValidator` for input validation
- [ ] Apply `authMiddleware` if needed
- [ ] Use `handleError` for error handling
- [ ] Return consistent response format
4. **Registration**
- [ ] Export router in index file
- [ ] Register route in `app.ts`
5. **Frontend** (`apps/dashboard/src/lib/features/` )
- [ ] Create API abstraction class (`.svelte.ts` )
- [ ] Use shared validation schema
- [ ] Use RPC client for API calls
- [ ] Map errors to translations
- [ ] Update local state after success
---
2025-12-02 09:54:38 +01:00
## Migrating Existing Supabase Calls to API Routes
When you have existing components using direct Supabase calls, follow this migration pattern to move them to the API architecture.
### Migration Workflow
#### Step 1: Identify the Operation
Analyze the existing Supabase call:
- **What database operation?** (CREATE, UPDATE, DELETE, SELECT)
- **What tables are involved?**
- **Are there related operations?** (e.g., create org + create member should be atomic)
- **What validation is currently done?** (client-side only or also server-side?)
**Example Analysis:**
```typescript
// Current code
const { data : org , error } = await supabase
. from ( 'organization' )
. insert ({ name : orgName , siteName : siteName })
. select ();
const { data , error } = await supabase
. from ( 'organizationmember' )
. insert ({ organization_id : org [ 0 ]. id , profile_id : $profile.id , role_id : 1 });
// Analysis:
// - Operation: CREATE (organization + member)
// - Tables: organization, organizationmember
// - Related: Yes, should be atomic (transaction)
// - Validation: Client-side only (needs server-side too)
```
#### Step 2: Check for Existing Services
Search for existing service functions that might handle this:
- Check `apps/api/src/services/` for similar operations
- Look in related domains (e.g., onboarding might have org creation)
- Check if the operation exists but in a different context
**Decision:**
- **Reuse existing service** if it does what you need
- **Create new service** if you need different logic or parameters
#### Step 3: Create/Update API Route
If the route doesn't exist:
- Follow the "Creating a New Route" workflow above
- Use appropriate HTTP method: `POST` (create), `PUT` (update), `DELETE` (delete)
If the route exists:
- Add new endpoint to existing router
- Follow route path conventions
#### Step 4: Create Frontend API Method
Add method to existing API class (e.g., `orgApi.create()` ):
```typescript
// apps/dashboard/src/lib/features/org/api/org.svelte.ts
class OrgApi extends BaseApiWithErrors {
async create ( fields : { name : string ; siteName : string }) {
// Client-side validation
const result = ZCreateOrganization . safeParse ( fields );
if ( ! result . success ) {
this . errors = mapZodErrorsToTranslations ( result . error , 'organization' );
return ;
}
await this . execute < typeof classroomio.organization. $ post >({
requestFn : () =>
classroomio . organization . $post ({
json : result.data
}),
logContext : 'creating organization' ,
onSuccess : ( response ) => {
// Update stores, navigate, show success
if ( response . data ) {
orgs . set ( response . data . organizations );
currentOrg . set ( response . data . organization );
}
snackbar . success ( 'Organization created' );
this . success = true ;
},
onError : ( result ) => {
// Error handling (automatic for validation errors)
if ( typeof result === 'string' ) {
snackbar . error ( result );
}
}
});
}
}
```
**Key Points:**
- Always use `this.execute()` from `BaseApiWithErrors`
- Client-side validation before API call
- Update stores in `onSuccess` callback
- Errors automatically handled by `BaseApiWithErrors`
#### Step 5: Update Component
Replace Supabase calls with API class method:
**Before (Direct Supabase):**
```typescript
< script lang = "ts" >
import { supabase } from '$lib/utils/functions/supabase' ;
import { profile } from '$lib/utils/store/user' ;
async function createNewOrg() {
// Create organization
const { data : org , error } = await supabase
. from ( 'organization' )
. insert ({ name : orgName , siteName })
. select ();
if ( error ) {
errors . siteName = error . message ;
return ;
}
// Create member (separate call - not atomic!)
const { data , error : memberError } = await supabase
. from ( 'organizationmember' )
. insert ({
organization_id : org [ 0 ]. id ,
profile_id : $profile.id ,
role_id : 1
})
. select ();
if ( memberError ) {
// Manual cleanup required
await supabase . from ( 'organization' ). delete (). match ({ siteName });
errors . siteName = 'Failed to create member' ;
return ;
}
// Manual store updates
await getOrganizations ( $profile . id );
goto ( `/org/ ${ siteName } ` );
}
</ script >
```
**After (API Route):**
```typescript
< script lang = "ts" >
import { orgApi } from '$lib/features/org/api/org.svelte' ;
import { goto } from '$app/navigation' ;
async function createNewOrg() {
// Client-side validation (optional, for instant feedback)
errors = createOrgValidation ({ orgName , siteName }) as Error ;
if ( Object . values ( errors ). length ) {
return ;
}
// Call API
await orgApi . create ({ name : orgName , siteName });
if ( orgApi . success ) {
// Success - stores already updated by orgApi
goto ( `/org/ ${ siteName } ` );
$newOrgModal . open = false ;
resetForm ();
} else {
// Errors automatically mapped to orgApi.errors
if ( orgApi . errors . siteName ) {
errors . siteName = orgApi . errors . siteName ;
} else if ( orgApi . errors . general ) {
errors . general = orgApi . errors . general ;
}
}
}
</ script >
```
**Key Differences:**
- ✅ **No manual transaction cleanup** - Handled by service layer
- ✅ **No separate member creation** - Handled atomically by service
- ✅ **Automatic store updates** - Handled by API class `onSuccess`
- ✅ **Consistent error structure** - Field-specific errors automatically mapped
- ✅ **Type-safe** - Full TypeScript support end-to-end
- ✅ **Server-side validation** - Always validated even if client validation passes
### Mapping API Errors to Form Fields
The `BaseApiWithErrors` class automatically maps field-specific errors from the API response:
**API Response Structure:**
```typescript
// Field-specific error
{
success : false ,
error : "Sitename already exists" ,
code : "SITENAME_EXISTS" ,
field : "siteName" // ← This maps to this.errors.siteName
}
// General error
{
success : false ,
error : "Failed to create organization" ,
code : "ORG_CREATE_FAILED"
// No field property → maps to this.errors.general
}
```
**In Component:**
```typescript
await orgApi . create ({ name : orgName , siteName });
if ( ! orgApi . success ) {
// Field-specific error automatically in orgApi.errors.siteName
if ( orgApi . errors . siteName ) {
errors . siteName = orgApi . errors . siteName ;
}
// General error automatically in orgApi.errors.general
if ( orgApi . errors . general ) {
errors . general = orgApi . errors . general ;
}
}
```
**How It Works:**
1. API returns error with `field` property
2. `BaseApiWithErrors.execute()` automatically sets `this.errors[field] = error`
3. Component can access `orgApi.errors.fieldName` directly
4. No manual error mapping needed
### Migration Checklist
When migrating a component from Supabase to API:
- [ ] **Identify operations** : List all Supabase calls in component
- [ ] **Check existing routes** : Search `apps/api/src/routes/` for existing API routes
- [ ] **Check existing services** : Search `apps/api/src/services/` for reusable functions
- [ ] **Create missing pieces** : Follow "Creating a New Route" if route doesn't exist
- [ ] **Create API method** : Add method to appropriate API class using `execute()` pattern
- [ ] **Update component** : Replace Supabase calls with API method
- [ ] **Remove cleanup code** : Delete manual rollback/cleanup (handled by service transactions)
- [ ] **Remove separate calls** : Combine related operations (handled atomically by service)
- [ ] **Update error handling** : Use `orgApi.errors.fieldName` instead of Supabase error structure
- [ ] **Update store sync** : Remove manual store updates (handled by API class `onSuccess` )
- [ ] **Remove Supabase imports** : Delete unused `supabase` imports
- [ ] **Test** : Verify success and error cases work correctly
- [ ] **Test transactions** : Verify atomic operations (e.g., org + member creation)
---
2025-11-24 01:00:50 +01:00
## Step 4: Export and Register Route
### Export Router
```typescript
// apps/api/src/routes/onboarding/index.ts
export * from './onboarding' ;
```
### Register in Main App
```typescript
// apps/api/src/app.ts
import { onboardingRouter } from '@api/routes/onboarding' ;
export const app = new Hono ()
// ... middleware ...
2025-12-02 15:27:41 +01:00
2025-11-24 01:00:50 +01:00
// Routes
. route ( '/account' , accountRouter )
2025-12-02 15:27:41 +01:00
. route ( '/onboarding' , onboardingRouter ) // Add new route
2025-11-24 01:00:50 +01:00
. route ( '/course' , courseRouter )
2025-12-02 15:27:41 +01:00
. route ( '/mail' , mailRouter );
// ... error handling ...
2025-11-24 01:00:50 +01:00
```
---
2025-12-02 15:27:41 +01:00
### Step 5: Run the api build command (at the root directory)
```
sudo pnpm --filter @cio/api build
```
---
## Step 6: Update Frontend
2025-11-24 01:00:50 +01:00
**Location** : `apps/dashboard/src/lib/utils/services/api.ts` (or similar)
### Setup Hono RPC Client
The frontend uses Hono's RPC client for type-safe API calls:
```typescript
// apps/dashboard/src/lib/utils/services/api.ts
import { hc } from 'hono/client' ;
import type { app } from '@api/app' ;
export const classroomio = hc < typeof app >( import . meta . env . VITE_API_URL || 'http://localhost:3000' , {
credentials : 'include' , // Important: sends cookies for authentication
headers : {
'Content-Type' : 'application/json'
}
});
```
### Using API in Components
Replace Supabase direct database calls with API calls:
```typescript
// Before: Direct Supabase call
const { data , error } = await supabase
. from ( 'organization' )
. insert ({
name : fields.orgName ,
siteName : fields.siteName
})
. select ();
// After: Type-safe API call
try {
const response = await classroomio . onboarding . step1 . $post ({
json : {
fullname : fields.fullname ,
orgName : fields.orgName ,
siteName : fields.siteName
}
});
const data = await response . json ();
if ( ! data . success ) {
// Handle error
errors . siteName = data . error ;
return ;
}
// Success - use data.data.organization, data.data.member, etc.
const { organization , member , organizations } = data . data ;
} catch ( error ) {
console . error ( 'API call failed:' , error );
// Handle network error
}
```
### Complete Frontend Example
Example with **client-side validation** before API call:
```typescript
// apps/dashboard/src/routes/course/+page.svelte
< script lang = "ts" >
import { classroomio } from '$lib/utils/services/api' ;
import { ZCourseCreate } from '@cio/utils/validation/course' ;
import { mapZodErrorsToTranslations } from '$lib/utils/validation' ;
import { goto } from '$app/navigation' ;
let fields = $state ({ title : '' , slug : '' , description : '' });
let errors = $state < Record < string , string >>({});
let loading = $state ( false );
const handleSubmit = async () => {
loading = true ;
errors = {};
// Client-side validation with entity-specific translations
const result = ZCourseCreate . safeParse ( fields );
2025-12-02 15:27:41 +01:00
2025-11-24 01:00:50 +01:00
if ( ! result . success ) {
// Map validation errors to translated messages
errors = mapZodErrorsToTranslations ( result . error , 'course' );
// errors.title = "Course title is required"
// errors.slug = "Course slug is required"
loading = false ;
return ;
}
try {
const response = await classroomio . course . create . $post ({
json : result.data // Send validated data
});
const data = await response . json ();
if ( ! data . success ) {
// Handle API error (e.g., duplicate slug)
if ( data . field ) {
errors [ data . field ] = data . error ;
} else {
errors . general = data . error ;
}
loading = false ;
return ;
}
// Success - redirect to course
const { course } = data . data ;
await goto ( `/courses/ ${ course . slug } ` );
2025-12-02 15:27:41 +01:00
2025-11-24 01:00:50 +01:00
} catch ( error ) {
console . error ( 'Error creating course:' , error );
errors . general = 'Network error. Please try again.' ;
} finally {
loading = false ;
}
};
</ script >
< form onsubmit = { handleSubmit }>
< div >
< label for = "title" > Course Title </ label >
< input
id = "title"
bind : value = { fields . title }
class : error = { errors . title }
/>
{ # if errors . title }
< span class = "error-message" >{ errors . title }</ span >
{ /if}
</ div >
< div >
< label for = "slug" > Course Slug </ label >
< input
id = "slug"
bind : value = { fields . slug }
class : error = { errors . slug }
/>
{ # if errors . slug }
< span class = "error-message" >{ errors . slug }</ span >
{ /if}
</ div >
< button type = "submit" disabled = { loading }>
{ loading ? 'Creating...' : 'Create Course' }
</ button >
</ form >
```
2025-12-02 09:54:38 +01:00
### API Abstraction Pattern (API Classes)
When creating API abstractions in `apps/dashboard/src/lib/features/{domain}/api/{entity}.svelte.ts` , **always use the `execute` method** from `BaseApi` or `BaseApiWithErrors` classes. This provides automatic loading state management, error handling, and consistent response processing.
**Important** :
- **`.svelte.ts` files** are for **client-side use only** (Svelte components). They use Svelte reactivity (`$state` , `$derived` ) and manage component state. **Never** include API key logic in these files.
- **`.server.ts` files** are for **server-side use only** (`+layout.server.ts` , `+page.server.ts` , `+server.ts` ). They contain static methods with API key authentication and are automatically excluded from client bundles by SvelteKit.
#### Using the `execute` Method
**Pattern:**
```typescript
import { BaseApiWithErrors , classroomio } from '$lib/utils/services/api' ;
class MyApi extends BaseApiWithErrors {
async updateItem ( id : string , data : Partial < Item >) {
await this . execute < typeof classroomio.items [ ':id' ][ '$put' ] >({
requestFn : () => classroomio . items [ ':id' ]. $put ({
param : { id },
json : data
}),
logContext : 'updating item' ,
onSuccess : ( response ) => {
// Update local stores with response.data
itemsStore . update (( items ) => {
const index = items . findIndex (( i ) => i . id === id );
if ( index !== - 1 && response . data ) {
items [ index ] = { ... items [ index ], ... response . data };
}
return items ;
});
},
onError : ( result ) => {
// Optional: Custom error handling
if ( typeof result === 'string' ) {
snackbar . error ( result );
return ;
}
if ( 'error' in result ) {
this . handleValidationError ( result );
}
}
});
}
}
export const myApi = new MyApi ();
```
**Key Points:**
- **Always use `this.execute` ** - Never call the RPC client directly
- **Type the execute call** with `typeof classroomio.{route}.{method}` for type safety
- **Use `requestFn` ** - Wrap the RPC call in a function
- **Use `logContext` ** - Provide descriptive context for error logging
- **Use `onSuccess` ** - Update local stores/reactivity with response data
- **Use `onError` ** - Optional custom error handling (validation errors are handled automatically by `BaseApiWithErrors` )
**Examples:**
```typescript
// Profile API - Simple update
await this . execute < typeof classroomio.account.user. $ put >({
requestFn : () => classroomio . account . user . $put ({ json : profileUpdates }),
logContext : 'updating profile' ,
onSuccess : ( response ) => {
profileStore . update (( _profile ) => ({
... _profile ,
... response . profile
}));
}
});
// Onboarding API - With error handling
await this . execute < typeof classroomio.onboarding.step1. $ post >({
requestFn : () => classroomio . onboarding . step1 . $post ({ json : data }),
logContext : 'submitting organization setup' ,
onSuccess : ( result ) => {
const { organizations } = result . data ;
orgs . set ( organizations );
currentOrg . set ( organizations [ 0 ]);
this . errors = {};
this . step = ONBOARDING_STEPS . USER_METADATA ;
},
onError : ( result ) => {
if ( typeof result === 'string' ) {
snackbar . error ( result );
return ;
}
if ( 'error' in result ) {
this . handleValidationError ( result );
}
}
});
```
**Benefits:**
- ✅ Automatic loading state management (`this.isLoading` )
- ✅ Automatic error state management (`this.error` or `this.errors` )
- ✅ Consistent error handling across all API calls
- ✅ Type-safe responses
- ✅ Centralized logging
- ✅ Easy to test and mock
### Server-Side API Calls
**For server-side files** (`+layout.server.ts` , `+page.server.ts` , `+server.ts` ), use ** `.server.ts` API classes** instead of calling the RPC client directly. This prevents API keys from being accidentally bundled into client-side code.
#### Pattern for Server-Side Files
Create a `.server.ts` file alongside your `.svelte.ts` API class with static methods that use API key authentication:
```typescript
// apps/dashboard/src/lib/features/org/api/org.server.ts
import { classroomio } from '$lib/utils/services/api' ;
import { getApiKeyHeaders } from '$lib/utils/services/api/server' ;
/**
* Server-side API methods for organization operations
* These methods use API key authentication and should only be used in server-side files
* (+server.ts, +layout.server.ts) to prevent API keys from being exposed to the client.
*/
export class OrgApiServer {
/**
* Gets organization by siteName (server-side)
* @param siteName Organization site name
* @returns Organization or null
*/
static async getOrgBySiteName ( siteName : string ) {
try {
const response = await classroomio . organization . $get (
{
query : { siteName }
},
getApiKeyHeaders ()
);
const data = await response . json ();
return data . success && data . data && data . data . length > 0 ? data . data [ 0 ] : null ;
} catch ( error ) {
console . error ( 'Error fetching organization by siteName (server):' , error );
return null ;
}
}
}
```
**Usage in server-side files:**
```typescript
// apps/dashboard/src/routes/invite/t/[hash]/+layout.server.ts
import { OrgApiServer } from '$lib/features/org/api/org.server' ;
export const load = async ({ params }) => {
// Use static method from .server.ts class
const org = await OrgApiServer . getOrgBySiteName ( orgSiteName );
return { org };
};
```
#### API Key Authentication for Webhooks/External Calls
For server-to-server communication (webhooks, external integrations), create `.server.ts` files with static methods:
```typescript
// apps/dashboard/src/lib/features/org/api/org-plan.server.ts
import { classroomio } from '$lib/utils/services/api' ;
import { getApiKeyHeaders } from '$lib/utils/services/api/server' ;
import type { TCreateOrgPlan } from '@cio/utils/validation/organization' ;
/**
* Server-side API methods for organization plan operations
* These methods use API key authentication and should only be used in server-side files
* (+server.ts, +layout.server.ts) to prevent API keys from being exposed to the client.
*/
export class OrgPlanApiServer {
/**
* Creates a new organization plan (server-side)
* @param params Organization plan creation parameters
* @returns Response data or null on error
*/
static async createOrgPlan ( params : TCreateOrgPlan ) {
try {
const response = await classroomio . organization . plan . $post (
{
json : params
},
getApiKeyHeaders ()
);
const data = await response . json ();
return data . success ? data.data : null ;
} catch ( error ) {
console . error ( 'Error creating org plan (server):' , error );
return null ;
}
}
}
```
**Usage in webhook handlers:**
```typescript
// apps/dashboard/src/routes/api/polar/webhook/+server.ts
import { OrgPlanApiServer } from '$lib/features/org/api/org-plan.server' ;
// Use static method from .server.ts class
const result = await OrgPlanApiServer . createOrgPlan ( planData );
```
#### File Naming Convention
- **`.svelte.ts` **: Client-side API classes (for Svelte components)
- Example: `org.svelte.ts` , `org-plan.svelte.ts`
- Uses Svelte reactivity (`$state` , `$derived` )
- Manages component state and loading/error states
- **Never** contains API key logic
- **`.server.ts` **: Server-side API classes (for server-side files)
- Example: `org.server.ts` , `org-plan.server.ts`
- Contains static methods with API key authentication
- **Only** used in `+layout.server.ts` , `+page.server.ts` , `+server.ts` files
- Automatically excluded from client bundles by SvelteKit
#### API Key Setup
1. Add `API_KEY` to `apps/api/src/config/env.ts` environment schema
2. Set `API_KEY` environment variable in your deployment
3. Use `authOrApiKeyMiddleware` in API routes that accept both user sessions and API keys
4. Create `.server.ts` files with static methods that use `getApiKeyHeaders()` helper
**Helper Function** : Use `getApiKeyHeaders()` from `$lib/utils/services/api/server` to get API key headers. This helper:
- Returns the correct headers format for RPC client
- Validates that `API_KEY` is configured
- Provides consistent API key authentication across all server-side files
#### Why This Pattern?
- ✅ **Security** : API keys are isolated in `.server.ts` files that cannot be imported on the client
- ✅ **Clear separation** : Client-side (`.svelte.ts` ) vs server-side (`.server.ts` ) is explicit
- ✅ **Type-safe** : Full TypeScript support end-to-end
- ✅ **Consistent** : Same API routes, same validation, same error handling
- ✅ **Maintainable** : Centralized server-side API methods in dedicated files
- ✅ **SvelteKit convention** : `.server.ts` files are automatically excluded from client bundles
2025-11-24 01:00:50 +01:00
### Frontend Best Practices
#### ✅ DO
- **Use type-safe RPC client** for API calls (Hono RPC)
2025-12-02 09:54:38 +01:00
- **Always use `execute` method** in API abstraction classes (never call RPC client directly)
- **Use `.server.ts` API classes** in server-side files (`+layout.server.ts` , `+page.server.ts` , `+server.ts` )
- **Create `.server.ts` files** with static methods for server-side API calls with API key authentication
- **Use API key** for server-to-server communication (webhooks, external calls)
2025-11-24 01:00:50 +01:00
- **Validate client-side** before API calls using shared schemas from `@cio/utils/validation`
- **Use entity-specific translations** when multiple entities share field names
```typescript
2025-12-02 15:27:41 +01:00
mapZodErrorsToTranslations(error, 'course'); // "Course title is required"
2025-11-24 01:00:50 +01:00
` ``
- **Provide generic translations** as fallbacks for shared fields (email, password)
- **Handle both API errors and network errors** separately
- **Show specific field errors** when validation or API returns them
- **Keep UI responsive** during API calls (loading states)
- **Optimistically update UI** when appropriate
- **Handle different HTTP status codes** appropriately
- **Show user-friendly, translated error messages**
#### ❌ DON'T
2025-12-02 09:54:38 +01:00
- **Call RPC client directly** in API classes (always use ` execute` method)
- **Call RPC client directly in server-side files** - use ` .server.ts` API classes instead
- **Use ` .svelte.ts` API classes in server-side files** - they're for client-side Svelte components only
- **Include API key logic in ` .svelte.ts` files** - API keys must only be in ` .server.ts` files
2025-11-24 01:00:50 +01:00
- Skip error handling assuming API calls always succeed
- Display raw API error messages without formatting
- Skip client-side validation (validate before API call for instant feedback)
- Hardcode error messages in schemas (use translation system)
- Use the same generic message for all entities (use entity-specific when needed)
- Make multiple simultaneous calls that should be atomic
- Store sensitive data in frontend state longer than necessary
- Retry failed requests automatically without user action (for mutations)
- Trust client-side validation alone (API always validates too)
---
## Public API Considerations
Since routes may become public APIs, consider these additional aspects:
### 1. **API Versioning**
Prepare for versioning from the start:
` ``typescript
// Current approach (v1 implied)
.route('/account', accountRouter)
// When versioning becomes necessary
.route('/v1/account', accountRouterV1)
.route('/v2/account', accountRouterV2)
` ``
### 2. **Rate Limiting**
Already implemented globally, but consider per-route limits:
` ``typescript
import rateLimiter from '@api/middlewares/rate-limiter';
// Higher rate limit for read operations
.get('/courses', rateLimiter({ limit: 100, window: 60000 }), async (c) => {})
// Lower rate limit for write operations
.post('/create', rateLimiter({ limit: 10, window: 60000 }), async (c) => {})
` ``
### 3. **API Documentation**
Add JSDoc comments to document your routes:
` ``typescript
/**
* POST /api/onboarding/create-org
* Creates organization during onboarding
2025-12-02 15:27:41 +01:00
*
2025-11-24 01:00:50 +01:00
* @requires Authentication
* @returns {201} Created - Organization created successfully
* @returns {409} Conflict - Sitename already exists
*/
.post('/create-org', authMiddleware, zValidator('json', createOrgSchema), async (c) => {
// ... implementation
});
` ``
### 4. **Pagination Standards**
For list endpoints, use consistent pagination:
` ``typescript
const paginationSchema = z.object({
page: z.string().regex(/^\d+$/).transform(Number).pipe(z.number().min(1)).default('1'),
limit: z.string().regex(/^\d+$/).transform(Number).pipe(z.number().min(1).max(100)).default('20'),
sort: z.enum(['asc', 'desc']).default('desc'),
sortBy: z.string().default('createdAt')
});
.get('/list', zValidator('query', paginationSchema), async (c) => {
const { page, limit, sort, sortBy } = c.req.valid('query');
2025-12-02 15:27:41 +01:00
2025-11-24 01:00:50 +01:00
const offset = (page - 1) * limit;
const items = await getItems({ offset, limit, sort, sortBy });
const total = await getItemsCount();
2025-12-02 15:27:41 +01:00
2025-11-24 01:00:50 +01:00
return c.json({
success: true,
data: items,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
hasMore: page * limit < total
}
});
});
` ``
### 5. **Filtering and Search**
Standard query parameter naming:
` ``typescript
const searchSchema = z.object({
q: z.string().optional(), // General search query
filter: z.string().optional(), // Specific field filter (JSON or comma-separated)
status: z.enum(['active', 'inactive', 'archived']).optional(),
startDate: z.string().datetime().optional(),
endDate: z.string().datetime().optional()
});
` ``
### 6. **Security**
Ensure the following (already configured globally in ` app.ts`):
- CORS properly configured for trusted origins
- Security headers set
- Rate limiting appropriate for endpoint
- Authentication enforced where needed
---
## Testing Routes
### Unit Testing Queries
` ``typescript
// packages/db/src/queries/__tests__/organization.test.ts
import { describe, it, expect } from 'vitest';
import { createOrganization, checkSiteNameExists } from '../organization';
describe('Organization Queries', () => {
it('should create an organization', async () => {
const org = await createOrganization({
name: 'Test Org',
siteName: 'test-org'
});
expect(org).toHaveProperty('id');
expect(org.name).toBe('Test Org');
});
it('should check if sitename exists', async () => {
await createOrganization({ name: 'Test Org', siteName: 'test-org' });
expect(await checkSiteNameExists('test-org')).toBe(true);
expect(await checkSiteNameExists('other-org')).toBe(false);
});
});
` ``
### Integration Testing Routes
` ``typescript
// apps/api/src/routes/__tests__/onboarding.test.ts
import { describe, it, expect } from 'vitest';
import { app } from '@api/app';
describe('Onboarding Routes', () => {
it('should create organization with valid data', async () => {
const res = await app.request('/onboarding/create-org', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
fullname: 'John Doe',
orgName: 'Test School',
siteName: 'test-school'
})
});
expect(res.status).toBe(201);
const data = await res.json();
expect(data.success).toBe(true);
});
it('should return 409 for duplicate sitename', async () => {
// Create first, then attempt duplicate
const res = await app.request('/onboarding/create-org', {
method: 'POST',
2025-12-02 15:27:41 +01:00
body: JSON.stringify({
/* duplicate data */
})
2025-11-24 01:00:50 +01:00
});
expect(res.status).toBe(409);
expect((await res.json()).code).toBe('SITENAME_EXISTS');
});
});
` ``
---
## Checklist for New Routes
Before marking a route as complete, ensure:
- [ ] **Queries Layer**
- [ ] Queries are in appropriate ` packages/db/src/queries/{domain}` folder
- [ ] All queries are pure functions (no side effects)
- [ ] Queries use Drizzle ORM with type inference
- [ ] **Use exported types from ` @cio/db/types `** (` TNew{Table}`, ` T{Table}`)
- [ ] Use ` TNew{Table}` for creates, ` Partial<T{Table}>` for updates
- [ ] No runtime validation in queries (validation happens at API route layer)
- [ ] JSDoc comments added to all exported functions
- [ ] Queries are exported in index files
- [ ] **Service Layer**
- [ ] Service file created in ` apps/api/src/services/{domain}.ts`
- [ ] Business logic moved from routes to services
- [ ] ` AppError` used with appropriate error codes and status codes
- [ ] Transactions handled in services
- [ ] Business rules validated (e.g., uniqueness checks)
- [ ] Services return structured data (not HTTP responses)
- [ ] JSDoc comments explaining business logic
- [ ] Services exported in index files
- [ ] **Route Layer**
- [ ] Route file created in ` apps/api/src/routes/{domain}`
- [ ] All inputs validated with Zod schemas
- [ ] **Validation schemas defined in ` packages/utils/src/validation/{entity}/`**
- [ ] Schemas imported from ` @cio/utils/validation/ {entity}` in route handlers
- [ ] Schemas are clean (no hardcoded error messages)
- [ ] Translation keys documented in schema comments
- [ ] Authentication middleware applied where needed
- [ ] Routes call services (not queries directly)
- [ ] **` handleError` utility used in all catch blocks**
- [ ] Custom fallback messages provided to ` handleError` for context
- [ ] Consistent response format used (` { success: true/false, data/error, code? }`)
- [ ] Route documented with JSDoc comments
- [ ] **Registration**
- [ ] Router exported in route's ` index.ts`
- [ ] Route registered in main ` app.ts`
- [ ] **Frontend**
- [ ] RPC client calls replace direct database calls
- [ ] Shared validation schemas imported from ` @cio/utils/validation `
- [ ] Client-side validation before API calls
- [ ] Entity-specific translations used when needed (e.g., ` mapZodErrorsToTranslations(error, 'course')`)
- [ ] Generic translations provided as fallbacks
- [ ] Translation keys follow convention: ` validations.{entity}.{field}.{errorCode}`
- [ ] Error handling for both validation, API, and network errors
- [ ] Loading states implemented
- [ ] Field-specific errors displayed
- [ ] User-friendly, translated error messages shown
- [ ] **Public API Ready**
- [ ] Route can work independently of frontend
- [ ] No sensitive information exposed in responses
- [ ] Rate limiting appropriate for endpoint
- [ ] Documentation complete and accurate
- [ ] Versioning strategy considered
- [ ] **Testing** (Optional but recommended)
- [ ] Query unit tests written
- [ ] Route integration tests written
- [ ] Error cases tested
- [ ] Authentication/authorization tested
---
## Common Patterns
### Transactional Operations
` ``typescript
const result = await db.transaction(async (tx) => {
const org = await createOrganization({ name, siteName });
const member = await createOrganizationMember({ orgId: org.id, userId });
return { org, member };
});
` ``
### Authorization Checks
` ``typescript
.patch('/update/:id', authMiddleware, async (c) => {
const id = c.req.param('id');
const user = c.get('user');
const hasAccess = await checkUserAccessToOrg(user.id, id);
if (!hasAccess) {
return c.json({ success: false, error: 'Forbidden' }, 403);
}
const updated = await updateOrganization(id, updates);
return c.json({ success: true, data: updated }, 200);
});
` ``
### Bulk Operations
` ``typescript
.post('/bulk', authMiddleware, zValidator('json', z.object({
items: z.array(itemSchema).min(1).max(100)
})), async (c) => {
const { items } = c.req.valid('json');
2025-12-02 15:27:41 +01:00
2025-11-24 01:00:50 +01:00
const results = await db.transaction(async (tx) => {
return await Promise.all(items.map(item => createItem(item)));
});
2025-12-02 15:27:41 +01:00
2025-11-24 01:00:50 +01:00
return c.json({ success: true, data: results }, 201);
});
` ``
---
## Summary
This four-layer architecture ensures:
### **Layer Separation**
1. **Shared Validation Layer**: Zod schemas shared between API and frontend
2. **Routes (HTTP Layer)**: Handle requests/responses, validation, authentication
3. **Services (Business Logic)**: Orchestrate queries, manage transactions, business rules
4. **Queries (Data Layer)**: Pure database operations, type-safe
5. **Frontend (UI Layer)**: Type-safe API consumption via RPC with client-side validation
### **Benefits**
1. **Maintainability**: Clear separation of concerns across all layers
2. **Testability**: Each layer can be tested independently
3. **Reusability**: Queries and services can be used across multiple routes
4. **Type Safety**: End-to-end type safety from database to frontend
5. **Public API Ready**: Routes designed to be exposed as public APIs
6. **Consistency**: Standardized response formats and error handling
7. **Security**: Authentication, validation, and proper error handling
8. **Scalability**: Easy to add new features following established patterns
9. **Developer Experience**: Type-safe RPC client, clear error messages
### **Example Flow**
` ``txt
Frontend Form (validates with @cio/utils/validation)
↓
Frontend Request (with validated data)
↓
Route (validates input again with @cio/utils/validation, checks auth)
↓
Service (business logic, transactions)
↓
Queries (database operations)
↓
Database
↓
Queries (return type-safe data)
↓
Service (return structured results)
↓
Route (format HTTP response)
↓
Frontend Response (maps errors to translations if validation failed)
` ``
By following these patterns, our API remains **consistent** , **secure** , **testable** , and **ready for public consumption** .