ArchitectureAPI Architecture

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 with pnpm migrate from 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