
Backend Dev Guidelines
- 1.6k installs
- 44k repo stars
- Updated July 27, 2026
- sickn33/antigravity-awesome-skills
Senior backend engineer operating production-grade services under strict architectural and reliability constraints using layered architecture, explicit error boundaries, strong typing, centralized config, and first-class
About
Backend Development Guidelines is an architectural ruleset for building maintainable, observable Node.js/Express/TypeScript microservices. It mandates layered architecture (routes → controllers → services → repositories), centralized configuration via unifiedConfig, Zod validation for all external input, and Sentry error tracking on all critical paths. Developers use it when building routes, controllers, services, repositories, middleware, and Prisma database access. Key workflows include the Backend Feasibility & Risk Index (BFRI) assessment pre-implementation, strict naming conventions, dependency injection discipline, and required unit + integration test coverage. Anti-patterns (business logic in routes, direct Prisma in controllers, console.log, untested logic) trigger immediate rejection.
- Mandatory layered architecture: routes → controllers → services → repositories with zero cross-layer leakage
- Backend Feasibility & Risk Index (BFRI) framework assesses architectural fit, complexity, data risk, operational risk, a
- All errors captured in Sentry; all config via unifiedConfig; all input validated with Zod schemas
- BaseController abstraction eliminates raw res.json calls; asyncErrorWrapper prevents unhandled promise rejections
- Strict naming conventions, DI pattern, repository encapsulation, and required unit/integration test coverage before merg
Backend Dev Guidelines by the numbers
- 1,553 all-time installs (skills.sh)
- +26 installs in the week ending Jul 28, 2026 (Skillselion tracking)
- Ranked #310 of 4,386 Backend & APIs skills by installs in the Skillselion catalog
- Security screen: MEDIUM risk (skills.sh audit)
- Data as of Jul 28, 2026 (Skillselion catalog sync)
backend-dev-guidelines capabilities & compatibility
- Capabilities
- enforce layered architecture validation · calculate backend feasibility & risk index (bfri · generate repository, service, and controller sca · review error handling and sentry integration · validate zod schemas and input validation · assess test coverage requirements · identify anti patterns in existing code
- Works with
- sentry · github
- Use cases
- code review · debugging · refactoring · api development · testing
- Platforms
- macOS · Windows · Linux · WSL
- Runs
- Remote server
- Pricing
- Free
npx skills add https://github.com/sickn33/antigravity-awesome-skills --skill backend-dev-guidelinesAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1.6k |
|---|---|
| repo stars | ★ 44k |
| Security audit | 3 / 3 scanners passed |
| Last updated | July 27, 2026 |
| Repository | sickn33/antigravity-awesome-skills ↗ |
What it does
Enforce production-grade Node.js/Express backend architecture with layered separation, validation, error tracking, and observability.
Who is it for?
Node.js/Express/TypeScript teams building production microservices, routes/controllers/services/repositories, Prisma database access, middleware composition, error handling and observability.
Skip if: Frontend logic, deployment automation, infrastructure provisioning, legacy non-layered codebases, or tasks outside routes/controllers/services/repositories scope.
When should I use this skill?
Building or modifying routes, controllers, services, repositories, Express middleware, Prisma database access, Zod validation, Sentry error tracking, or backend refactors.
What you get
Predictable, testable, observable backend systems with zero anti-patterns, BFRI-driven risk assessment, and mandatory Sentry tracking on all critical paths.
- layered API modules
- Prisma data access layer
- middleware with error boundaries
By the numbers
- BFRI scoring range: -10 to +10; threshold for safe implementation ≥ 6
- Mandatory test coverage: unit tests for services, integration tests for routes, repository tests for complex queries
- 10 core anti-patterns explicitly rejected: business logic in routes, skipping service layer, direct Prisma in controller
Files
Backend Development Guidelines
(Node.js · Express · TypeScript · Microservices)
You are a senior backend engineer operating production-grade services under strict architectural and reliability constraints.
Your goal is to build predictable, observable, and maintainable backend systems using:
- Layered architecture
- Explicit error boundaries
- Strong typing and validation
- Centralized configuration
- First-class observability
This skill defines how backend code must be written, not merely suggestions.
---
1. Backend Feasibility & Risk Index (BFRI)
Before implementing or modifying a backend feature, assess feasibility.
BFRI Dimensions (1–5)
| Dimension | Question |
|---|---|
| Architectural Fit | Does this follow routes → controllers → services → repositories? |
| Business Logic Complexity | How complex is the domain logic? |
| Data Risk | Does this affect critical data paths or transactions? |
| Operational Risk | Does this impact auth, billing, messaging, or infra? |
| Testability | Can this be reliably unit + integration tested? |
Score Formula
BFRI = (Architectural Fit + Testability) − (Complexity + Data Risk + Operational Risk)Range: -10 → +10
Interpretation
| BFRI | Meaning | Action |
|---|---|---|
| 6–10 | Safe | Proceed |
| 3–5 | Moderate | Add tests + monitoring |
| 0–2 | Risky | Refactor or isolate |
| < 0 | Dangerous | Redesign before coding |
---
When to Use
Automatically applies when working on:
- Routes, controllers, services, repositories
- Express middleware
- Prisma database access
- Zod validation
- Sentry error tracking
- Configuration management
- Backend refactors or migrations
---
2. Core Architecture Doctrine (Non-Negotiable)
1. Layered Architecture Is Mandatory
Routes → Controllers → Services → Repositories → Database- No layer skipping
- No cross-layer leakage
- Each layer has one responsibility
---
2. Routes Only Route
// ❌ NEVER
router.post('/create', async (req, res) => {
await prisma.user.create(...);
});
// ✅ ALWAYS
router.post('/create', (req, res) =>
userController.create(req, res)
);Routes must contain zero business logic.
---
3. Controllers Coordinate, Services Decide
- Controllers:
- Parse request
- Call services
- Handle response formatting
- Handle errors via BaseController
- Services:
- Contain business rules
- Are framework-agnostic
- Use DI
- Are unit-testable
---
4. All Controllers Extend BaseController
export class UserController extends BaseController {
async getUser(req: Request, res: Response): Promise<void> {
try {
const user = await this.userService.getById(req.params.id);
this.handleSuccess(res, user);
} catch (error) {
this.handleError(error, res, 'getUser');
}
}
}No raw res.json calls outside BaseController helpers.
---
5. All Errors Go to Sentry
catch (error) {
Sentry.captureException(error);
throw error;
}❌ console.log ❌ silent failures ❌ swallowed errors
---
6. unifiedConfig Is the Only Config Source
// ❌ NEVER
process.env.JWT_SECRET;
// ✅ ALWAYS
import { config } from '@/config/unifiedConfig';
config.auth.jwtSecret;---
7. Validate All External Input with Zod
- Request bodies
- Query params
- Route params
- Webhook payloads
const schema = z.object({
email: z.string().email(),
});
const input = schema.parse(req.body);No validation = bug.
---
3. Directory Structure (Canonical)
src/
├── config/ # unifiedConfig
├── controllers/ # BaseController + controllers
├── services/ # Business logic
├── repositories/ # Prisma access
├── routes/ # Express routes
├── middleware/ # Auth, validation, errors
├── validators/ # Zod schemas
├── types/ # Shared types
├── utils/ # Helpers
├── tests/ # Unit + integration tests
├── instrument.ts # Sentry (FIRST IMPORT)
├── app.ts # Express app
└── server.ts # HTTP server---
4. Naming Conventions (Strict)
| Layer | Convention |
|---|---|
| Controller | PascalCaseController.ts |
| Service | camelCaseService.ts |
| Repository | PascalCaseRepository.ts |
| Routes | camelCaseRoutes.ts |
| Validators | camelCase.schema.ts |
---
5. Dependency Injection Rules
- Services receive dependencies via constructor
- No importing repositories directly inside controllers
- Enables mocking and testing
export class UserService {
constructor(
private readonly userRepository: UserRepository
) {}
}---
6. Prisma & Repository Rules
- Prisma client never used directly in controllers
- Repositories:
- Encapsulate queries
- Handle transactions
- Expose intent-based methods
await userRepository.findActiveUsers();---
7. Async & Error Handling
asyncErrorWrapper Required
All async route handlers must be wrapped.
router.get(
'/users',
asyncErrorWrapper((req, res) =>
controller.list(req, res)
)
);No unhandled promise rejections.
---
8. Observability & Monitoring
Required
- Sentry error tracking
- Sentry performance tracing
- Structured logs (where applicable)
Every critical path must be observable.
---
9. Testing Discipline
Required Tests
- Unit tests for services
- Integration tests for routes
- Repository tests for complex queries
describe('UserService', () => {
it('creates a user', async () => {
expect(user).toBeDefined();
});
});No tests → no merge.
---
10. Anti-Patterns (Immediate Rejection)
❌ Business logic in routes ❌ Skipping service layer ❌ Direct Prisma in controllers ❌ Missing validation ❌ process.env usage ❌ console.log instead of Sentry ❌ Untested business logic
---
11. Integration With Other Skills
- frontend-dev-guidelines → API contract alignment
- error-tracking → Sentry standards
- database-verification → Schema correctness
- analytics-tracking → Event pipelines
- skill-developer → Skill governance
---
12. Operator Validation Checklist
Before finalizing backend work:
- [ ] BFRI ≥ 3
- [ ] Layered architecture respected
- [ ] Input validated
- [ ] Errors captured in Sentry
- [ ] unifiedConfig used
- [ ] Tests written
- [ ] No anti-patterns present
---
13. Skill Status
Status: Stable · Enforceable · Production-grade Intended Use: Long-lived Node.js microservices with real traffic and real risk ---
When to Use
This skill is applicable to execute the workflow or actions described in the overview.
Limitations
- Use this skill only when the task clearly matches the scope described above.
- Do not treat the output as a substitute for environment-specific validation, testing, or expert review.
- Stop and ask for clarification if required inputs, permissions, safety boundaries, or success criteria are missing.
Architecture Overview - Backend Services
Complete guide to the layered architecture pattern used in backend microservices.
Table of Contents
- Layered Architecture Pattern
- Request Lifecycle
- Service Comparison
- Directory Structure Rationale
- Module Organization
- Separation of Concerns
---
Layered Architecture Pattern
The Four Layers
┌─────────────────────────────────────┐
│ HTTP Request │
└───────────────┬─────────────────────┘
↓
┌─────────────────────────────────────┐
│ Layer 1: ROUTES │
│ - Route definitions only │
│ - Middleware registration │
│ - Delegate to controllers │
│ - NO business logic │
└───────────────┬─────────────────────┘
↓
┌─────────────────────────────────────┐
│ Layer 2: CONTROLLERS │
│ - Request/response handling │
│ - Input validation │
│ - Call services │
│ - Format responses │
│ - Error handling │
└───────────────┬─────────────────────┘
↓
┌─────────────────────────────────────┐
│ Layer 3: SERVICES │
│ - Business logic │
│ - Orchestration │
│ - Call repositories │
│ - No HTTP knowledge │
└───────────────┬─────────────────────┘
↓
┌─────────────────────────────────────┐
│ Layer 4: REPOSITORIES │
│ - Data access abstraction │
│ - Prisma operations │
│ - Query optimization │
│ - Caching │
└───────────────┬─────────────────────┘
↓
┌─────────────────────────────────────┐
│ Database (MySQL) │
└─────────────────────────────────────┘Why This Architecture?
Testability:
- Each layer can be tested independently
- Easy to mock dependencies
- Clear test boundaries
Maintainability:
- Changes isolated to specific layers
- Business logic separate from HTTP concerns
- Easy to locate bugs
Reusability:
- Services can be used by routes, cron jobs, scripts
- Repositories hide database implementation
- Business logic not tied to HTTP
Scalability:
- Easy to add new endpoints
- Clear patterns to follow
- Consistent structure
---
Request Lifecycle
Complete Flow Example
1. HTTP POST /api/users
↓
2. Express matches route in userRoutes.ts
↓
3. Middleware chain executes:
- SSOMiddleware.verifyLoginStatus (authentication)
- auditMiddleware (context tracking)
↓
4. Route handler delegates to controller:
router.post('/users', (req, res) => userController.create(req, res))
↓
5. Controller validates and calls service:
- Validate input with Zod
- Call userService.create(data)
- Handle success/error
↓
6. Service executes business logic:
- Check business rules
- Call userRepository.create(data)
- Return result
↓
7. Repository performs database operation:
- PrismaService.main.user.create({ data })
- Handle database errors
- Return created user
↓
8. Response flows back:
Repository → Service → Controller → Express → ClientMiddleware Execution Order
Critical: Middleware executes in registration order
app.use(Sentry.Handlers.requestHandler()); // 1. Sentry tracing (FIRST)
app.use(express.json()); // 2. Body parsing
app.use(express.urlencoded({ extended: true })); // 3. URL encoding
app.use(cookieParser()); // 4. Cookie parsing
app.use(SSOMiddleware.initialize()); // 5. Auth initialization
// ... routes registered here
app.use(auditMiddleware); // 6. Audit (if global)
app.use(errorBoundary); // 7. Error handler (LAST)
app.use(Sentry.Handlers.errorHandler()); // 8. Sentry errors (LAST)Rule: Error handlers must be registered AFTER routes!
---
Service Comparison
Email Service (Mature Pattern ✅)
Strengths:
- Comprehensive BaseController with Sentry integration
- Clean route delegation (no business logic in routes)
- Consistent dependency injection pattern
- Good middleware organization
- Type-safe throughout
- Excellent error handling
Example Structure:
email/src/
├── controllers/
│ ├── BaseController.ts ✅ Excellent template
│ ├── NotificationController.ts ✅ Extends BaseController
│ └── EmailController.ts ✅ Clean patterns
├── routes/
│ ├── notificationRoutes.ts ✅ Clean delegation
│ └── emailRoutes.ts ✅ No business logic
├── services/
│ ├── NotificationService.ts ✅ Dependency injection
│ └── BatchingService.ts ✅ Clear responsibility
└── middleware/
├── errorBoundary.ts ✅ Comprehensive
└── DevImpersonationSSOMiddleware.tsUse as template for new services!
Form Service (Transitioning ⚠️)
Strengths:
- Excellent workflow architecture (event sourcing)
- Good Sentry integration
- Innovative audit middleware (AsyncLocalStorage)
- Comprehensive permission system
Weaknesses:
- Some routes have 200+ lines of business logic
- Inconsistent controller naming
- Direct process.env usage (60+ occurrences)
- Minimal repository pattern usage
Example:
form/src/
├── routes/
│ ├── responseRoutes.ts ❌ Business logic in routes
│ └── proxyRoutes.ts ✅ Good validation pattern
├── controllers/
│ ├── formController.ts ⚠️ Lowercase naming
│ └── UserProfileController.ts ✅ PascalCase naming
├── workflow/ ✅ Excellent architecture!
│ ├── core/
│ │ ├── WorkflowEngineV3.ts ✅ Event sourcing
│ │ └── DryRunWrapper.ts ✅ Innovative
│ └── services/
└── middleware/
└── auditMiddleware.ts ✅ AsyncLocalStorage patternLearn from: workflow/, middleware/auditMiddleware.ts Avoid: responseRoutes.ts, direct process.env
---
Directory Structure Rationale
Controllers Directory
Purpose: Handle HTTP request/response concerns
Contents:
BaseController.ts- Base class with common methods{Feature}Controller.ts- Feature-specific controllers
Naming: PascalCase + Controller
Responsibilities:
- Parse request parameters
- Validate input (Zod)
- Call appropriate service methods
- Format responses
- Handle errors (via BaseController)
- Set HTTP status codes
Services Directory
Purpose: Business logic and orchestration
Contents:
{feature}Service.ts- Feature business logic
Naming: camelCase + Service (or PascalCase + Service)
Responsibilities:
- Implement business rules
- Orchestrate multiple repositories
- Transaction management
- Business validations
- No HTTP knowledge (Request/Response types)
Repositories Directory
Purpose: Data access abstraction
Contents:
{Entity}Repository.ts- Database operations for entity
Naming: PascalCase + Repository
Responsibilities:
- Prisma query operations
- Query optimization
- Database error handling
- Caching layer
- Hide Prisma implementation details
Current Gap: Only 1 repository exists (WorkflowRepository)
Routes Directory
Purpose: Route registration ONLY
Contents:
{feature}Routes.ts- Express router for feature
Naming: camelCase + Routes
Responsibilities:
- Register routes with Express
- Apply middleware
- Delegate to controllers
- NO business logic!
Middleware Directory
Purpose: Cross-cutting concerns
Contents:
- Authentication middleware
- Audit middleware
- Error boundaries
- Validation middleware
- Custom middleware
Naming: camelCase
Types:
- Request processing (before handler)
- Response processing (after handler)
- Error handling (error boundary)
Config Directory
Purpose: Configuration management
Contents:
unifiedConfig.ts- Type-safe configuration- Environment-specific configs
Pattern: Single source of truth
Types Directory
Purpose: TypeScript type definitions
Contents:
{feature}.types.ts- Feature-specific types- DTOs (Data Transfer Objects)
- Request/Response types
- Domain models
---
Module Organization
Feature-Based Organization
For large features, use subdirectories:
src/workflow/
├── core/ # Core engine
├── services/ # Workflow-specific services
├── actions/ # System actions
├── models/ # Domain models
├── validators/ # Workflow validation
└── utils/ # Workflow utilitiesWhen to use:
- Feature has 5+ files
- Clear sub-domains exist
- Logical grouping improves clarity
Flat Organization
For simple features:
src/
├── controllers/UserController.ts
├── services/userService.ts
├── routes/userRoutes.ts
└── repositories/UserRepository.tsWhen to use:
- Simple features (< 5 files)
- No clear sub-domains
- Flat structure is clearer
---
Separation of Concerns
What Goes Where
Routes Layer:
- ✅ Route definitions
- ✅ Middleware registration
- ✅ Controller delegation
- ❌ Business logic
- ❌ Database operations
- ❌ Validation logic (should be in validator or controller)
Controllers Layer:
- ✅ Request parsing (params, body, query)
- ✅ Input validation (Zod)
- ✅ Service calls
- ✅ Response formatting
- ✅ Error handling
- ❌ Business logic
- ❌ Database operations
Services Layer:
- ✅ Business logic
- ✅ Business rules enforcement
- ✅ Orchestration (multiple repos)
- ✅ Transaction management
- ❌ HTTP concerns (Request/Response)
- ❌ Direct Prisma calls (use repositories)
Repositories Layer:
- ✅ Prisma operations
- ✅ Query construction
- ✅ Database error handling
- ✅ Caching
- ❌ Business logic
- ❌ HTTP concerns
Example: User Creation
Route:
router.post('/users',
SSOMiddleware.verifyLoginStatus,
auditMiddleware,
(req, res) => userController.create(req, res)
);Controller:
async create(req: Request, res: Response): Promise<void> {
try {
const validated = createUserSchema.parse(req.body);
const user = await this.userService.create(validated);
this.handleSuccess(res, user, 'User created');
} catch (error) {
this.handleError(error, res, 'create');
}
}Service:
async create(data: CreateUserDTO): Promise<User> {
// Business rule: check if email already exists
const existing = await this.userRepository.findByEmail(data.email);
if (existing) throw new ConflictError('Email already exists');
// Create user
return await this.userRepository.create(data);
}Repository:
async create(data: CreateUserDTO): Promise<User> {
return PrismaService.main.user.create({ data });
}
async findByEmail(email: string): Promise<User | null> {
return PrismaService.main.user.findUnique({ where: { email } });
}Notice: Each layer has clear, distinct responsibilities!
---
Related Files:
- SKILL.md - Main guide
- routing-and-controllers.md - Routes and controllers details
- services-and-repositories.md - Service and repository patterns
Async Patterns and Error Handling
Complete guide to async/await patterns and custom error handling.
Table of Contents
- Async/Await Best Practices
- Promise Error Handling
- Custom Error Types
- asyncErrorWrapper Utility
- Error Propagation
- Common Async Pitfalls
---
Async/Await Best Practices
Always Use Try-Catch
// ❌ NEVER: Unhandled async errors
async function fetchData() {
const data = await database.query(); // If throws, unhandled!
return data;
}
// ✅ ALWAYS: Wrap in try-catch
async function fetchData() {
try {
const data = await database.query();
return data;
} catch (error) {
Sentry.captureException(error);
throw error;
}
}Avoid .then() Chains
// ❌ AVOID: Promise chains
function processData() {
return fetchData()
.then(data => transform(data))
.then(transformed => save(transformed))
.catch(error => {
console.error(error);
});
}
// ✅ PREFER: Async/await
async function processData() {
try {
const data = await fetchData();
const transformed = await transform(data);
return await save(transformed);
} catch (error) {
Sentry.captureException(error);
throw error;
}
}---
Promise Error Handling
Parallel Operations
// ✅ Handle errors in Promise.all
try {
const [users, profiles, settings] = await Promise.all([
userService.getAll(),
profileService.getAll(),
settingsService.getAll(),
]);
} catch (error) {
// One failure fails all
Sentry.captureException(error);
throw error;
}
// ✅ Handle errors individually with Promise.allSettled
const results = await Promise.allSettled([
userService.getAll(),
profileService.getAll(),
settingsService.getAll(),
]);
results.forEach((result, index) => {
if (result.status === 'rejected') {
Sentry.captureException(result.reason, {
tags: { operation: ['users', 'profiles', 'settings'][index] }
});
}
});---
Custom Error Types
Define Custom Errors
// Base error class
export class AppError extends Error {
constructor(
message: string,
public code: string,
public statusCode: number,
public isOperational: boolean = true
) {
super(message);
this.name = this.constructor.name;
Error.captureStackTrace(this, this.constructor);
}
}
// Specific error types
export class ValidationError extends AppError {
constructor(message: string) {
super(message, 'VALIDATION_ERROR', 400);
}
}
export class NotFoundError extends AppError {
constructor(message: string) {
super(message, 'NOT_FOUND', 404);
}
}
export class ForbiddenError extends AppError {
constructor(message: string) {
super(message, 'FORBIDDEN', 403);
}
}
export class ConflictError extends AppError {
constructor(message: string) {
super(message, 'CONFLICT', 409);
}
}Usage
// Throw specific errors
if (!user) {
throw new NotFoundError('User not found');
}
if (user.age < 18) {
throw new ValidationError('User must be 18+');
}
// Error boundary handles them
function errorBoundary(error, req, res, next) {
if (error instanceof AppError) {
return res.status(error.statusCode).json({
error: {
message: error.message,
code: error.code
}
});
}
// Unknown error
Sentry.captureException(error);
res.status(500).json({ error: { message: 'Internal server error' } });
}---
asyncErrorWrapper Utility
Pattern
export function asyncErrorWrapper(
handler: (req: Request, res: Response, next: NextFunction) => Promise<any>
) {
return async (req: Request, res: Response, next: NextFunction) => {
try {
await handler(req, res, next);
} catch (error) {
next(error);
}
};
}Usage
// Without wrapper - error can be unhandled
router.get('/users', async (req, res) => {
const users = await userService.getAll(); // If throws, unhandled!
res.json(users);
});
// With wrapper - errors caught
router.get('/users', asyncErrorWrapper(async (req, res) => {
const users = await userService.getAll();
res.json(users);
}));---
Error Propagation
Proper Error Chains
// ✅ Propagate errors up the stack
async function repositoryMethod() {
try {
return await PrismaService.main.user.findMany();
} catch (error) {
Sentry.captureException(error, { tags: { layer: 'repository' } });
throw error; // Propagate to service
}
}
async function serviceMethod() {
try {
return await repositoryMethod();
} catch (error) {
Sentry.captureException(error, { tags: { layer: 'service' } });
throw error; // Propagate to controller
}
}
async function controllerMethod(req, res) {
try {
const result = await serviceMethod();
res.json(result);
} catch (error) {
this.handleError(error, res, 'controllerMethod'); // Final handler
}
}---
Common Async Pitfalls
Fire and Forget (Bad)
// ❌ NEVER: Fire and forget
async function processRequest(req, res) {
sendEmail(user.email); // Fires async, errors unhandled!
res.json({ success: true });
}
// ✅ ALWAYS: Await or handle
async function processRequest(req, res) {
try {
await sendEmail(user.email);
res.json({ success: true });
} catch (error) {
Sentry.captureException(error);
res.status(500).json({ error: 'Failed to send email' });
}
}
// ✅ OR: Intentional background task
async function processRequest(req, res) {
sendEmail(user.email).catch(error => {
Sentry.captureException(error);
});
res.json({ success: true });
}Unhandled Rejections
// ✅ Global handler for unhandled rejections
process.on('unhandledRejection', (reason, promise) => {
Sentry.captureException(reason, {
tags: { type: 'unhandled_rejection' }
});
console.error('Unhandled Rejection:', reason);
});
process.on('uncaughtException', (error) => {
Sentry.captureException(error, {
tags: { type: 'uncaught_exception' }
});
console.error('Uncaught Exception:', error);
process.exit(1);
});---
Related Files:
- SKILL.md
- sentry-and-monitoring.md
- complete-examples.md
Complete Examples - Full Working Code
Real-world examples showing complete implementation patterns.
Table of Contents
- Complete Controller Example
- Complete Service with DI
- Complete Route File
- Complete Repository
- Refactoring Example: Bad to Good
- End-to-End Feature Example
---
Complete Controller Example
UserController (Following All Best Practices)
// controllers/UserController.ts
import { Request, Response } from 'express';
import { BaseController } from './BaseController';
import { UserService } from '../services/userService';
import { createUserSchema, updateUserSchema } from '../validators/userSchemas';
import { z } from 'zod';
export class UserController extends BaseController {
private userService: UserService;
constructor() {
super();
this.userService = new UserService();
}
async getUser(req: Request, res: Response): Promise<void> {
try {
this.addBreadcrumb('Fetching user', 'user_controller', {
userId: req.params.id,
});
const user = await this.withTransaction(
'user.get',
'db.query',
() => this.userService.findById(req.params.id)
);
if (!user) {
return this.handleError(
new Error('User not found'),
res,
'getUser',
404
);
}
this.handleSuccess(res, user);
} catch (error) {
this.handleError(error, res, 'getUser');
}
}
async listUsers(req: Request, res: Response): Promise<void> {
try {
const users = await this.userService.getAll();
this.handleSuccess(res, users);
} catch (error) {
this.handleError(error, res, 'listUsers');
}
}
async createUser(req: Request, res: Response): Promise<void> {
try {
// Validate input with Zod
const validated = createUserSchema.parse(req.body);
// Track performance
const user = await this.withTransaction(
'user.create',
'db.mutation',
() => this.userService.create(validated)
);
this.handleSuccess(res, user, 'User created successfully', 201);
} catch (error) {
if (error instanceof z.ZodError) {
return this.handleError(error, res, 'createUser', 400);
}
this.handleError(error, res, 'createUser');
}
}
async updateUser(req: Request, res: Response): Promise<void> {
try {
const validated = updateUserSchema.parse(req.body);
const user = await this.userService.update(
req.params.id,
validated
);
this.handleSuccess(res, user, 'User updated');
} catch (error) {
if (error instanceof z.ZodError) {
return this.handleError(error, res, 'updateUser', 400);
}
this.handleError(error, res, 'updateUser');
}
}
async deleteUser(req: Request, res: Response): Promise<void> {
try {
await this.userService.delete(req.params.id);
this.handleSuccess(res, null, 'User deleted', 204);
} catch (error) {
this.handleError(error, res, 'deleteUser');
}
}
}---
Complete Service with DI
UserService
// services/userService.ts
import { UserRepository } from '../repositories/UserRepository';
import { ConflictError, NotFoundError, ValidationError } from '../types/errors';
import type { CreateUserDTO, UpdateUserDTO, User } from '../types/user.types';
export class UserService {
private userRepository: UserRepository;
constructor(userRepository?: UserRepository) {
this.userRepository = userRepository || new UserRepository();
}
async findById(id: string): Promise<User | null> {
return await this.userRepository.findById(id);
}
async getAll(): Promise<User[]> {
return await this.userRepository.findActive();
}
async create(data: CreateUserDTO): Promise<User> {
// Business rule: validate age
if (data.age < 18) {
throw new ValidationError('User must be 18 or older');
}
// Business rule: check email uniqueness
const existing = await this.userRepository.findByEmail(data.email);
if (existing) {
throw new ConflictError('Email already in use');
}
// Create user with profile
return await this.userRepository.create({
email: data.email,
profile: {
create: {
firstName: data.firstName,
lastName: data.lastName,
age: data.age,
},
},
});
}
async update(id: string, data: UpdateUserDTO): Promise<User> {
// Check exists
const existing = await this.userRepository.findById(id);
if (!existing) {
throw new NotFoundError('User not found');
}
// Business rule: email uniqueness if changing
if (data.email && data.email !== existing.email) {
const emailTaken = await this.userRepository.findByEmail(data.email);
if (emailTaken) {
throw new ConflictError('Email already in use');
}
}
return await this.userRepository.update(id, data);
}
async delete(id: string): Promise<void> {
const existing = await this.userRepository.findById(id);
if (!existing) {
throw new NotFoundError('User not found');
}
await this.userRepository.delete(id);
}
}---
Complete Route File
userRoutes.ts
// routes/userRoutes.ts
import { Router } from 'express';
import { UserController } from '../controllers/UserController';
import { SSOMiddlewareClient } from '../middleware/SSOMiddleware';
import { auditMiddleware } from '../middleware/auditMiddleware';
const router = Router();
const controller = new UserController();
// GET /users - List all users
router.get('/',
SSOMiddlewareClient.verifyLoginStatus,
auditMiddleware,
async (req, res) => controller.listUsers(req, res)
);
// GET /users/:id - Get single user
router.get('/:id',
SSOMiddlewareClient.verifyLoginStatus,
auditMiddleware,
async (req, res) => controller.getUser(req, res)
);
// POST /users - Create user
router.post('/',
SSOMiddlewareClient.verifyLoginStatus,
auditMiddleware,
async (req, res) => controller.createUser(req, res)
);
// PUT /users/:id - Update user
router.put('/:id',
SSOMiddlewareClient.verifyLoginStatus,
auditMiddleware,
async (req, res) => controller.updateUser(req, res)
);
// DELETE /users/:id - Delete user
router.delete('/:id',
SSOMiddlewareClient.verifyLoginStatus,
auditMiddleware,
async (req, res) => controller.deleteUser(req, res)
);
export default router;---
Complete Repository
UserRepository
// repositories/UserRepository.ts
import { PrismaService } from '@project-lifecycle-portal/database';
import type { User, Prisma } from '@prisma/client';
export class UserRepository {
async findById(id: string): Promise<User | null> {
return PrismaService.main.user.findUnique({
where: { id },
include: { profile: true },
});
}
async findByEmail(email: string): Promise<User | null> {
return PrismaService.main.user.findUnique({
where: { email },
include: { profile: true },
});
}
async findActive(): Promise<User[]> {
return PrismaService.main.user.findMany({
where: { isActive: true },
include: { profile: true },
orderBy: { createdAt: 'desc' },
});
}
async create(data: Prisma.UserCreateInput): Promise<User> {
return PrismaService.main.user.create({
data,
include: { profile: true },
});
}
async update(id: string, data: Prisma.UserUpdateInput): Promise<User> {
return PrismaService.main.user.update({
where: { id },
data,
include: { profile: true },
});
}
async delete(id: string): Promise<User> {
// Soft delete
return PrismaService.main.user.update({
where: { id },
data: {
isActive: false,
deletedAt: new Date(),
},
});
}
}---
Refactoring Example: Bad to Good
BEFORE: Business Logic in Routes ❌
// routes/postRoutes.ts (BAD - 200+ lines)
router.post('/posts', async (req, res) => {
try {
const username = res.locals.claims.preferred_username;
const responses = req.body.responses;
const stepInstanceId = req.body.stepInstanceId;
// ❌ Permission check in route
const userId = await userProfileService.getProfileByEmail(username).then(p => p.id);
const canComplete = await permissionService.canCompleteStep(userId, stepInstanceId);
if (!canComplete) {
return res.status(403).json({ error: 'No permission' });
}
// ❌ Business logic in route
const post = await postRepository.create({
title: req.body.title,
content: req.body.content,
authorId: userId
});
// ❌ More business logic...
if (res.locals.isImpersonating) {
impersonationContextStore.storeContext(...);
}
// ... 100+ more lines
res.json({ success: true, data: result });
} catch (e) {
handler.handleException(res, e);
}
});AFTER: Clean Separation ✅
1. Clean Route:
// routes/postRoutes.ts
import { PostController } from '../controllers/PostController';
const router = Router();
const controller = new PostController();
// ✅ CLEAN: 8 lines total!
router.post('/',
SSOMiddlewareClient.verifyLoginStatus,
auditMiddleware,
async (req, res) => controller.createPost(req, res)
);
export default router;2. Controller:
// controllers/PostController.ts
export class PostController extends BaseController {
private postService: PostService;
constructor() {
super();
this.postService = new PostService();
}
async createPost(req: Request, res: Response): Promise<void> {
try {
const validated = createPostSchema.parse({
...req.body,
});
const result = await this.postService.createPost(
validated,
res.locals.userId
);
this.handleSuccess(res, result, 'Post created successfully');
} catch (error) {
this.handleError(error, res, 'createPost');
}
}
}3. Service:
// services/postService.ts
export class PostService {
async createPost(
data: CreatePostDTO,
userId: string
): Promise<SubmissionResult> {
// Permission check
const canComplete = await permissionService.canCompleteStep(
userId,
data.stepInstanceId
);
if (!canComplete) {
throw new ForbiddenError('No permission to complete step');
}
// Execute workflow
const engine = await createWorkflowEngine();
const command = new CompleteStepCommand(
data.stepInstanceId,
userId,
data.responses
);
const events = await engine.executeCommand(command);
// Handle impersonation
if (context.isImpersonating) {
await this.handleImpersonation(data.stepInstanceId, context);
}
return { events, success: true };
}
private async handleImpersonation(stepInstanceId: number, context: any) {
impersonationContextStore.storeContext(stepInstanceId, {
originalUserId: context.originalUserId,
effectiveUserId: context.effectiveUserId,
});
}
}Result:
- Route: 8 lines (was 200+)
- Controller: 25 lines
- Service: 40 lines
- Testable, maintainable, reusable!
---
End-to-End Feature Example
Complete User Management Feature
1. Types:
// types/user.types.ts
export interface User {
id: string;
email: string;
isActive: boolean;
profile?: UserProfile;
}
export interface CreateUserDTO {
email: string;
firstName: string;
lastName: string;
age: number;
}
export interface UpdateUserDTO {
email?: string;
firstName?: string;
lastName?: string;
}2. Validators:
// validators/userSchemas.ts
import { z } from 'zod';
export const createUserSchema = z.object({
email: z.string().email(),
firstName: z.string().min(1).max(100),
lastName: z.string().min(1).max(100),
age: z.number().int().min(18).max(120),
});
export const updateUserSchema = z.object({
email: z.string().email().optional(),
firstName: z.string().min(1).max(100).optional(),
lastName: z.string().min(1).max(100).optional(),
});3. Repository:
// repositories/UserRepository.ts
export class UserRepository {
async findById(id: string): Promise<User | null> {
return PrismaService.main.user.findUnique({
where: { id },
include: { profile: true },
});
}
async create(data: Prisma.UserCreateInput): Promise<User> {
return PrismaService.main.user.create({
data,
include: { profile: true },
});
}
}4. Service:
// services/userService.ts
export class UserService {
private userRepository: UserRepository;
constructor() {
this.userRepository = new UserRepository();
}
async create(data: CreateUserDTO): Promise<User> {
const existing = await this.userRepository.findByEmail(data.email);
if (existing) {
throw new ConflictError('Email already exists');
}
return await this.userRepository.create({
email: data.email,
profile: {
create: {
firstName: data.firstName,
lastName: data.lastName,
age: data.age,
},
},
});
}
}5. Controller:
// controllers/UserController.ts
export class UserController extends BaseController {
private userService: UserService;
constructor() {
super();
this.userService = new UserService();
}
async createUser(req: Request, res: Response): Promise<void> {
try {
const validated = createUserSchema.parse(req.body);
const user = await this.userService.create(validated);
this.handleSuccess(res, user, 'User created', 201);
} catch (error) {
this.handleError(error, res, 'createUser');
}
}
}6. Routes:
// routes/userRoutes.ts
const router = Router();
const controller = new UserController();
router.post('/',
SSOMiddlewareClient.verifyLoginStatus,
async (req, res) => controller.createUser(req, res)
);
export default router;7. Register in app.ts:
// app.ts
import userRoutes from './routes/userRoutes';
app.use('/api/users', userRoutes);Complete Request Flow:
POST /api/users
↓
userRoutes matches /
↓
SSOMiddleware authenticates
↓
controller.createUser called
↓
Validates with Zod
↓
userService.create called
↓
Checks business rules
↓
userRepository.create called
↓
Prisma creates user
↓
Returns up the chain
↓
Controller formats response
↓
200/201 sent to client---
Related Files:
- SKILL.md
- routing-and-controllers.md
- services-and-repositories.md
- validation-patterns.md
Configuration Management - UnifiedConfig Pattern
Complete guide to managing configuration in backend microservices.
Table of Contents
- UnifiedConfig Overview
- NEVER Use process.env Directly
- Configuration Structure
- Environment-Specific Configs
- Secrets Management
- Migration Guide
---
UnifiedConfig Overview
Why UnifiedConfig?
Problems with process.env:
- ❌ No type safety
- ❌ No validation
- ❌ Hard to test
- ❌ Scattered throughout code
- ❌ No default values
- ❌ Runtime errors for typos
Benefits of unifiedConfig:
- ✅ Type-safe configuration
- ✅ Single source of truth
- ✅ Validated at startup
- ✅ Easy to test with mocks
- ✅ Clear structure
- ✅ Fallback to environment variables
---
NEVER Use process.env Directly
The Rule
// ❌ NEVER DO THIS
const timeout = parseInt(process.env.TIMEOUT_MS || '5000');
const dbHost = process.env.DB_HOST || 'localhost';
// ✅ ALWAYS DO THIS
import { config } from './config/unifiedConfig';
const timeout = config.timeouts.default;
const dbHost = config.database.host;Why This Matters
Example of problems:
// Typo in environment variable name
const host = process.env.DB_HSOT; // undefined! No error!
// Type safety
const port = process.env.PORT; // string! Need parseInt
const timeout = parseInt(process.env.TIMEOUT); // NaN if not set!With unifiedConfig:
const port = config.server.port; // number, guaranteed
const timeout = config.timeouts.default; // number, with fallback---
Configuration Structure
UnifiedConfig Interface
export interface UnifiedConfig {
database: {
host: string;
port: number;
username: string;
password: string;
database: string;
};
server: {
port: number;
sessionSecret: string;
};
tokens: {
jwt: string;
inactivity: string;
internal: string;
};
keycloak: {
realm: string;
client: string;
baseUrl: string;
secret: string;
};
aws: {
region: string;
emailQueueUrl: string;
accessKeyId: string;
secretAccessKey: string;
};
sentry: {
dsn: string;
environment: string;
tracesSampleRate: number;
};
// ... more sections
}Implementation Pattern
File: /blog-api/src/config/unifiedConfig.ts
import * as fs from 'fs';
import * as path from 'path';
import * as ini from 'ini';
const configPath = path.join(__dirname, '../../config.ini');
const iniConfig = ini.parse(fs.readFileSync(configPath, 'utf-8'));
export const config: UnifiedConfig = {
database: {
host: iniConfig.database?.host || process.env.DB_HOST || 'localhost',
port: parseInt(iniConfig.database?.port || process.env.DB_PORT || '3306'),
username: iniConfig.database?.username || process.env.DB_USER || 'root',
password: iniConfig.database?.password || process.env.DB_PASSWORD || '',
database: iniConfig.database?.database || process.env.DB_NAME || 'blog_dev',
},
server: {
port: parseInt(iniConfig.server?.port || process.env.PORT || '3002'),
sessionSecret: iniConfig.server?.sessionSecret || process.env.SESSION_SECRET || 'dev-secret',
},
// ... more configuration
};
// Validate critical config
if (!config.tokens.jwt) {
throw new Error('JWT secret not configured!');
}Key Points:
- Read from config.ini first
- Fallback to process.env
- Default values for development
- Validation at startup
- Type-safe access
---
Environment-Specific Configs
config.ini Structure
[database]
host = localhost
port = 3306
username = root
password = password1
database = blog_dev
[server]
port = 3002
sessionSecret = your-secret-here
[tokens]
jwt = your-jwt-secret
inactivity = 30m
internal = internal-api-token
[keycloak]
realm = myapp
client = myapp-client
baseUrl = http://localhost:8080
secret = keycloak-client-secret
[sentry]
dsn = https://your-sentry-dsn
environment = development
tracesSampleRate = 0.1Environment Overrides
# .env file (optional overrides)
DB_HOST=production-db.example.com
DB_PASSWORD=secure-password
PORT=80Precedence: 1. config.ini (highest priority) 2. process.env variables 3. Hard-coded defaults (lowest priority)
---
Secrets Management
DO NOT Commit Secrets
# .gitignore
config.ini
.env
sentry.ini
*.pem
*.keyUse Environment Variables in Production
// Development: config.ini
// Production: Environment variables
export const config: UnifiedConfig = {
database: {
password: process.env.DB_PASSWORD || iniConfig.database?.password || '',
},
tokens: {
jwt: process.env.JWT_SECRET || iniConfig.tokens?.jwt || '',
},
};---
Migration Guide
Find All process.env Usage
grep -r "process.env" blog-api/src/ --include="*.ts" | wc -lMigration Example
Before:
// Scattered throughout code
const timeout = parseInt(process.env.OPENID_HTTP_TIMEOUT_MS || '15000');
const keycloakUrl = process.env.KEYCLOAK_BASE_URL;
const jwtSecret = process.env.JWT_SECRET;After:
import { config } from './config/unifiedConfig';
const timeout = config.keycloak.timeout;
const keycloakUrl = config.keycloak.baseUrl;
const jwtSecret = config.tokens.jwt;Benefits:
- Type-safe
- Centralized
- Easy to test
- Validated at startup
---
Related Files:
- SKILL.md
- testing-guide.md
Database Patterns - Prisma Best Practices
Complete guide to database access patterns using Prisma in backend microservices.
Table of Contents
- PrismaService Usage
- Repository Pattern
- Transaction Patterns
- Query Optimization
- N+1 Query Prevention
- Error Handling
---
PrismaService Usage
Basic Pattern
import { PrismaService } from '@project-lifecycle-portal/database';
// Always use PrismaService.main
const users = await PrismaService.main.user.findMany();Check Availability
if (!PrismaService.isAvailable) {
throw new Error('Prisma client not initialized');
}
const user = await PrismaService.main.user.findUnique({ where: { id } });---
Repository Pattern
Why Use Repositories
✅ Use repositories when:
- Complex queries with joins/includes
- Query used in multiple places
- Need caching layer
- Want to mock for testing
❌ Skip repositories for:
- Simple one-off queries
- Prototyping (can refactor later)
Repository Template
export class UserRepository {
async findById(id: string): Promise<User | null> {
return PrismaService.main.user.findUnique({
where: { id },
include: { profile: true },
});
}
async findActive(): Promise<User[]> {
return PrismaService.main.user.findMany({
where: { isActive: true },
orderBy: { createdAt: 'desc' },
});
}
async create(data: Prisma.UserCreateInput): Promise<User> {
return PrismaService.main.user.create({ data });
}
}---
Transaction Patterns
Simple Transaction
const result = await PrismaService.main.$transaction(async (tx) => {
const user = await tx.user.create({ data: userData });
const profile = await tx.userProfile.create({ data: { userId: user.id } });
return { user, profile };
});Interactive Transaction
const result = await PrismaService.main.$transaction(
async (tx) => {
const user = await tx.user.findUnique({ where: { id } });
if (!user) throw new Error('User not found');
return await tx.user.update({
where: { id },
data: { lastLogin: new Date() },
});
},
{
maxWait: 5000,
timeout: 10000,
}
);---
Query Optimization
Use select to Limit Fields
// ❌ Fetches all fields
const users = await PrismaService.main.user.findMany();
// ✅ Only fetch needed fields
const users = await PrismaService.main.user.findMany({
select: {
id: true,
email: true,
profile: { select: { firstName: true, lastName: true } },
},
});Use include Carefully
// ❌ Excessive includes
const user = await PrismaService.main.user.findUnique({
where: { id },
include: {
profile: true,
posts: { include: { comments: true } },
workflows: { include: { steps: { include: { actions: true } } } },
},
});
// ✅ Only include what you need
const user = await PrismaService.main.user.findUnique({
where: { id },
include: { profile: true },
});---
N+1 Query Prevention
Problem: N+1 Queries
// ❌ N+1 Query Problem
const users = await PrismaService.main.user.findMany(); // 1 query
for (const user of users) {
// N queries (one per user)
const profile = await PrismaService.main.userProfile.findUnique({
where: { userId: user.id },
});
}Solution: Use include or Batching
// ✅ Single query with include
const users = await PrismaService.main.user.findMany({
include: { profile: true },
});
// ✅ Or batch query
const userIds = users.map(u => u.id);
const profiles = await PrismaService.main.userProfile.findMany({
where: { userId: { in: userIds } },
});---
Error Handling
Prisma Error Types
import { Prisma } from '@prisma/client';
try {
await PrismaService.main.user.create({ data });
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
// Unique constraint violation
if (error.code === 'P2002') {
throw new ConflictError('Email already exists');
}
// Foreign key constraint
if (error.code === 'P2003') {
throw new ValidationError('Invalid reference');
}
// Record not found
if (error.code === 'P2025') {
throw new NotFoundError('Record not found');
}
}
// Unknown error
Sentry.captureException(error);
throw error;
}---
Related Files:
- SKILL.md
- services-and-repositories.md
- async-and-errors.md
Middleware Guide - Express Middleware Patterns
Complete guide to creating and using middleware in backend microservices.
Table of Contents
- Authentication Middleware
- Audit Middleware with AsyncLocalStorage
- Error Boundary Middleware
- Validation Middleware
- Composable Middleware
- Middleware Ordering
---
Authentication Middleware
SSOMiddleware Pattern
File: /form/src/middleware/SSOMiddleware.ts
export class SSOMiddlewareClient {
static verifyLoginStatus(req: Request, res: Response, next: NextFunction): void {
const token = req.cookies.refresh_token;
if (!token) {
return res.status(401).json({ error: 'Not authenticated' });
}
try {
const decoded = jwt.verify(token, config.tokens.jwt);
res.locals.claims = decoded;
res.locals.effectiveUserId = decoded.sub;
next();
} catch (error) {
res.status(401).json({ error: 'Invalid token' });
}
}
}---
Audit Middleware with AsyncLocalStorage
Excellent Pattern from Blog API
File: /form/src/middleware/auditMiddleware.ts
import { AsyncLocalStorage } from 'async_hooks';
export interface AuditContext {
userId: string;
userName?: string;
impersonatedBy?: string;
sessionId?: string;
timestamp: Date;
requestId: string;
}
export const auditContextStorage = new AsyncLocalStorage<AuditContext>();
export function auditMiddleware(req: Request, res: Response, next: NextFunction): void {
const context: AuditContext = {
userId: res.locals.effectiveUserId || 'anonymous',
userName: res.locals.claims?.preferred_username,
impersonatedBy: res.locals.isImpersonating ? res.locals.originalUserId : undefined,
timestamp: new Date(),
requestId: req.id || uuidv4(),
};
auditContextStorage.run(context, () => {
next();
});
}
// Getter for current context
export function getAuditContext(): AuditContext | null {
return auditContextStorage.getStore() || null;
}Benefits:
- Context propagates through entire request
- No need to pass context through every function
- Automatically available in services, repositories
- Type-safe context access
Usage in Services:
import { getAuditContext } from '../middleware/auditMiddleware';
async function someOperation() {
const context = getAuditContext();
console.log('Operation by:', context?.userId);
}---
Error Boundary Middleware
Comprehensive Error Handler
File: /form/src/middleware/errorBoundary.ts
export function errorBoundary(
error: Error,
req: Request,
res: Response,
next: NextFunction
): void {
// Determine status code
const statusCode = getStatusCodeForError(error);
// Capture to Sentry
Sentry.withScope((scope) => {
scope.setLevel(statusCode >= 500 ? 'error' : 'warning');
scope.setTag('error_type', error.name);
scope.setContext('error_details', {
message: error.message,
stack: error.stack,
});
Sentry.captureException(error);
});
// User-friendly response
res.status(statusCode).json({
success: false,
error: {
message: getUserFriendlyMessage(error),
code: error.name,
},
requestId: Sentry.getCurrentScope().getPropagationContext().traceId,
});
}
// Async wrapper
export function asyncErrorWrapper(
handler: (req: Request, res: Response, next: NextFunction) => Promise<any>
) {
return async (req: Request, res: Response, next: NextFunction) => {
try {
await handler(req, res, next);
} catch (error) {
next(error);
}
};
}---
Composable Middleware
withAuthAndAudit Pattern
export function withAuthAndAudit(...authMiddleware: any[]) {
return [
...authMiddleware,
auditMiddleware,
];
}
// Usage
router.post('/:formID/submit',
...withAuthAndAudit(SSOMiddlewareClient.verifyLoginStatus),
async (req, res) => controller.submit(req, res)
);---
Middleware Ordering
Critical Order (Must Follow)
// 1. Sentry request handler (FIRST)
app.use(Sentry.Handlers.requestHandler());
// 2. Body parsing
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// 3. Cookie parsing
app.use(cookieParser());
// 4. Auth initialization
app.use(SSOMiddleware.initialize());
// 5. Routes registered here
app.use('/api/users', userRoutes);
// 6. Error handler (AFTER routes)
app.use(errorBoundary);
// 7. Sentry error handler (LAST)
app.use(Sentry.Handlers.errorHandler());Rule: Error handlers MUST be registered AFTER all routes!
---
Related Files:
- SKILL.md
- routing-and-controllers.md
- async-and-errors.md
Routing and Controllers - Best Practices
Complete guide to clean route definitions and controller patterns.
Table of Contents
- Routes: Routing Only
- BaseController Pattern
- Good Examples
- Anti-Patterns
- Refactoring Guide
- Error Handling
- HTTP Status Codes
---
Routes: Routing Only
The Golden Rule
Routes should ONLY:
- ✅ Define route paths
- ✅ Register middleware
- ✅ Delegate to controllers
Routes should NEVER:
- ❌ Contain business logic
- ❌ Access database directly
- ❌ Implement validation logic (use Zod + controller)
- ❌ Format complex responses
- ❌ Handle complex error scenarios
Clean Route Pattern
// routes/userRoutes.ts
import { Router } from 'express';
import { UserController } from '../controllers/UserController';
import { SSOMiddlewareClient } from '../middleware/SSOMiddleware';
import { auditMiddleware } from '../middleware/auditMiddleware';
const router = Router();
const controller = new UserController();
// ✅ CLEAN: Route definition only
router.get('/:id',
SSOMiddlewareClient.verifyLoginStatus,
auditMiddleware,
async (req, res) => controller.getUser(req, res)
);
router.post('/',
SSOMiddlewareClient.verifyLoginStatus,
auditMiddleware,
async (req, res) => controller.createUser(req, res)
);
router.put('/:id',
SSOMiddlewareClient.verifyLoginStatus,
auditMiddleware,
async (req, res) => controller.updateUser(req, res)
);
export default router;Key Points:
- Each route: method, path, middleware chain, controller delegation
- No try-catch needed (controller handles errors)
- Clean, readable, maintainable
- Easy to see all endpoints at a glance
---
BaseController Pattern
Why BaseController?
Benefits:
- Consistent error handling across all controllers
- Automatic Sentry integration
- Standardized response formats
- Reusable helper methods
- Performance tracking utilities
- Logging and breadcrumb helpers
BaseController Pattern (Template)
File: /email/src/controllers/BaseController.ts
import * as Sentry from '@sentry/node';
import { Response } from 'express';
export abstract class BaseController {
/**
* Handle errors with Sentry integration
*/
protected handleError(
error: unknown,
res: Response,
context: string,
statusCode = 500
): void {
Sentry.withScope((scope) => {
scope.setTag('controller', this.constructor.name);
scope.setTag('operation', context);
scope.setUser({ id: res.locals?.claims?.userId });
if (error instanceof Error) {
scope.setContext('error_details', {
message: error.message,
stack: error.stack,
});
}
Sentry.captureException(error);
});
res.status(statusCode).json({
success: false,
error: {
message: error instanceof Error ? error.message : 'An error occurred',
code: statusCode,
},
});
}
/**
* Handle success responses
*/
protected handleSuccess<T>(
res: Response,
data: T,
message?: string,
statusCode = 200
): void {
res.status(statusCode).json({
success: true,
message,
data,
});
}
/**
* Performance tracking wrapper
*/
protected async withTransaction<T>(
name: string,
operation: string,
callback: () => Promise<T>
): Promise<T> {
return await Sentry.startSpan(
{ name, op: operation },
callback
);
}
/**
* Validate required fields
*/
protected validateRequest(
required: string[],
actual: Record<string, any>,
res: Response
): boolean {
const missing = required.filter((field) => !actual[field]);
if (missing.length > 0) {
Sentry.captureMessage(
`Missing required fields: ${missing.join(', ')}`,
'warning'
);
res.status(400).json({
success: false,
error: {
message: 'Missing required fields',
code: 'VALIDATION_ERROR',
details: { missing },
},
});
return false;
}
return true;
}
/**
* Logging helpers
*/
protected logInfo(message: string, context?: Record<string, any>): void {
Sentry.addBreadcrumb({
category: this.constructor.name,
message,
level: 'info',
data: context,
});
}
protected logWarning(message: string, context?: Record<string, any>): void {
Sentry.captureMessage(message, {
level: 'warning',
tags: { controller: this.constructor.name },
extra: context,
});
}
/**
* Add Sentry breadcrumb
*/
protected addBreadcrumb(
message: string,
category: string,
data?: Record<string, any>
): void {
Sentry.addBreadcrumb({ message, category, level: 'info', data });
}
/**
* Capture custom metric
*/
protected captureMetric(name: string, value: number, unit: string): void {
Sentry.metrics.gauge(name, value, { unit });
}
}Using BaseController
// controllers/UserController.ts
import { Request, Response } from 'express';
import { BaseController } from './BaseController';
import { UserService } from '../services/userService';
import { createUserSchema } from '../validators/userSchemas';
export class UserController extends BaseController {
private userService: UserService;
constructor() {
super();
this.userService = new UserService();
}
async getUser(req: Request, res: Response): Promise<void> {
try {
this.addBreadcrumb('Fetching user', 'user_controller', { userId: req.params.id });
const user = await this.userService.findById(req.params.id);
if (!user) {
return this.handleError(
new Error('User not found'),
res,
'getUser',
404
);
}
this.handleSuccess(res, user);
} catch (error) {
this.handleError(error, res, 'getUser');
}
}
async createUser(req: Request, res: Response): Promise<void> {
try {
// Validate input
const validated = createUserSchema.parse(req.body);
// Track performance
const user = await this.withTransaction(
'user.create',
'db.query',
() => this.userService.create(validated)
);
this.handleSuccess(res, user, 'User created successfully', 201);
} catch (error) {
this.handleError(error, res, 'createUser');
}
}
async updateUser(req: Request, res: Response): Promise<void> {
try {
const validated = updateUserSchema.parse(req.body);
const user = await this.userService.update(req.params.id, validated);
this.handleSuccess(res, user, 'User updated');
} catch (error) {
this.handleError(error, res, 'updateUser');
}
}
}Benefits:
- Consistent error handling
- Automatic Sentry integration
- Performance tracking
- Clean, readable code
- Easy to test
---
Good Examples
Example 1: Email Notification Routes (Excellent ✅)
File: /email/src/routes/notificationRoutes.ts
import { Router } from 'express';
import { NotificationController } from '../controllers/NotificationController';
import { SSOMiddlewareClient } from '../middleware/SSOMiddleware';
const router = Router();
const controller = new NotificationController();
// ✅ EXCELLENT: Clean delegation
router.get('/',
SSOMiddlewareClient.verifyLoginStatus,
async (req, res) => controller.getNotifications(req, res)
);
router.post('/',
SSOMiddlewareClient.verifyLoginStatus,
async (req, res) => controller.createNotification(req, res)
);
router.put('/:id/read',
SSOMiddlewareClient.verifyLoginStatus,
async (req, res) => controller.markAsRead(req, res)
);
export default router;What Makes This Excellent:
- Zero business logic in routes
- Clear middleware chain
- Consistent pattern
- Easy to understand
Example 2: Proxy Routes with Validation (Good ✅)
File: /form/src/routes/proxyRoutes.ts
import { z } from 'zod';
const createProxySchema = z.object({
originalUserID: z.string().min(1),
proxyUserID: z.string().min(1),
startsAt: z.string().datetime(),
expiresAt: z.string().datetime(),
});
router.post('/',
SSOMiddlewareClient.verifyLoginStatus,
async (req, res) => {
try {
const validated = createProxySchema.parse(req.body);
const proxy = await proxyService.createProxyRelationship(validated);
res.status(201).json({ success: true, data: proxy });
} catch (error) {
handler.handleException(res, error);
}
}
);What Makes This Good:
- Zod validation
- Delegates to service
- Proper HTTP status codes
- Error handling
Could Be Better:
- Move validation to controller
- Use BaseController
---
Anti-Patterns
Anti-Pattern 1: Business Logic in Routes (Bad ❌)
File: /form/src/routes/responseRoutes.ts (actual production code)
// ❌ ANTI-PATTERN: 200+ lines of business logic in route
router.post('/:formID/submit', async (req: Request, res: Response) => {
try {
const username = res.locals.claims.preferred_username;
const responses = req.body.responses;
const stepInstanceId = req.body.stepInstanceId;
// ❌ Permission checking in route
const userId = await userProfileService.getProfileByEmail(username).then(p => p.id);
const canComplete = await permissionService.canCompleteStep(userId, stepInstanceId);
if (!canComplete) {
return res.status(403).json({ error: 'No permission' });
}
// ❌ Workflow logic in route
const { createWorkflowEngine, CompleteStepCommand } = require('../workflow/core/WorkflowEngineV3');
const engine = await createWorkflowEngine();
const command = new CompleteStepCommand(
stepInstanceId,
userId,
responses,
additionalContext
);
const events = await engine.executeCommand(command);
// ❌ Impersonation handling in route
if (res.locals.isImpersonating) {
impersonationContextStore.storeContext(stepInstanceId, {
originalUserId: res.locals.originalUserId,
effectiveUserId: userId,
});
}
// ❌ Response processing in route
const post = await PrismaService.main.post.findUnique({
where: { id: postData.id },
include: { comments: true },
});
// ❌ Permission check in route
await checkPostPermissions(post, userId);
// ... 100+ more lines of business logic
res.json({ success: true, data: result });
} catch (e) {
handler.handleException(res, e);
}
});Why This Is Terrible:
- 200+ lines of business logic
- Hard to test (requires HTTP mocking)
- Hard to reuse (tied to route)
- Mixed responsibilities
- Difficult to debug
- Performance tracking difficult
How to Refactor (Step-by-Step)
Step 1: Create Controller
// controllers/PostController.ts
export class PostController extends BaseController {
private postService: PostService;
constructor() {
super();
this.postService = new PostService();
}
async createPost(req: Request, res: Response): Promise<void> {
try {
const validated = createPostSchema.parse({
...req.body,
});
const result = await this.postService.createPost(
validated,
res.locals.userId
);
this.handleSuccess(res, result, 'Post created successfully');
} catch (error) {
this.handleError(error, res, 'createPost');
}
}
}Step 2: Create Service
// services/postService.ts
export class PostService {
async createPost(
data: CreatePostDTO,
userId: string
): Promise<PostResult> {
// Permission check
const canCreate = await permissionService.canCreatePost(userId);
if (!canCreate) {
throw new ForbiddenError('No permission to create post');
}
// Execute workflow
const engine = await createWorkflowEngine();
const command = new CompleteStepCommand(/* ... */);
const events = await engine.executeCommand(command);
// Handle impersonation if needed
if (context.isImpersonating) {
await this.handleImpersonation(data.stepInstanceId, context);
}
// Synchronize roles
await this.synchronizeRoles(events, userId);
return { events, success: true };
}
private async handleImpersonation(stepInstanceId: number, context: any) {
impersonationContextStore.storeContext(stepInstanceId, {
originalUserId: context.originalUserId,
effectiveUserId: context.effectiveUserId,
});
}
private async synchronizeRoles(events: WorkflowEvent[], userId: string) {
// Role synchronization logic
}
}Step 3: Update Route
// routes/postRoutes.ts
import { PostController } from '../controllers/PostController';
const router = Router();
const controller = new PostController();
// ✅ CLEAN: Just routing
router.post('/',
SSOMiddlewareClient.verifyLoginStatus,
auditMiddleware,
async (req, res) => controller.createPost(req, res)
);Result:
- Route: 8 lines (was 200+)
- Controller: 25 lines (request handling)
- Service: 50 lines (business logic)
- Testable, reusable, maintainable!
---
Error Handling
Controller Error Handling
async createUser(req: Request, res: Response): Promise<void> {
try {
const result = await this.userService.create(req.body);
this.handleSuccess(res, result, 'User created', 201);
} catch (error) {
// BaseController.handleError automatically:
// - Captures to Sentry with context
// - Sets appropriate status code
// - Returns formatted error response
this.handleError(error, res, 'createUser');
}
}Custom Error Status Codes
async getUser(req: Request, res: Response): Promise<void> {
try {
const user = await this.userService.findById(req.params.id);
if (!user) {
// Custom 404 status
return this.handleError(
new Error('User not found'),
res,
'getUser',
404 // Custom status code
);
}
this.handleSuccess(res, user);
} catch (error) {
this.handleError(error, res, 'getUser');
}
}Validation Errors
async createUser(req: Request, res: Response): Promise<void> {
try {
const validated = createUserSchema.parse(req.body);
const user = await this.userService.create(validated);
this.handleSuccess(res, user, 'User created', 201);
} catch (error) {
// Zod errors get 400 status
if (error instanceof z.ZodError) {
return this.handleError(error, res, 'createUser', 400);
}
this.handleError(error, res, 'createUser');
}
}---
HTTP Status Codes
Standard Codes
| Code | Use Case | Example |
|---|---|---|
| 200 | Success (GET, PUT) | User retrieved, Updated |
| 201 | Created (POST) | User created |
| 204 | No Content (DELETE) | User deleted |
| 400 | Bad Request | Invalid input data |
| 401 | Unauthorized | Not authenticated |
| 403 | Forbidden | No permission |
| 404 | Not Found | Resource doesn't exist |
| 409 | Conflict | Duplicate resource |
| 422 | Unprocessable Entity | Validation failed |
| 500 | Internal Server Error | Unexpected error |
Usage Examples
// 200 - Success (default)
this.handleSuccess(res, user);
// 201 - Created
this.handleSuccess(res, user, 'Created', 201);
// 400 - Bad Request
this.handleError(error, res, 'operation', 400);
// 404 - Not Found
this.handleError(new Error('Not found'), res, 'operation', 404);
// 403 - Forbidden
this.handleError(new ForbiddenError('No permission'), res, 'operation', 403);---
Refactoring Guide
Identify Routes Needing Refactoring
Red Flags:
- Route file > 100 lines
- Multiple try-catch blocks in one route
- Direct database access (Prisma calls)
- Complex business logic (if statements, loops)
- Permission checks in routes
Check your routes:
# Find large route files
wc -l form/src/routes/*.ts | sort -n
# Find routes with Prisma usage
grep -r "PrismaService" form/src/routes/Refactoring Process
1. Extract to Controller:
// Before: Route with logic
router.post('/action', async (req, res) => {
try {
// 50 lines of logic
} catch (e) {
handler.handleException(res, e);
}
});
// After: Clean route
router.post('/action', (req, res) => controller.performAction(req, res));
// New controller method
async performAction(req: Request, res: Response): Promise<void> {
try {
const result = await this.service.performAction(req.body);
this.handleSuccess(res, result);
} catch (error) {
this.handleError(error, res, 'performAction');
}
}2. Extract to Service:
// Controller stays thin
async performAction(req: Request, res: Response): Promise<void> {
try {
const validated = actionSchema.parse(req.body);
const result = await this.actionService.execute(validated);
this.handleSuccess(res, result);
} catch (error) {
this.handleError(error, res, 'performAction');
}
}
// Service contains business logic
export class ActionService {
async execute(data: ActionDTO): Promise<Result> {
// All business logic here
// Permission checks
// Database operations
// Complex transformations
return result;
}
}3. Add Repository (if needed):
// Service calls repository
export class ActionService {
constructor(private actionRepository: ActionRepository) {}
async execute(data: ActionDTO): Promise<Result> {
// Business logic
const entity = await this.actionRepository.findById(data.id);
// More logic
return await this.actionRepository.update(data.id, changes);
}
}
// Repository handles data access
export class ActionRepository {
async findById(id: number): Promise<Entity | null> {
return PrismaService.main.entity.findUnique({ where: { id } });
}
async update(id: number, data: Partial<Entity>): Promise<Entity> {
return PrismaService.main.entity.update({ where: { id }, data });
}
}---
Related Files:
- SKILL.md - Main guide
- services-and-repositories.md - Service layer details
- complete-examples.md - Full refactoring examples
Sentry Integration and Monitoring
Complete guide to error tracking and performance monitoring with Sentry v8.
Table of Contents
- Core Principles
- Sentry Initialization
- Error Capture Patterns
- Performance Monitoring
- Cron Job Monitoring
- Error Context Best Practices
- Common Mistakes
---
Core Principles
MANDATORY: All errors MUST be captured to Sentry. No exceptions.
ALL ERRORS MUST BE CAPTURED - Use Sentry v8 with comprehensive error tracking across all services.
---
Sentry Initialization
instrument.ts Pattern
Location: src/instrument.ts (MUST be first import in server.ts and all cron jobs)
Template for Microservices:
import * as Sentry from '@sentry/node';
import * as fs from 'fs';
import * as path from 'path';
import * as ini from 'ini';
const sentryConfigPath = path.join(__dirname, '../sentry.ini');
const sentryConfig = ini.parse(fs.readFileSync(sentryConfigPath, 'utf-8'));
Sentry.init({
dsn: sentryConfig.sentry?.dsn,
environment: process.env.NODE_ENV || 'development',
tracesSampleRate: parseFloat(sentryConfig.sentry?.tracesSampleRate || '0.1'),
profilesSampleRate: parseFloat(sentryConfig.sentry?.profilesSampleRate || '0.1'),
integrations: [
...Sentry.getDefaultIntegrations({}),
Sentry.extraErrorDataIntegration({ depth: 5 }),
Sentry.localVariablesIntegration(),
Sentry.requestDataIntegration({
include: {
cookies: false,
data: true,
headers: true,
ip: true,
query_string: true,
url: true,
user: { id: true, email: true, username: true },
},
}),
Sentry.consoleIntegration(),
Sentry.contextLinesIntegration(),
Sentry.prismaIntegration(),
],
beforeSend(event, hint) {
// Filter health checks
if (event.request?.url?.includes('/healthcheck')) {
return null;
}
// Scrub sensitive headers
if (event.request?.headers) {
delete event.request.headers['authorization'];
delete event.request.headers['cookie'];
}
// Mask emails for PII
if (event.user?.email) {
event.user.email = event.user.email.replace(/^(.{2}).*(@.*)$/, '$1***$2');
}
return event;
},
ignoreErrors: [
/^Invalid JWT/,
/^JWT expired/,
'NetworkError',
],
});
// Set service context
Sentry.setTags({
service: 'form',
version: '1.0.1',
});
Sentry.setContext('runtime', {
node_version: process.version,
platform: process.platform,
});Critical Points:
- PII protection built-in (beforeSend)
- Filter non-critical errors
- Comprehensive integrations
- Prisma instrumentation
- Service-specific tagging
---
Error Capture Patterns
1. BaseController Pattern
// Use BaseController.handleError
protected handleError(error: unknown, res: Response, context: string, statusCode = 500): void {
Sentry.withScope((scope) => {
scope.setTag('controller', this.constructor.name);
scope.setTag('operation', context);
scope.setUser({ id: res.locals?.claims?.userId });
Sentry.captureException(error);
});
res.status(statusCode).json({
success: false,
error: { message: error instanceof Error ? error.message : 'Error occurred' }
});
}2. Workflow Error Handling
import { SentryHelper } from '../utils/sentryHelper';
try {
await businessOperation();
} catch (error) {
SentryHelper.captureOperationError(error, {
operationType: 'POST_CREATION',
entityId: 123,
userId: 'user-123',
operation: 'createPost',
});
throw error;
}3. Service Layer Error Handling
try {
await someOperation();
} catch (error) {
Sentry.captureException(error, {
tags: {
service: 'form',
operation: 'someOperation'
},
extra: {
userId: currentUser.id,
entityId: 123
}
});
throw error;
}---
Performance Monitoring
Database Performance Tracking
import { DatabasePerformanceMonitor } from '../utils/databasePerformance';
const result = await DatabasePerformanceMonitor.withPerformanceTracking(
'findMany',
'UserProfile',
async () => {
return await PrismaService.main.userProfile.findMany({ take: 5 });
}
);API Endpoint Spans
router.post('/operation', async (req, res) => {
return await Sentry.startSpan({
name: 'operation.execute',
op: 'http.server',
attributes: {
'http.method': 'POST',
'http.route': '/operation'
}
}, async () => {
const result = await performOperation();
res.json(result);
});
});---
Cron Job Monitoring
Mandatory Pattern
#!/usr/bin/env node
import '../instrument'; // FIRST LINE after shebang
import * as Sentry from '@sentry/node';
async function main() {
return await Sentry.startSpan({
name: 'cron.job-name',
op: 'cron',
attributes: {
'cron.job': 'job-name',
'cron.startTime': new Date().toISOString(),
}
}, async () => {
try {
// Cron job logic here
} catch (error) {
Sentry.captureException(error, {
tags: {
'cron.job': 'job-name',
'error.type': 'execution_error'
}
});
console.error('[Cron] Error:', error);
process.exit(1);
}
});
}
main().then(() => {
console.log('[Cron] Completed successfully');
process.exit(0);
}).catch((error) => {
console.error('[Cron] Fatal error:', error);
process.exit(1);
});---
Error Context Best Practices
Rich Context Example
Sentry.withScope((scope) => {
// User context
scope.setUser({
id: user.id,
email: user.email,
username: user.username
});
// Tags for filtering
scope.setTag('service', 'form');
scope.setTag('endpoint', req.path);
scope.setTag('method', req.method);
// Structured context
scope.setContext('operation', {
type: 'workflow.complete',
workflowId: 123,
stepId: 456
});
// Breadcrumbs for timeline
scope.addBreadcrumb({
category: 'workflow',
message: 'Starting step completion',
level: 'info',
data: { stepId: 456 }
});
Sentry.captureException(error);
});---
Common Mistakes
// ❌ Swallowing errors
try {
await riskyOperation();
} catch (error) {
// Silent failure
}
// ❌ Generic error messages
throw new Error('Error occurred');
// ❌ Exposing sensitive data
Sentry.captureException(error, {
extra: { password: user.password } // NEVER
});
// ❌ Missing async error handling
async function bad() {
fetchData().then(data => processResult(data)); // Unhandled
}
// ✅ Proper async handling
async function good() {
try {
const data = await fetchData();
processResult(data);
} catch (error) {
Sentry.captureException(error);
throw error;
}
}---
Related Files:
- SKILL.md
- routing-and-controllers.md
- async-and-errors.md
Services and Repositories - Business Logic Layer
Complete guide to organizing business logic with services and data access with repositories.
Table of Contents
- Service Layer Overview
- Dependency Injection Pattern
- Singleton Pattern
- Repository Pattern
- Service Design Principles
- Caching Strategies
- Testing Services
---
Service Layer Overview
Purpose of Services
Services contain business logic - the 'what' and 'why' of your application:
Controller asks: "Should I do this?"
Service answers: "Yes/No, here's why, and here's what happens"
Repository executes: "Here's the data you requested"Services are responsible for:
- ✅ Business rules enforcement
- ✅ Orchestrating multiple repositories
- ✅ Transaction management
- ✅ Complex calculations
- ✅ External service integration
- ✅ Business validations
Services should NOT:
- ❌ Know about HTTP (Request/Response)
- ❌ Direct Prisma access (use repositories)
- ❌ Handle route-specific logic
- ❌ Format HTTP responses
---
Dependency Injection Pattern
Why Dependency Injection?
Benefits:
- Easy to test (inject mocks)
- Clear dependencies
- Flexible configuration
- Promotes loose coupling
Excellent Example: NotificationService
File: /blog-api/src/services/NotificationService.ts
// Define dependencies interface for clarity
export interface NotificationServiceDependencies {
prisma: PrismaClient;
batchingService: BatchingService;
emailComposer: EmailComposer;
}
// Service with dependency injection
export class NotificationService {
private prisma: PrismaClient;
private batchingService: BatchingService;
private emailComposer: EmailComposer;
private preferencesCache: Map<string, { preferences: UserPreference; timestamp: number }> = new Map();
private CACHE_TTL = (notificationConfig.preferenceCacheTTLMinutes || 5) * 60 * 1000;
// Dependencies injected via constructor
constructor(dependencies: NotificationServiceDependencies) {
this.prisma = dependencies.prisma;
this.batchingService = dependencies.batchingService;
this.emailComposer = dependencies.emailComposer;
}
/**
* Create a notification and route it appropriately
*/
async createNotification(params: CreateNotificationParams) {
const { recipientID, type, title, message, link, context = {}, channel = 'both', priority = NotificationPriority.NORMAL } = params;
try {
// Get template and render content
const template = getNotificationTemplate(type);
const rendered = renderNotificationContent(template, context);
// Create in-app notification record
const notificationId = await createNotificationRecord({
instanceId: parseInt(context.instanceId || '0', 10),
template: type,
recipientUserId: recipientID,
channel: channel === 'email' ? 'email' : 'inApp',
contextData: context,
title: finalTitle,
message: finalMessage,
link: finalLink,
});
// Route notification based on channel
if (channel === 'email' || channel === 'both') {
await this.routeNotification({
notificationId,
userId: recipientID,
type,
priority,
title: finalTitle,
message: finalMessage,
link: finalLink,
context,
});
}
return notification;
} catch (error) {
ErrorLogger.log(error, {
context: {
'[NotificationService] createNotification': {
type: params.type,
recipientID: params.recipientID,
},
},
});
throw error;
}
}
/**
* Route notification based on user preferences
*/
private async routeNotification(params: { notificationId: number; userId: string; type: string; priority: NotificationPriority; title: string; message: string; link?: string; context?: Record<string, any> }) {
// Get user preferences with caching
const preferences = await this.getUserPreferences(params.userId);
// Check if we should batch or send immediately
if (this.shouldBatchEmail(preferences, params.type, params.priority)) {
await this.batchingService.queueNotificationForBatch({
notificationId: params.notificationId,
userId: params.userId,
userPreference: preferences,
priority: params.priority,
});
} else {
// Send immediately via EmailComposer
await this.sendImmediateEmail({
userId: params.userId,
title: params.title,
message: params.message,
link: params.link,
context: params.context,
type: params.type,
});
}
}
/**
* Determine if email should be batched
*/
shouldBatchEmail(preferences: UserPreference, notificationType: string, priority: NotificationPriority): boolean {
// HIGH priority always immediate
if (priority === NotificationPriority.HIGH) {
return false;
}
// Check batch mode
const batchMode = preferences.emailBatchMode || BatchMode.IMMEDIATE;
return batchMode !== BatchMode.IMMEDIATE;
}
/**
* Get user preferences with caching
*/
async getUserPreferences(userId: string): Promise<UserPreference> {
// Check cache first
const cached = this.preferencesCache.get(userId);
if (cached && Date.now() - cached.timestamp < this.CACHE_TTL) {
return cached.preferences;
}
const preference = await this.prisma.userPreference.findUnique({
where: { userID: userId },
});
const finalPreferences = preference || DEFAULT_PREFERENCES;
// Update cache
this.preferencesCache.set(userId, {
preferences: finalPreferences,
timestamp: Date.now(),
});
return finalPreferences;
}
}Usage in Controller:
// Instantiate with dependencies
const notificationService = new NotificationService({
prisma: PrismaService.main,
batchingService: new BatchingService(PrismaService.main),
emailComposer: new EmailComposer(),
});
// Use in controller
const notification = await notificationService.createNotification({
recipientID: 'user-123',
type: 'AFRLWorkflowNotification',
context: { workflowName: 'AFRL Monthly Report' },
});Key Takeaways:
- Dependencies passed via constructor
- Clear interface defines required dependencies
- Easy to test (inject mocks)
- Encapsulated caching logic
- Business rules isolated from HTTP
---
Singleton Pattern
When to Use Singletons
Use for:
- Services with expensive initialization
- Services with shared state (caching)
- Services accessed from many places
- Permission services
- Configuration services
Example: PermissionService (Singleton)
File: /blog-api/src/services/permissionService.ts
import { PrismaClient } from '@prisma/client';
class PermissionService {
private static instance: PermissionService;
private prisma: PrismaClient;
private permissionCache: Map<string, { canAccess: boolean; timestamp: number }> = new Map();
private CACHE_TTL = 5 * 60 * 1000; // 5 minutes
// Private constructor prevents direct instantiation
private constructor() {
this.prisma = PrismaService.main;
}
// Get singleton instance
public static getInstance(): PermissionService {
if (!PermissionService.instance) {
PermissionService.instance = new PermissionService();
}
return PermissionService.instance;
}
/**
* Check if user can complete a workflow step
*/
async canCompleteStep(userId: string, stepInstanceId: number): Promise<boolean> {
const cacheKey = `${userId}:${stepInstanceId}`;
// Check cache
const cached = this.permissionCache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < this.CACHE_TTL) {
return cached.canAccess;
}
try {
const post = await this.prisma.post.findUnique({
where: { id: postId },
include: {
author: true,
comments: {
include: {
user: true,
},
},
},
});
if (!post) {
return false;
}
// Check if user has permission
const canEdit = post.authorId === userId ||
await this.isUserAdmin(userId);
// Cache result
this.permissionCache.set(cacheKey, {
canAccess: isAssigned,
timestamp: Date.now(),
});
return isAssigned;
} catch (error) {
console.error('[PermissionService] Error checking step permission:', error);
return false;
}
}
/**
* Clear cache for user
*/
clearUserCache(userId: string): void {
for (const [key] of this.permissionCache) {
if (key.startsWith(`${userId}:`)) {
this.permissionCache.delete(key);
}
}
}
/**
* Clear all cache
*/
clearCache(): void {
this.permissionCache.clear();
}
}
// Export singleton instance
export const permissionService = PermissionService.getInstance();Usage:
import { permissionService } from '../services/permissionService';
// Use anywhere in the codebase
const canComplete = await permissionService.canCompleteStep(userId, stepId);
if (!canComplete) {
throw new ForbiddenError('You do not have permission to complete this step');
}---
Repository Pattern
Purpose of Repositories
Repositories abstract data access - the 'how' of data operations:
Service: "Get me all active users sorted by name"
Repository: "Here's the Prisma query that does that"Repositories are responsible for:
- ✅ All Prisma operations
- ✅ Query construction
- ✅ Query optimization (select, include)
- ✅ Database error handling
- ✅ Caching database results
Repositories should NOT:
- ❌ Contain business logic
- ❌ Know about HTTP
- ❌ Make decisions (that's service layer)
Repository Template
// repositories/UserRepository.ts
import { PrismaService } from '@project-lifecycle-portal/database';
import type { User, Prisma } from '@project-lifecycle-portal/database';
export class UserRepository {
/**
* Find user by ID with optimized query
*/
async findById(userId: string): Promise<User | null> {
try {
return await PrismaService.main.user.findUnique({
where: { userID: userId },
select: {
userID: true,
email: true,
name: true,
isActive: true,
roles: true,
createdAt: true,
updatedAt: true,
},
});
} catch (error) {
console.error('[UserRepository] Error finding user by ID:', error);
throw new Error(`Failed to find user: ${userId}`);
}
}
/**
* Find all active users
*/
async findActive(options?: { orderBy?: Prisma.UserOrderByWithRelationInput }): Promise<User[]> {
try {
return await PrismaService.main.user.findMany({
where: { isActive: true },
orderBy: options?.orderBy || { name: 'asc' },
select: {
userID: true,
email: true,
name: true,
roles: true,
},
});
} catch (error) {
console.error('[UserRepository] Error finding active users:', error);
throw new Error('Failed to find active users');
}
}
/**
* Find user by email
*/
async findByEmail(email: string): Promise<User | null> {
try {
return await PrismaService.main.user.findUnique({
where: { email },
});
} catch (error) {
console.error('[UserRepository] Error finding user by email:', error);
throw new Error(`Failed to find user with email: ${email}`);
}
}
/**
* Create new user
*/
async create(data: Prisma.UserCreateInput): Promise<User> {
try {
return await PrismaService.main.user.create({ data });
} catch (error) {
console.error('[UserRepository] Error creating user:', error);
throw new Error('Failed to create user');
}
}
/**
* Update user
*/
async update(userId: string, data: Prisma.UserUpdateInput): Promise<User> {
try {
return await PrismaService.main.user.update({
where: { userID: userId },
data,
});
} catch (error) {
console.error('[UserRepository] Error updating user:', error);
throw new Error(`Failed to update user: ${userId}`);
}
}
/**
* Delete user (soft delete by setting isActive = false)
*/
async delete(userId: string): Promise<User> {
try {
return await PrismaService.main.user.update({
where: { userID: userId },
data: { isActive: false },
});
} catch (error) {
console.error('[UserRepository] Error deleting user:', error);
throw new Error(`Failed to delete user: ${userId}`);
}
}
/**
* Check if email exists
*/
async emailExists(email: string): Promise<boolean> {
try {
const count = await PrismaService.main.user.count({
where: { email },
});
return count > 0;
} catch (error) {
console.error('[UserRepository] Error checking email exists:', error);
throw new Error('Failed to check if email exists');
}
}
}
// Export singleton instance
export const userRepository = new UserRepository();Using Repository in Service:
// services/userService.ts
import { userRepository } from '../repositories/UserRepository';
import { ConflictError, NotFoundError } from '../utils/errors';
export class UserService {
/**
* Create new user with business rules
*/
async createUser(data: { email: string; name: string; roles: string[] }): Promise<User> {
// Business rule: Check if email already exists
const emailExists = await userRepository.emailExists(data.email);
if (emailExists) {
throw new ConflictError('Email already exists');
}
// Business rule: Validate roles
const validRoles = ['admin', 'operations', 'user'];
const invalidRoles = data.roles.filter((role) => !validRoles.includes(role));
if (invalidRoles.length > 0) {
throw new ValidationError(`Invalid roles: ${invalidRoles.join(', ')}`);
}
// Create user via repository
return await userRepository.create({
email: data.email,
name: data.name,
roles: data.roles,
isActive: true,
});
}
/**
* Get user by ID
*/
async getUser(userId: string): Promise<User> {
const user = await userRepository.findById(userId);
if (!user) {
throw new NotFoundError(`User not found: ${userId}`);
}
return user;
}
}---
Service Design Principles
1. Single Responsibility
Each service should have ONE clear purpose:
// ✅ GOOD - Single responsibility
class UserService {
async createUser() {}
async updateUser() {}
async deleteUser() {}
}
class EmailService {
async sendEmail() {}
async sendBulkEmails() {}
}
// ❌ BAD - Too many responsibilities
class UserService {
async createUser() {}
async sendWelcomeEmail() {} // Should be EmailService
async logUserActivity() {} // Should be AuditService
async processPayment() {} // Should be PaymentService
}2. Clear Method Names
Method names should describe WHAT they do:
// ✅ GOOD - Clear intent
async createNotification()
async getUserPreferences()
async shouldBatchEmail()
async routeNotification()
// ❌ BAD - Vague or misleading
async process()
async handle()
async doIt()
async execute()3. Return Types
Always use explicit return types:
// ✅ GOOD - Explicit types
async createUser(data: CreateUserDTO): Promise<User> {}
async findUsers(): Promise<User[]> {}
async deleteUser(id: string): Promise<void> {}
// ❌ BAD - Implicit any
async createUser(data) {} // No types!4. Error Handling
Services should throw meaningful errors:
// ✅ GOOD - Meaningful errors
if (!user) {
throw new NotFoundError(`User not found: ${userId}`);
}
if (emailExists) {
throw new ConflictError('Email already exists');
}
// ❌ BAD - Generic errors
if (!user) {
throw new Error('Error'); // What error?
}5. Avoid God Services
Don't create services that do everything:
// ❌ BAD - God service
class WorkflowService {
async startWorkflow() {}
async completeStep() {}
async assignRoles() {}
async sendNotifications() {} // Should be NotificationService
async validatePermissions() {} // Should be PermissionService
async logAuditTrail() {} // Should be AuditService
// ... 50 more methods
}
// ✅ GOOD - Focused services
class WorkflowService {
constructor(
private notificationService: NotificationService,
private permissionService: PermissionService,
private auditService: AuditService
) {}
async startWorkflow() {
// Orchestrate other services
await this.permissionService.checkPermission();
await this.workflowRepository.create();
await this.notificationService.notify();
await this.auditService.log();
}
}---
Caching Strategies
1. In-Memory Caching
class UserService {
private cache: Map<string, { user: User; timestamp: number }> = new Map();
private CACHE_TTL = 5 * 60 * 1000; // 5 minutes
async getUser(userId: string): Promise<User> {
// Check cache
const cached = this.cache.get(userId);
if (cached && Date.now() - cached.timestamp < this.CACHE_TTL) {
return cached.user;
}
// Fetch from database
const user = await userRepository.findById(userId);
// Update cache
if (user) {
this.cache.set(userId, { user, timestamp: Date.now() });
}
return user;
}
clearUserCache(userId: string): void {
this.cache.delete(userId);
}
}2. Cache Invalidation
class UserService {
async updateUser(userId: string, data: UpdateUserDTO): Promise<User> {
// Update in database
const user = await userRepository.update(userId, data);
// Invalidate cache
this.clearUserCache(userId);
return user;
}
}---
Testing Services
Unit Tests
// tests/userService.test.ts
import { UserService } from '../services/userService';
import { userRepository } from '../repositories/UserRepository';
import { ConflictError } from '../utils/errors';
// Mock repository
jest.mock('../repositories/UserRepository');
describe('UserService', () => {
let userService: UserService;
beforeEach(() => {
userService = new UserService();
jest.clearAllMocks();
});
describe('createUser', () => {
it('should create user when email does not exist', async () => {
// Arrange
const userData = {
email: 'test@example.com',
name: 'Test User',
roles: ['user'],
};
(userRepository.emailExists as jest.Mock).mockResolvedValue(false);
(userRepository.create as jest.Mock).mockResolvedValue({
userID: '123',
...userData,
});
// Act
const user = await userService.createUser(userData);
// Assert
expect(user).toBeDefined();
expect(user.email).toBe(userData.email);
expect(userRepository.emailExists).toHaveBeenCalledWith(userData.email);
expect(userRepository.create).toHaveBeenCalled();
});
it('should throw ConflictError when email exists', async () => {
// Arrange
const userData = {
email: 'existing@example.com',
name: 'Test User',
roles: ['user'],
};
(userRepository.emailExists as jest.Mock).mockResolvedValue(true);
// Act & Assert
await expect(userService.createUser(userData)).rejects.toThrow(ConflictError);
expect(userRepository.create).not.toHaveBeenCalled();
});
});
});---
Related Files:
- SKILL.md - Main guide
- routing-and-controllers.md - Controllers that use services
- database-patterns.md - Prisma and repository patterns
- complete-examples.md - Full service/repository examples
Testing Guide - Backend Testing Strategies
Complete guide to testing backend services with Jest and best practices.
Table of Contents
- Unit Testing
- Integration Testing
- Mocking Strategies
- Test Data Management
- Testing Authenticated Routes
- Coverage Targets
---
Unit Testing
Test Structure
// services/userService.test.ts
import { UserService } from './userService';
import { UserRepository } from '../repositories/UserRepository';
jest.mock('../repositories/UserRepository');
describe('UserService', () => {
let service: UserService;
let mockRepository: jest.Mocked<UserRepository>;
beforeEach(() => {
mockRepository = {
findByEmail: jest.fn(),
create: jest.fn(),
} as any;
service = new UserService();
(service as any).userRepository = mockRepository;
});
afterEach(() => {
jest.clearAllMocks();
});
describe('create', () => {
it('should throw error if email exists', async () => {
mockRepository.findByEmail.mockResolvedValue({ id: '123' } as any);
await expect(
service.create({ email: 'test@test.com' })
).rejects.toThrow('Email already in use');
});
it('should create user if email is unique', async () => {
mockRepository.findByEmail.mockResolvedValue(null);
mockRepository.create.mockResolvedValue({ id: '123' } as any);
const user = await service.create({
email: 'test@test.com',
firstName: 'John',
lastName: 'Doe',
});
expect(user).toBeDefined();
expect(mockRepository.create).toHaveBeenCalledWith(
expect.objectContaining({
email: 'test@test.com'
})
);
});
});
});---
Integration Testing
Test with Real Database
import { PrismaService } from '@project-lifecycle-portal/database';
describe('UserService Integration', () => {
let testUser: any;
beforeAll(async () => {
// Create test data
testUser = await PrismaService.main.user.create({
data: {
email: 'test@test.com',
profile: { create: { firstName: 'Test', lastName: 'User' } },
},
});
});
afterAll(async () => {
// Cleanup
await PrismaService.main.user.delete({ where: { id: testUser.id } });
});
it('should find user by email', async () => {
const user = await userService.findByEmail('test@test.com');
expect(user).toBeDefined();
expect(user?.email).toBe('test@test.com');
});
});---
Mocking Strategies
Mock PrismaService
jest.mock('@project-lifecycle-portal/database', () => ({
PrismaService: {
main: {
user: {
findMany: jest.fn(),
findUnique: jest.fn(),
create: jest.fn(),
update: jest.fn(),
},
},
isAvailable: true,
},
}));Mock Services
const mockUserService = {
findById: jest.fn(),
create: jest.fn(),
update: jest.fn(),
} as jest.Mocked<UserService>;---
Test Data Management
Setup and Teardown
describe('PermissionService', () => {
let instanceId: number;
beforeAll(async () => {
// Create test post
const post = await PrismaService.main.post.create({
data: { title: 'Test Post', content: 'Test', authorId: 'test-user' },
});
instanceId = post.id;
});
afterAll(async () => {
// Cleanup
await PrismaService.main.post.delete({
where: { id: instanceId },
});
});
beforeEach(() => {
// Clear caches
permissionService.clearCache();
});
it('should check permissions', async () => {
const hasPermission = await permissionService.checkPermission(
'user-id',
instanceId,
'VIEW_WORKFLOW'
);
expect(hasPermission).toBeDefined();
});
});---
Testing Authenticated Routes
Using test-auth-route.js
# Test authenticated endpoint
node scripts/test-auth-route.js http://localhost:3002/form/api/users
# Test with POST data
node scripts/test-auth-route.js http://localhost:3002/form/api/users POST '{"email":"test@test.com"}'Mock Authentication in Tests
// Mock auth middleware
jest.mock('../middleware/SSOMiddleware', () => ({
SSOMiddlewareClient: {
verifyLoginStatus: (req, res, next) => {
res.locals.claims = {
sub: 'test-user-id',
preferred_username: 'testuser',
};
next();
},
},
}));---
Coverage Targets
Recommended Coverage
- Unit Tests: 70%+ coverage
- Integration Tests: Critical paths covered
- E2E Tests: Happy paths covered
Run Coverage
npm test -- --coverage---
Related Files:
- SKILL.md
- services-and-repositories.md
- complete-examples.md
Related skills
How it compares
Use backend-dev-guidelines for opinionated Express plus Prisma layering rather than generic language-agnostic API advice.
FAQ
Can I put business logic in routes?
No. Routes only route. All business logic lives in services. Controllers coordinate between requests and services.
What is the BFRI formula and when do I use it?
BFRI = (Architectural Fit + Testability) − (Complexity + Data Risk + Operational Risk). Calculate before implementing features to assess feasibility (6+ safe, 3-5 moderate, 0-2 risky, <0 dangerous).
Why must all config come from unifiedConfig?
Centralized config prevents secrets leakage, enables environment-specific overrides, and eliminates scattered process.env calls that break observability and testing.
Is Backend Dev Guidelines safe to install?
skills.sh reports 3 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.