API Architecture
Learn about our backend architecture and API patterns.
Architecture Pattern
We follow a layered architecture with clear separation of concerns:
βββββββββββββββββββββββ
β React Component β UI Layer
ββββββββββββ¬βββββββββββ
β
ββββββββββββΌβββββββββββ
β Client API Func β API Client Layer
ββββββββββββ¬βββββββββββ
β
ββββββββββββΌβββββββββββ
β Next.js Route β HTTP Layer
ββββββββββββ¬βββββββββββ
β
ββββββββββββΌβββββββββββ
β Service β Business Logic Layer
ββββββββββββ¬βββββββββββ
β
ββββββββββββΌβββββββββββ
β Repository β Data Access Layer
ββββββββββββ¬βββββββββββ
β
ββββββββββββΌβββββββββββ
β Database β Persistence Layer
βββββββββββββββββββββββLayer Breakdown
1. React Components (UI Layer)
Location: apps/{web,cxc}/components/, apps/{web,cxc}/app/
Purpose: Display UI and handle user interactions
Example:
"use client";
import { useState, useEffect } from "react";
import { getSystemHealth } from "@/lib/api";
import { Card, CardContent } from "@uwdsc/ui";
export function HealthStatus() {
const [health, setHealth] = useState(null);
useEffect(() => {
async function fetchHealth() {
const healthData = await getSystemHealth();
setHealth(healthData);
}
fetchHealth();
}, []);
return (
<Card>
<CardContent>Status: {health?.status}</CardContent>
</Card>
);
}2. Client API Functions (API Client Layer)
Location: apps/{web,cxc}/lib/api/
Purpose: Type-safe API wrappers for frontend
Example (apps/web/lib/api/health.ts):
import type { HealthResponse } from "@/types/api";
export async function getSystemHealth(): Promise<HealthResponse> {
const response = await fetch("/api/health");
if (!response.ok) {
throw new Error("Failed to fetch health status");
}
return response.json();
}
export async function login(credentials: LoginRequest): Promise<LoginResponse> {
const response = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(credentials),
});
const data = await response.json();
if (!response.ok) {
throw createApiError(data, response.status);
}
return data;
}3. Next.js API Routes (HTTP Layer)
Location: apps/web/app/api/, apps/admin/app/api/
Purpose: Handle HTTP requests and responses. Use ApiResponse from @uwdsc/common/utils for consistent status codes and body shapes (ok, badRequest, unauthorized, notFound, serverError). See Creating API Endpoints for the full reference.
Example (apps/web/app/api/profile/route.ts):
import { ApiResponse } from "@uwdsc/common/utils";
import { profileService } from "@uwdsc/core";
export async function GET() {
try {
const profile = await profileService.getProfileByUserId(user.id);
if (!profile) return ApiResponse.notFound("Profile not found");
return ApiResponse.ok(profile);
} catch (error) {
console.error("Profile fetch failed:", error);
return ApiResponse.serverError(error, "Failed to fetch profile");
}
}4. Services (Business Logic Layer)
Location: packages/server/core/src/services/ (and admin-specific in packages/server/admin/src/)
Purpose: Business logic and orchestration
Example (service in packages/server/core using a repository):
import { ProfileRepository } from "../repositories/ProfileRepository";
export class ProfileService {
private repository: ProfileRepository;
constructor() {
this.repository = new ProfileRepository();
}
async getProfileByUserId(userId: string) {
const profile = await this.repository.findByUserId(userId);
if (!profile) throw new ApiError("Profile not found", 404);
return profile;
}
}Use singletons (profileService, applicationService, teamService) from @uwdsc/core in API routes when the service is stateless. Create AuthService and ResumeService per-request in the app via createAuthService() / createResumeService() from lib/services.ts (they need a Supabase client).
5. Repositories (Data Access Layer)
Location: packages/server/core/src/repositories/ (extend BaseRepository from @uwdsc/db/baseRepository)
Purpose: Database queries and data access
Example (repository in packages/server/core):
import { BaseRepository } from "@uwdsc/db/baseRepository";
export class ProfileRepository extends BaseRepository {
async findByUserId(userId: string) {
const rows = await this.sql`
SELECT * FROM profiles WHERE id = ${userId}
`;
return rows[0] ?? null;
}
}Repositories use this.sql (postgres.js) from BaseRepository; the database connection is provided by @uwdsc/db.
6. Database (Persistence Layer)
Technologies:
- postgres.js: Used via
@uwdsc/db(Supabase Transaction Poolerβcompatible) - db-migrate: Migrations in
packages/server/db; run withpnpm migratefrom root - Supabase: PostgreSQL database, auth, and storage
- PostgreSQL: Relational database
Complete Flow Example
Health Check Flow
// 1. React Component (apps/web/components/HealthDashboard.tsx)
"use client";
import { useEffect, useState } from "react";
import { getSystemHealth } from "@/lib/api";
export function HealthDashboard() {
const [health, setHealth] = useState(null);
useEffect(() => {
async function fetchHealth() {
const healthData = await getSystemHealth();
setHealth(healthData);
}
fetchHealth();
}, []);
return <div>Status: {health?.status}</div>;
}
// 2. Client API (apps/web/lib/api/health.ts)
export async function getSystemHealth() {
const response = await fetch("/api/health");
return response.json();
}
// 3. API Route (apps/web/app/api/profile/route.ts)
import { NextResponse } from "next/server";
import { profileService } from "@uwdsc/core";
export async function GET() {
const profile = await profileService.getProfileByUserId(user.id);
return NextResponse.json(profile);
}
// 4. Service (packages/server/core) β use profileService singleton from @uwdsc/core
// 5. Repository extends BaseRepository from @uwdsc/db/baseRepository, uses this.sql (postgres.js)Authentication Flow
Note: Authentication in this project uses Supabase Auth, not direct database queries. Supabase handles password hashing, session management, and email verification automatically.
User Login
// 1. Component calls client API (apps/web/components/login/LoginForm.tsx)
import { login } from "@/lib/api/auth";
const handleLogin = async (email: string, password: string) => {
try {
const result = await login({ email, password });
// Handle success - result contains user and session
console.log("Logged in:", result.user);
} catch (error) {
// Handle error
console.error("Login failed:", error);
}
};
// 2. Client API (apps/web/lib/api/auth.ts)
export async function login(credentials: LoginRequest): Promise<LoginResponse> {
const response = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(credentials),
});
const data = await response.json();
if (!response.ok) {
throw createApiError(data, response.status);
}
return data;
}
// 3. API Route (apps/web/app/api/auth/login/route.ts)
import { NextRequest, NextResponse } from "next/server";
import { createAuthService } from "@/lib/services";
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { email, password } = body;
if (!email || !password) {
return NextResponse.json(
{ error: "Email and password are required" },
{ status: 400 }
);
}
const authService = await createAuthService();
const result = await authService.login({ email, password });
if (!result.success) {
return NextResponse.json(
{
error: result.error,
needsVerification: result.needsVerification,
email: result.email,
},
{ status: 400 }
);
}
return NextResponse.json({
success: true,
user: result.user,
session: result.session,
});
} catch (error) {
console.error("Login error:", error);
return NextResponse.json(
{ error: "An unexpected error occurred" },
{ status: 500 }
);
}
}
// 4. Service (packages/server/core) β AuthService and ResumeService take a Supabase client
// Create in app: const authService = await createAuthService(); (from lib/services.ts)
export class AuthService {
constructor(supabaseClient: SupabaseClient) {
// uses AuthRepository(supabaseClient)
}
async login(credentials: LoginData) {
const { data, error } = await this.repository.signInWithPassword({
email: credentials.email,
password: credentials.password,
});
if (error) {
if (error.message.includes("Email not confirmed")) {
return {
success: false,
needsVerification: true,
email: credentials.email,
error: "Email not verified",
};
}
return {
success: false,
error: error.message,
};
}
return {
success: true,
user: data.user,
session: data.session,
};
}
}
// 5. Repository (packages/server/core) β AuthRepository uses Supabase client for auth (no postgres.js)Error Handling
Centralized Error Handler
// utils/errors.ts
export class ApiError extends Error {
constructor(
message: string,
public statusCode: number = 500,
public code?: string
) {
super(message);
this.name = "ApiError";
}
}
export function handleApiError(error: unknown) {
if (error instanceof ApiError) {
return {
message: error.message,
code: error.code,
statusCode: error.statusCode,
};
}
return {
message: "Internal server error",
statusCode: 500,
};
}Usage in API Routes
import { NextResponse } from "next/server";
import { ApiError, handleApiError } from "@/lib/errors";
export async function POST(request: Request) {
try {
const data = await request.json();
const result = await service.process(data);
return NextResponse.json(result);
} catch (error) {
const { message, statusCode } = handleApiError(error);
return NextResponse.json({ error: message }, { status: statusCode });
}
}Validation
Using Zod Schemas
// lib/schemas/user.ts
import { z } from "zod";
export const loginSchema = z.object({
email: z.string().email("Invalid email"),
password: z.string().min(8, "Password must be at least 8 characters"),
});
export type LoginInput = z.infer<typeof loginSchema>;
// app/api/auth/login/route.ts
export async function POST(request: Request) {
try {
const body = await request.json();
const validated = loginSchema.parse(body);
// Process validated data
return NextResponse.json({ success: true });
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ errors: error.errors }, { status: 400 });
}
throw error;
}
}Best Practices
β Do
- Keep layers separated and focused
- Use services for business logic
- Use repositories for data access
- Validate inputs with Zod
- Handle errors appropriately
- Use TypeScript for type safety
- Write unit tests for services and repositories
β Donβt
- Put business logic in API routes
- Access database directly from API routes
- Skip input validation
- Expose internal errors to clients
- Mix concerns across layers
Next Steps
- Data Flow - Understanding data patterns
- Creating API Endpoints - Practical guide