Skip to main content

Service Layer Patterns

This guide covers the conventions and patterns used in Cascadia's service layer, located in src/lib/services/ and src/lib/items/services/.

Architecture Overview

Cascadia uses a three-layer architecture for server-side logic:

API Routes (src/routes/api/)
│ Thin handlers — parse request, call service, return response

Service Layer (src/lib/services/, src/lib/items/services/)
│ Business logic, validation, orchestration

Database Layer (src/lib/db/, Drizzle ORM)
│ Schema definitions, queries, transactions

PostgreSQL

Layer Responsibilities

LayerDoesDoes NOT
API RoutesAuth, request parsing, call services, format responseBusiness logic, direct DB queries
ServicesValidation, business rules, orchestration, transactionsHTTP concerns, response formatting
DatabaseSchema, queries, migrationsBusiness logic, validation

Service Conventions

Static Class Pattern

All services use static methods on a class. There are no instances to manage or inject.

// src/lib/services/BranchService.ts
export class BranchService {
static async getById(id: string) {
const result = await db
.select()
.from(branches)
.where(eq(branches.id, id))
.limit(1)

return result.at(0) || null
}

static async createEcoBranch(
designId: string,
changeOrderItemId: string,
userId: string,
) {
// ... business logic
}
}

Call services directly from routes or other services:

const branch = await BranchService.getById(branchId)
const part = await ItemService.findById(partId)

File Organization

  • One service per file, named after the service class
  • Co-located test files: BranchService.ts + BranchService.test.ts
  • Zod schemas defined at the top of the service file
  • Types/interfaces exported alongside the service
src/lib/services/
├── BranchService.ts # Branch management
├── BranchService.test.ts # Tests
├── CheckoutService.ts # Item checkout/checkin
├── CommitService.ts # Version commits
├── VersionResolver.ts # Item version resolution
├── ChangeOrderMergeService.ts # ECO branch merging
└── types/ # Shared type definitions

Validation with Zod

Services define Zod schemas for input validation and parse data at the entry point:

// Define schema at top of file
export const checkoutSchema = z.object({
itemMasterId: z.string().uuid(),
branchId: z.string().uuid(),
})

export type CheckoutInput = z.infer<typeof checkoutSchema>

// Parse in the service method
static async checkout(data: CheckoutInput, userId: string) {
const validated = checkoutSchema.parse(data)
// ... use validated data
}

For ItemService.create(), the schema comes from the ItemTypeRegistry:

const typeConfig = ItemTypeRegistry.getType(type)
const validatedData = typeConfig.schema.parse(dataWithType)

Error Handling

Typed Error Classes

Services throw typed errors from src/lib/errors/. Each error maps to an HTTP status code automatically.

Error ClassHTTP StatusWhen to Use
NotFoundError404Resource doesn't exist
ValidationError400Input validation failed
PermissionDeniedError403User lacks permission
AlreadyExistsError409Duplicate resource
ConflictError409State conflict
ResourceLockedError423Resource is locked
WorkflowTransitionError422Invalid state transition
BranchProtectionError403Main branch is protected
MergeConflictError409Branch merge conflict

Throwing Errors

import { NotFoundError, ValidationError } from '../errors'

// Resource not found — pass resource type and optional ID
if (!branch) {
throw new NotFoundError('Branch', branchId, { operation: 'lock' })
}

// Validation failure — pass message and optional field errors
if (branch.branchType === 'main') {
throw new ValidationError('Cannot lock main branch')
}

// Validation with field-level errors (for form display)
throw new ValidationError(
'ECO branch already exists for this change order on this design',
[
{
field: 'changeOrderItemId',
message: 'An ECO branch for this change order already exists',
},
],
)

// Permission denied — pass resource and action
throw new PermissionDeniedError('parts', 'delete')

How apiHandler Catches Errors

The apiHandler() wrapper in src/lib/api/handler.ts catches all errors thrown by services and converts them to proper HTTP responses automatically. You never need try/catch in routes:

// In the route — just throw, apiHandler catches it
GET: apiHandler({ permission: ['parts', 'read'] }, async ({ params }) => {
const part = await ItemService.findById(params.id)
if (!part) throw new NotFoundError('Part', params.id)
return { part }
})

