Layer Responsibility: The Service Layer contains all business logic, validation rules, authorization checks, and coordinates data operations. It never handles UI concerns or direct database queries.
Perfect for: Centralizing business rules, ensuring data integrity, implementing security policies, and maintaining clean separation between UI and data layers.
Service Layer Architecture
The Service Layer is organized into specialized modules for different concerns:Service Layer Organization:Key Principles:
src/services/ # 📁 Service Layer Root
├── facades/ # 🎭 Interface Layer
│ ├── user-service-facade.ts
│ ├── subscription-service-facade.ts
│ └── organization-service-facade.ts
├── validation/ # ✅ Input Validation
│ ├── user-validation.ts
│ ├── subscription-validation.ts
│ └── common-validation.ts
├── authorization/ # 🔐 Access Control
│ ├── user-authorization.ts
│ ├── subscription-authorization.ts
│ └── rbac-permissions.ts
├── errors/ # 🚨 Custom Errors
│ ├── validation-error.ts
│ ├── authorization-error.ts
│ └── business-error.ts
├── interceptors/ # 🔄 Cross-cutting Concerns
│ ├── logging-interceptor.ts
│ ├── audit-interceptor.ts
│ └── performance-interceptor.ts
├── helpers/ # 🛠️ Server-side Utilities
│ ├── billing-helper.ts
│ └── email-helper.ts
├── types/ # 📋 Type Definitions
│ ├── domain/ # Domain types
│ └── common-types.ts
├── __tests__/ # 🧪 Service Tests
└── [feature]-service.ts # Core service files
- Single Responsibility - Each service handles one domain
- Dependency Injection - Services depend on abstractions
- Layered Validation - Input validation before business logic
- Authorization First - Permission checks before operations
Validation Layer
The validation layer ensures data integrity with Zod schemas and custom validation rules:Zod Validation Schemas:Validation Features:
// src/services/validation/user-validation.ts
import { z } from 'zod'
// Base schemas
export const userUuidSchema = z.string().uuid("Invalid user ID format")
export const createUserSchema = z.object({
name: z.string()
.min(2, "Name must be at least 2 characters")
.max(100, "Name must be under 100 characters")
.regex(/^[a-zA-Z\s]+$/, "Name can only contain letters and spaces"),
email: z.string()
.email("Invalid email format")
.toLowerCase()
.refine(async (email) => {
// Custom async validation
const existing = await getUserByEmailDao(email)
return !existing
}, "Email already exists"),
password: z.string()
.min(8, "Password must be at least 8 characters")
.regex(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
"Password must contain uppercase, lowercase, and number"),
organizationId: z.string().uuid().optional(),
metadata: z.record(z.string()).optional()
})
export const updateUserSchema = createUserSchema.partial().extend({
id: userUuidSchema
})
// Type inference
export type CreateUserInput = z.infer<typeof createUserSchema>
export type UpdateUserInput = z.infer<typeof updateUserSchema>- Type Safety - Automatic TypeScript types
- Custom Rules - Business-specific validation
- Async Validation - Database checks, API calls
- Internationalization - Localized error messages
Authorization Layer
The authorization layer implements Role-Based Access Control (RBAC) using CASL:Permission Definitions:Authorization Functions:
// src/services/authorization/permissions.ts
import { AbilityBuilder, createMongoAbility } from '@casl/ability'
export type Actions =
| 'create' | 'read' | 'update' | 'delete'
| 'manage' // Special action that represents all actions
export type Subjects =
| 'User' | 'Subscription' | 'Organization'
| 'all' // Special subject that represents all subjects
export const defineAbilityFor = (user: User) => {
const { can, cannot, build } = new AbilityBuilder(createMongoAbility)
// Super Admin - can do everything
if (user.role === 'SUPER_ADMIN') {
can('manage', 'all')
return build()
}
// Admin - can manage users and organizations
if (user.role === 'ADMIN') {
can('manage', 'User')
can('manage', 'Organization')
can('read', 'Subscription')
return build()
}
// User - can manage own data
if (user.role === 'USER') {
can('read', 'User', { id: user.id })
can('update', 'User', { id: user.id })
can('read', 'Subscription', { userId: user.id })
can('update', 'Subscription', { userId: user.id })
// Organization permissions
if (user.organizationId) {
can('read', 'Organization', { id: user.organizationId })
can('update', 'Organization', { id: user.organizationId })
}
return build()
}
// Guest - read only
can('read', 'User', { id: user.id })
return build()
}// src/services/authorization/user-authorization.ts
import { getCurrentUser } from '@/lib/auth'
import { defineAbilityFor } from './permissions'
export async function canReadUser(userId: string): Promise<boolean> {
const currentUser = await getCurrentUser()
if (!currentUser) return false
const ability = defineAbilityFor(currentUser)
return ability.can('read', 'User', { id: userId })
}
export async function canUpdateUser(userId: string): Promise<boolean> {
const currentUser = await getCurrentUser()
if (!currentUser) return false
const ability = defineAbilityFor(currentUser)
return ability.can('update', 'User', { id: userId })
}
export async function canDeleteUser(userId: string): Promise<boolean> {
const currentUser = await getCurrentUser()
if (!currentUser) return false
const ability = defineAbilityFor(currentUser)
return ability.can('delete', 'User', { id: userId })
}Interceptors and Cross-Cutting Concerns
Interceptors handle cross-cutting concerns like logging, auditing, and performance monitoring:Logging Implementation:Structured Logging:
// src/services/interceptors/logging-interceptor.ts
type ServiceFunction = (...args: any[]) => Promise<any>
export function withLogging<T extends ServiceFunction>(
serviceFn: T,
context: string
): T {
return (async (...args: any[]) => {
const startTime = Date.now()
const requestId = generateRequestId()
console.log(`[${context}] Starting operation`, {
requestId,
args: sanitizeArgs(args),
timestamp: new Date().toISOString()
})
try {
const result = await serviceFn(...args)
console.log(`[${context}] Operation completed`, {
requestId,
duration: Date.now() - startTime,
success: true
})
return result
} catch (error) {
console.error(`[${context}] Operation failed`, {
requestId,
duration: Date.now() - startTime,
error: error.message,
stack: error.stack
})
throw error
}
}) as T
}
// Usage
export const getUserByIdService = withLogging(
async (id: string) => {
// Service implementation
},
'getUserByIdService'
)interface LogEntry {
timestamp: string
level: 'info' | 'warn' | 'error'
service: string
operation: string
requestId: string
userId?: string
duration?: number
error?: string
metadata?: Record<string, any>
}
export class Logger {
static info(message: string, metadata: Partial<LogEntry>) {
const entry: LogEntry = {
timestamp: new Date().toISOString(),
level: 'info',
service: metadata.service || 'unknown',
operation: metadata.operation || 'unknown',
requestId: metadata.requestId || generateRequestId(),
...metadata
}
console.log(JSON.stringify(entry))
}
}Testing Services
Service testing focuses on business logic, validation, and authorization:Test Structure:Test Coverage Areas:
// src/services/__tests__/user-service.test.ts
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { getUserByIdService, updateUserService } from '../user-service'
// Mock dependencies
vi.mock('../authorization/user-authorization', () => ({
canReadUser: vi.fn(),
canUpdateUser: vi.fn()
}))
vi.mock('../../db/repositories/user-repository', () => ({
getUserByIdDao: vi.fn(),
updateUserDao: vi.fn()
}))
describe('User Service', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('getUserByIdService', () => {
it('should return user when authorized', async () => {
// Arrange
const userId = 'test-user-id'
const mockUser = { id: userId, name: 'Test User' }
vi.mocked(canReadUser).mockResolvedValue(true)
vi.mocked(getUserByIdDao).mockResolvedValue(mockUser)
// Act
const result = await getUserByIdService(userId)
// Assert
expect(canReadUser).toHaveBeenCalledWith(userId)
expect(getUserByIdDao).toHaveBeenCalledWith(userId)
expect(result).toEqual(mockUser)
})
it('should throw AuthorizationError when not authorized', async () => {
// Arrange
vi.mocked(canReadUser).mockResolvedValue(false)
// Act & Assert
await expect(getUserByIdService('test-id'))
.rejects
.toThrow('AuthorizationError')
expect(getUserByIdDao).not.toHaveBeenCalled()
})
it('should throw ValidationError for invalid ID', async () => {
// Act & Assert
await expect(getUserByIdService('invalid-id'))
.rejects
.toThrow('ValidationError')
})
})
})- Validation - Test all validation scenarios
- Authorization - Test permitted and denied access
- Business Logic - Test core functionality
- Error Handling - Test error cases
- Side Effects - Test notifications, emails, etc.
Best Practices
✅
Validation Best Practices
• Validate early - At service boundary
• Use Zod schemas - Type-safe validation
• Async validation - Database constraints
• Localized messages - User-friendly errors
• Sanitize inputs - Transform data consistently
🔐
Authorization Best Practices
• Check permissions first - Before business logic
• Use CASL abilities - Declarative permissions
• Resource-level checks - Fine-grained access
• Fail secure - Deny by default
• Audit access - Log permission checks
🏗️
Service Design
• Single responsibility - One concern per service
• Pure functions - Predictable, testable
• Dependency injection - Mock-friendly
• Error handling - Custom error types
• Transaction support - Data consistency
🧪
Testing Strategy
• Unit tests - Service logic isolation
• Integration tests - End-to-end flows
• Mock dependencies - Repository and external services
• Test authorization - Both success and failure cases
• Performance tests - Response time validation
Service layer ready! Your business logic is secure, validated, authorized, and thoroughly tested with clean separation of concerns and enterprise-grade patterns.