| name | backend-engineer |
| description | Expert in Lighthouse Journey Timeline backend architecture, service patterns, testing, and API development using Express.js, TypeScript, and Drizzle ORM. Use when implementing backend services, APIs, controllers, database queries, authentication, permissions, error handling, testing server code, or writing migrations. |
Lighthouse Backend Engineer
Expert knowledge of backend patterns and architecture for the Lighthouse Journey Timeline.
๐๏ธ Core Architecture
Technology Stack
- Runtime: Node.js with TypeScript
- Framework: Express.js
- Database: PostgreSQL with Drizzle ORM
- DI Container: Awilix (Proxy injection mode)
- Testing: Vitest with vitest-mock-extended
- Validation: Zod schemas (CRITICAL: Use across ALL layers)
- Documentation: express-jsdoc-swagger โ OpenAPI
Service Layer Architecture
HTTP Request โ Routes โ Middleware โ Controller โ Service โ Repository โ Database
โ โ โ
Validation Business Drizzle
& Mapping Logic ORM
๐ CRITICAL: Where to Look Before Making Changes
Pattern References (ALWAYS CHECK THESE FIRST)
| Pattern | Primary Reference | Example Implementation |
|---|---|---|
| New Service | src/services/hierarchy-service.ts |
Complex service with multiple dependencies |
| New Controller | src/controllers/user.controller.ts |
Request validation, error handling, response mapping |
| New Repository | src/repositories/user-repository.ts |
Drizzle patterns, complex queries |
| New Route | src/routes/hierarchy.routes.ts |
DI scope resolution, middleware chain |
| Response Mapper | src/mappers/user.mapper.ts |
DTO transformation patterns |
| Unit Tests | src/services/__tests__/*.test.ts |
Mock setup, test structure |
| Error Codes | src/core/error-codes.ts |
70+ standardized error codes |
| API Documentation | openapi-schema.yaml |
Current API specs - MUST UPDATE |
โก CRITICAL: Zod Validation Across All Layers
Why Zod Everywhere?
Strong typing with Zod schemas ensures type safety between server and client, catching errors at compile/validation time rather than runtime. EVERY data boundary must have Zod validation.
๐ฏ CRITICAL: Use Enums and Constants, Not Magic Strings
Never use magic strings. Always use enums or constants for:
- Error codes:
src/core/error-codes.ts- STRING enums (not numbers) - Node types:
z.enum(['job', 'education', 'project', 'event']) - Permission levels: Define as const objects or Zod enums
- Status values: Use Zod enums for type safety
- Configuration: Store in constants files, not inline strings
Example - Error Codes:
// โ
GOOD: Using enum
export enum ErrorCode {
USER_NOT_FOUND = 'USER_NOT_FOUND',
INVALID_PERMISSION = 'INVALID_PERMISSION',
}
throw new AppError(ErrorCode.USER_NOT_FOUND);
// โ BAD: Magic strings
throw new AppError('USER_NOT_FOUND');
Example - Zod Enums:
// โ
GOOD: Zod enum for validation + TypeScript type
const NodeTypeSchema = z.enum(['job', 'education', 'project']);
type NodeType = z.infer<typeof NodeTypeSchema>;
// โ BAD: Plain strings
type NodeType = string;
Layer-by-Layer Validation
1. Controller Layer (Request Validation)
// Define request/response schemas in controller or separate file
const CreateNodeRequestSchema = z.object({
type: z.enum(['job', 'education', 'project', 'event']),
parentId: z.string().uuid().optional(),
meta: z.object({
title: z.string().min(1).max(200),
company: z.string().optional(),
startDate: z.string().datetime(),
endDate: z.string().datetime().optional(),
description: z.string().optional()
})
});
// In controller method
async createNode(req: Request, res: Response) {
// Validate request body
const validatedData = CreateNodeRequestSchema.parse(req.body);
// validatedData is now strongly typed
// Pass to service (type-safe)
const result = await this.nodeService.create(validatedData);
}
2. Service Layer (Business Logic Validation)
// Service method input schemas
const ServiceCreateNodeSchema = z.object({
userId: z.number().positive(),
type: z.enum(['job', 'education', 'project']),
parentId: z.string().uuid().optional(),
meta: z.record(z.any())
});
// Service method
async create(input: z.infer<typeof ServiceCreateNodeSchema>) {
// Additional business validation
const validated = ServiceCreateNodeSchema.parse(input);
// Business rules validation
if (validated.parentId) {
await this.validateParentAccess(validated.parentId, validated.userId);
}
return await this.repository.create(validated);
}
3. Repository Layer (Database Schema Validation)
// Database insert/update schemas
const DbNodeInsertSchema = z.object({
id: z.string().uuid().default(() => crypto.randomUUID()),
type: z.string(),
userId: z.number(),
parentId: z.string().uuid().nullable(),
meta: z.any(), // JSON column
createdAt: z.date().default(() => new Date()),
updatedAt: z.date().default(() => new Date())
});
// Repository method
async create(data: unknown) {
// Validate before database operation
const dbData = DbNodeInsertSchema.parse(data);
return await this.db.insert(nodes).values(dbData);
}
4. Response Validation (DTO Layer)
// Response DTOs with Zod
const NodeResponseSchema = z.object({
id: z.string().uuid(),
type: z.string(),
meta: z.object({
title: z.string(),
// ... other fields
}),
createdAt: z.string().datetime(),
permissions: z.object({
canView: z.boolean(),
canEdit: z.boolean(),
canDelete: z.boolean()
})
});
// In mapper
static toDto(node: unknown): NodeResponseDto {
// Validate and transform
return NodeResponseSchema.parse({
id: node.id,
type: node.type,
// ... mapping
});
}
Shared Schema Patterns
Location Strategy
packages/
โโโ schema/
โ โโโ src/
โ โโโ api/ # โญ API Contracts (PRIMARY - ALWAYS USE)
โ โ โโโ auth.schemas.ts # Auth request/response schemas
โ โ โโโ user.schemas.ts # User request/response schemas
โ โ โโโ timeline.schemas.ts # Timeline request/response schemas
โ โ โโโ files.schemas.ts # File upload schemas
โ โ โโโ common.schemas.ts # Shared API schemas (pagination, etc.)
โ โ โโโ validation-helpers.ts # Reusable validators
โ โ โโโ index.ts # Re-exports all API schemas
โ โโโ schema.ts # Drizzle database schema
โ โโโ types.ts # Database type exports
โโโ server/
โโโ src/
โโโ controllers/ # Import from @journey/schema/src/api
โโโ services/ # Import from @journey/schema/src/api
โโโ mappers/ # Transform DB types โ API response types
CRITICAL:
- All API request/response contracts MUST be defined in
@journey/schema/src/api/ - Never create validation schemas in
server/src/- always use@journey/schema/src/api/ - Controllers import and use these schemas directly
- This ensures type safety between frontend and backend
Reusable Schema Components
// packages/schema/src/api/common.schemas.ts (or validation-helpers.ts)
export const uuidSchema = z.string().uuid();
export const dateTimeSchema = z.string().datetime();
export const paginationSchema = z.object({
page: z.number().positive().default(1),
limit: z.number().positive().max(100).default(20),
});
// Compose in controller-specific schemas
// packages/schema/src/api/timeline.schemas.ts
import { uuidSchema, paginationSchema } from './common.schemas';
export const getNodesRequestSchema = z.object({
userId: uuidSchema,
...paginationSchema.shape,
});
export type GetNodesRequest = z.infer<typeof getNodesRequestSchema>;
Zod with Drizzle ORM
// Generate Zod schemas from Drizzle tables
import { createInsertSchema, createSelectSchema } from 'drizzle-zod';
import { users } from '@journey/schema';
// Auto-generate schemas from Drizzle table definitions
export const insertUserSchema = createInsertSchema(users);
export const selectUserSchema = createSelectSchema(users);
// Extend with custom validations
export const createUserSchema = insertUserSchema
.extend({
email: z.string().email(),
password: z.string().min(8).max(100),
})
.omit({ id: true, createdAt: true });
Error Handling for Zod Validation
// In controller error handling
import { ZodError } from 'zod';
import { fromZodError } from 'zod-validation-error';
try {
const validated = schema.parse(req.body);
} catch (error) {
if (error instanceof ZodError) {
// Convert to friendly error message
const validationError = fromZodError(error);
return res.status(400).json({
success: false,
error: {
code: 'VALIDATION_ERROR',
message: validationError.toString(),
details: error.issues,
},
});
}
throw error;
}
Type Inference Pattern
// Define schema once
const NodeCreateSchema = z.object({
type: z.enum(['job', 'education']),
meta: z.object({
title: z.string(),
company: z.string().optional(),
}),
});
// Infer TypeScript type from schema
type NodeCreateInput = z.infer<typeof NodeCreateSchema>;
// Use throughout application
class NodeService {
async create(input: NodeCreateInput) {
// input is strongly typed
}
}
Testing Zod Schemas
describe('NodeCreateSchema', () => {
it('should validate correct input', () => {
const input = {
type: 'job',
meta: { title: 'Software Engineer' },
};
expect(() => NodeCreateSchema.parse(input)).not.toThrow();
});
it('should reject invalid type', () => {
const input = {
type: 'invalid',
meta: { title: 'Test' },
};
expect(() => NodeCreateSchema.parse(input)).toThrow(ZodError);
});
});
Best Practices
- Parse, Don't Validate: Use
parse()for runtime validation,safeParse()when you need to handle errors - Single Source of Truth: Define schemas once in
@journey/schemapackage - Compose Schemas: Build complex schemas from simple, reusable parts
- Type Inference: Use
z.infer<>instead of manually defining types - Validate Early: Validate at system boundaries (API endpoints, external services)
- Document Schemas: Add descriptions for better OpenAPI generation
z.string().describe('User email address');
๐ Authentication Patterns
JWT Implementation
- Access Token: 15min expiry, contains userId and basic claims
- Refresh Token: 7 days, stored in DB, rotated on use
- Token Service:
src/services/jwt.service.ts - Refresh Service:
src/services/refresh-token.service.ts
Auth Middleware Stack
// From src/middleware/auth.middleware.ts
requireAuth; // Validates JWT, loads user, fails if invalid
optionalAuth; // Validates JWT if present, continues regardless
requireGuest; // Ensures user is NOT authenticated
requireRole(role); // Role-based access control
requirePermission(); // Permission-based access
Token Refresh Flow
- Client sends refresh token to
/api/auth/refresh - Validate refresh token exists in DB and not expired
- Rotate: Mark old token as used, create new token pair
- Return new access + refresh tokens
- Clean up expired tokens periodically
๐งช Unit Testing Patterns
Test Structure (TDD Approach)
// Standard test file structure
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { mock, mockDeep } from 'vitest-mock-extended';
describe('ServiceName', () => {
let service: ServiceName;
let mockRepo: MockType<Repository>;
beforeEach(() => {
vi.clearAllMocks();
mockRepo = mock<Repository>();
service = new ServiceName(mockRepo);
});
describe('methodName', () => {
it('should handle success case', async () => {
// Arrange
const input = {
/* test data */
};
const expected = {
/* expected result */
};
mockRepo.findById.mockResolvedValue(expected);
// Act
const result = await service.method(input);
// Assert
expect(result).toEqual(expected);
expect(mockRepo.findById).toHaveBeenCalledWith(input.id);
});
it('should handle error case', async () => {
// Test error scenarios
});
});
});
Common Mock Patterns
// Mock with specific return
mockService.method.mockResolvedValue(result);
// Mock with implementation
mockService.method.mockImplementation(async (id) => {
return id === 'valid' ? data : null;
});
// Mock chainable queries (Drizzle)
const mockQuery = {
select: vi.fn().mockReturnThis(),
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
limit: vi.fn().mockResolvedValue([result]),
};
// Reset mocks
mockReset(mockService); // Clear all mock data
mockClear(mockService); // Clear call history only
๐๏ธ Database & Schema
Schema Management
- Schema Location:
packages/schema/src/schema.ts - Migrations:
packages/schema/migrations/ - Commands:
cd packages/schema pnpm db:generate --name migration_name # Generate migration pnpm db:migrate # Run migrations pnpm db:studio # Open Drizzle Studio
Drizzle ORM Patterns
// Repository pattern example
class UserRepository {
constructor(private db: NodePgDatabase) {}
async findById(id: number) {
return this.db.query.users.findFirst({
where: eq(users.id, id),
});
}
async search(term: string) {
return this.db
.select()
.from(users)
.where(
or(
ilike(users.firstName, `%${term}%`),
ilike(users.lastName, `%${term}%`)
)
)
.limit(20);
}
}
๐ Permission Cascade Pattern
Hierarchy
Node Level (most specific)
โ
Organization Level (if node belongs to org member)
โ
User Level (node owner)
โ
Public Level (if explicitly set)
Permission Check Flow
Check Node Policies (
node_policiestable)- Direct user permissions
- Organization permissions (if user is org member)
- Public permissions
Check Ownership
- Node owner has full access
- Parent node owners have view access
Apply Most Permissive
- If multiple policies exist, most permissive wins
- DENY effects override ALLOW
Implementation Reference
- Service:
src/services/node-permission.service.ts - Repository:
src/repositories/node-permission.repository.ts - SQL CTE:
src/repositories/sql/permission-cte.ts
โ Error Handling
Error Code Reference
// Location: src/core/error-codes.ts
// NOTE: Error codes are STRING enums, not numbers
export enum ErrorCode {
// Auth errors
AUTHENTICATION_REQUIRED = 'AUTHENTICATION_REQUIRED',
INVALID_TOKEN = 'INVALID_TOKEN',
TOKEN_EXPIRED = 'TOKEN_EXPIRED',
// Validation errors
VALIDATION_ERROR = 'VALIDATION_ERROR',
INVALID_REQUEST = 'INVALID_REQUEST',
// Resource errors
NOT_FOUND = 'NOT_FOUND',
ALREADY_EXISTS = 'ALREADY_EXISTS',
// Business errors
BUSINESS_RULE_VIOLATION = 'BUSINESS_RULE_VIOLATION',
CIRCULAR_REFERENCE = 'CIRCULAR_REFERENCE',
MAX_DEPTH_EXCEEDED = 'MAX_DEPTH_EXCEEDED',
// Server errors
INTERNAL_ERROR = 'INTERNAL_ERROR',
DATABASE_ERROR = 'DATABASE_ERROR',
EXTERNAL_SERVICE_ERROR = 'EXTERNAL_SERVICE_ERROR',
}
// Check error-codes.ts for complete list (70+ codes)
Error Usage Pattern
// In service layer
throw new ValidationError(ErrorCode.VALIDATION_ERROR, 'Detailed error message');
// In controller - automatically handled by error middleware
// Returns standardized ApiErrorResponse
๐ API Documentation
JSDoc Requirements
/**
* @route POST /api/v2/timeline/nodes
* @summary Create timeline node
* @description Creates a new node in user's timeline hierarchy
* @body {CreateNodeDto} Node creation data
* @response {201} {ApiSuccessResponse<Node>} Node created
* @response {400} {ApiErrorResponse} Validation error
* @response {403} {ApiErrorResponse} Permission denied
* @security BearerAuth
*/
async createNode(req: Request, res: Response) {
// Implementation
}
OpenAPI Verification
- Schema File:
packages/server/openapi-schema.yaml - Generation: Automatic from JSDoc comments
- Verification: After API changes, verify schema is updated:
pnpm generate:swagger # Check git diff on openapi-schema.yaml
๐ One-Time Scripts Pattern
Script Structure
#!/usr/bin/env tsx
/**
* Script Name and Purpose
*
* Run with: NODE_ENV=development tsx scripts/script-name.ts
*/
import { Container } from '../src/core/container-setup';
import { CONTAINER_TOKENS } from '../src/core/container-tokens';
async function main() {
console.log('๐ Starting script...');
// Initialize container (reuse app services)
await Container.configure(console);
const container = Container.getRootContainer();
// Resolve services
const userService = container.resolve(CONTAINER_TOKENS.USER_SERVICE);
const hierarchyService = container.resolve(
CONTAINER_TOKENS.HIERARCHY_SERVICE
);
try {
// Use services exactly like controllers do
const users = await userService.getAllUsers();
// Process data...
console.log('โ
Script completed successfully');
} catch (error) {
console.error('โ Script failed:', error);
process.exit(1);
}
}
// Run if executed directly
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch(console.error);
}
Script Locations
- Database Scripts:
packages/server/scripts/ - Migration Scripts:
packages/schema/migrations/ - Package Commands: See
package.jsonscripts section
๐ Response Mapping Patterns
Mapper Location Strategy
// Check for existing mapper
src / mappers / [entity].mapper.ts;
// If none exists, check similar entities
src / mappers / user.mapper.ts; // User-related mappings
src / mappers / experience.mapper.ts; // Experience-related
// Mapper pattern
class EntityMapper {
static toDto(entity: Entity): EntityDto {
return {
id: entity.id,
// Transform fields
// Omit internal fields
// Format dates
};
}
static toDtoArray(entities: Entity[]): EntityDto[] {
return entities.map((e) => this.toDto(e));
}
}
Response Wrapper
// All responses use standardized format
interface ApiSuccessResponse<T> {
success: true;
data: T;
}
// In controller
return res.status(200).json({
success: true,
data: UserMapper.toDto(user),
});
๐ฏ Development Workflow
Before Making Changes
- Check Pattern References (table above)
- Read Existing Implementation of similar feature
- Check Error Codes for appropriate errors
- Write Tests First (TDD approach)
- Update OpenAPI documentation via JSDoc
Common Commands
# From packages/server
pnpm test:unit # Run unit tests only (fast)
pnpm test # All tests including integration
pnpm dev # Start dev server
# Run specific test file (fastest for TDD)
pnpm vitest run --no-coverage src/services/__tests__/user.service.test.ts
# From project root (Nx commands)
pnpm test:changed # Test only changed packages
pnpm test:changed:all # Include e2e for changed packages
๐ Learning & Skill Updates
How to Update This Skill
- Identify New Pattern: During implementation, notice repeated pattern
- Verify Pattern: Ensure it's used in multiple places
- Document Location: Add to "Pattern References" table
- Add Example: Include minimal code example if complex
- Update Skill: Edit this file at
.claude/skills/backend-engineer/SKILL.md
What to Document
- โ New service patterns that differ from existing
- โ Complex query patterns (CTEs, joins)
- โ New middleware patterns
- โ Integration patterns with external services
- โ Performance optimization patterns
- โ One-off implementations
- โ Temporary workarounds
- โ Feature-specific business logic
Memory Integration
- Project Patterns: Document in Serena memory via
mcp__serena__write_memory - Reusable Patterns: Save to Memory MCP via
mcp__memory__create_entities - Skill Updates: Direct edits to this file
๐ Quick Reference
DI Container Tokens
// Infrastructure
CONTAINER_TOKENS.DATABASE;
CONTAINER_TOKENS.LOGGER;
// Services (check container-tokens.ts for complete list)
CONTAINER_TOKENS.USER_SERVICE;
CONTAINER_TOKENS.HIERARCHY_SERVICE;
CONTAINER_TOKENS.NODE_PERMISSION_SERVICE;
// ... 15+ more services
// Controllers
CONTAINER_TOKENS.USER_CONTROLLER;
CONTAINER_TOKENS.HIERARCHY_CONTROLLER;
// ... 9+ controllers
Test Coverage Requirements
- Target: 80% coverage minimum
- Critical Paths: 100% coverage for auth, permissions
- Check Coverage:
pnpm test:coverage - View Report:
pnpm coverage:html
Performance Considerations
- Query Optimization: Use Drizzle's query builder over raw SQL
- Pagination: Always paginate lists (default 20, max 100)
- N+1 Prevention: Use joins or batch queries
- Caching: Consider Redis for frequently accessed data
Remember: Always check existing patterns before implementing new ones. This maintains consistency and reduces technical debt.