The error handling chain:

  1. Service throws NotFoundError('Part', params.id)
  2. apiHandler catches it in its try/catch
  3. Calls handleApiError(error, request, requestId)
  4. handleApiError checks the error type:
    • AppError subclass: creates response from error's httpStatus and code
    • ZodError: wraps as ValidationError (400)
    • PostgreSQL error: maps error code to appropriate response
    • Unknown error: returns 500 Internal Server Error
  5. Error is logged to console and database (fire-and-forget)
  6. Response includes security headers (CORS, X-Frame-Options, etc.)

Error Response Format

All error responses follow this structure:

{
"error": {
"code": "RESOURCE_NOT_FOUND",
"message": "Part with ID 'abc-123' was not found",
"requestId": "req_abc123",
"timestamp": "2025-01-15T10:30:00.000Z"
}
}

Validation errors include field-level details:

{
"error": {
"code": "VALIDATION_FAILED",
"message": "Validation failed",
"fieldErrors": [
{ "field": "name", "message": "Name is required", "code": "too_small" }
],
"requestId": "req_abc123",
"timestamp": "2025-01-15T10:30:00.000Z"
}
}

Transaction Patterns

When to Use Transactions

Use db.transaction() when a service method performs multiple database operations that must succeed or fail together:

// BranchService.createBranch — must create branch atomically
return db.transaction(async (tx) => {
const [branch] = await tx
.insert(branches)
.values({ ... })
.returning()

return branch
}, { isolationLevel: 'repeatable read' })

Transaction with Isolation Level

For operations that read-then-write and must avoid phantom reads, use repeatable read:

return db.transaction(async (tx) => {
// Read and write in the same transaction
const [branch] = await tx
.insert(branches)
.values({ ... })
.returning()
return branch
}, { isolationLevel: 'repeatable read' })

When NOT to Use Transactions

Avoid wrapping calls that contain their own transactions. Nested transactions with postgres.js attempt to reserve additional connections and can deadlock:

// CheckoutService.createOnBranch — CommitService.create() has its own transaction
// So we do NOT wrap the outer method in db.transaction()

// 1. Insert item
const [newItem] = await db.insert(items).values({ ... }).returning()

// 2. Insert branchItem
await db.insert(branchItems).values({ ... })

// 3. Create commit (has its own transaction internally)
const commit = await CommitService.create({ ... }, userId)

Multi-step Operations

When multiple operations must happen atomically but one step has its own transaction, use sequential calls without a wrapping transaction:

static async deleteOnBranch(itemMasterId, branchId, commitMessage, userId) {
// Step 1: Update branchItem (single query, auto-committed)
await db.update(branchItems)
.set({ changeType: 'deleted' })
.where(eq(branchItems.id, bi.id))

// Step 2: Create commit (has its own transaction)
const commit = await CommitService.create({ ... }, userId)

return commit
}

Service Composition

Services call other services freely. There is no dependency injection — just direct static method calls:

// CheckoutService calls BranchService and CommitService
export class CheckoutService {
static async checkout(data: CheckoutInput, userId: string) {
const branch = await BranchService.getById(validated.branchId)
if (!branch) throw new NotFoundError('Branch', validated.branchId)

const releasedItem = await VersionResolver.getReleasedVersion(
validated.itemMasterId, branch.designId
)

// ... create branchItem entry
}

static async saveChanges(data: SaveChangesInput, userId: string) {
// ... create new item version
const commit = await CommitService.create({ ... }, userId)
return { item: newItem, commit }
}
}

Key Services Reference

ServiceLocationPurpose
ItemServicesrc/lib/items/services/CRUD for all item types
BranchServicesrc/lib/services/Branch creation, locking, archiving
CheckoutServicesrc/lib/services/Item checkout/checkin on branches
CommitServicesrc/lib/services/Create version commits
VersionResolversrc/lib/services/Resolve item versions per branch/commit
ChangeOrderMergeServicesrc/lib/services/Merge ECO branches to main
DesignServicesrc/lib/services/Design management
LifecycleServicesrc/lib/services/Lifecycle state transitions
ConflictDetectionServicesrc/lib/services/Detect merge conflicts
RevisionServicesrc/lib/services/Assign revision letters on release
JobServicesrc/lib/jobs/Submit and manage background jobs