790 lines
22 KiB
Markdown
790 lines
22 KiB
Markdown
# 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](#service-layer-overview)
|
|
- [Dependency Injection Pattern](#dependency-injection-pattern)
|
|
- [Singleton Pattern](#singleton-pattern)
|
|
- [Repository Pattern](#repository-pattern)
|
|
- [Service Design Principles](#service-design-principles)
|
|
- [Caching Strategies](#caching-strategies)
|
|
- [Testing Services](#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`
|
|
|
|
```typescript
|
|
// 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:**
|
|
|
|
```typescript
|
|
// 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`
|
|
|
|
```typescript
|
|
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:**
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
// 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:**
|
|
|
|
```typescript
|
|
// 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:
|
|
|
|
```typescript
|
|
// ✅ 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:
|
|
|
|
```typescript
|
|
// ✅ 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:
|
|
|
|
```typescript
|
|
// ✅ 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:
|
|
|
|
```typescript
|
|
// ✅ 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:
|
|
|
|
```typescript
|
|
// ❌ 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
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
// 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](routing-and-controllers.md) - Controllers that use services
|
|
- [database-patterns.md](database-patterns.md) - Prisma and repository patterns
|
|
- [complete-examples.md](complete-examples.md) - Full service/repository examples
|