We provide all the information about MCP servers via our MCP API.
curl -X GET 'https://glama.ai/api/mcp/v1/servers/nickdesi/FFBB-MCP-Server'
If you have feedback or need assistance with the MCP directory API, please join our Discord server
# 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](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