Service Layer Pattern
The Service Layer pattern in Mifty provides a clean separation between business logic and data access. Services act as an intermediary layer that orchestrates business operations, validates business rules, and coordinates between controllers and repositories.
Pattern Overview
The Service Layer pattern provides:
- Business Logic Encapsulation - All business rules in one place
- Transaction Management - Coordinated database operations
- Validation - Business rule validation before data persistence
- Error Handling - Consistent error handling and transformation
- Testability - Easy to unit test business logic
Architecture
Controller Layer
↓
Service Layer (Business Logic)
↓
Repository Layer (Data Access)
↓
Database
Service Interface Contract
IService Interface
interface IService<T, TCreateInput, TUpdateInput> {
findAll(options?: any): Promise<T[]>;
findById(id: string): Promise<T | null>;
create(data: TCreateInput): Promise<T>;
update(id: string, data: TUpdateInput): Promise<T | null>;
search(searchTerm: string, fields: string[], include?: any): Promise<T[]>;
findWithPagination(options: SearchOptions): Promise<PaginatedResult<T>>;
findByIds(ids: string[]): Promise<T[]>;
findFirst(where: any, include?: any): Promise<T | null>;
findMany(options: SearchOptions): Promise<T[]>;
delete(id: string): Promise<boolean>;
count(options?: any): Promise<number>;
}
Implementation Patterns
Basic Service Implementation
import { BaseService } from '@mifty/core/base';
import { UserRepository } from './user.repository';
import { User, CreateUserDto, UpdateUserDto } from './user.types';
export class UserService extends BaseService<User, CreateUserDto, UpdateUserDto> {
constructor(private userRepository: UserRepository) {
super(userRepository);
}
// Inherit all base CRUD operations
// Add custom business logic methods below
}
Service with Business Logic
import { ConflictException, NotFoundException } from '@mifty/core/exceptions';
import { ValidationException } from '@mifty/core/exceptions';
export class UserService extends BaseService<User, CreateUserDto, UpdateUserDto> {
constructor(
private userRepository: UserRepository,
private emailService: EmailService,
private auditService: AuditService
) {
super(userRepository);
}
// Override create to add business logic
async create(data: CreateUserDto): Promise<User> {
// Business validation
await this.validateUserCreation(data);
// Transform data according to business rules
const userData = this.prepareUserData(data);
// Create user with audit trail
const user = await super.create(userData);
// Post-creation business logic
await this.handleUserCreated(user);
return user;
}
// Override update with business validation
async update(id: string, data: UpdateUserDto): Promise<User> {
const existingUser = await this.findById(id);
// Business validation
await this.validateUserUpdate(existingUser, data);
// Apply business rules
const updateData = this.prepareUpdateData(existingUser, data);
const updatedUser = await super.update(id, updateData);
// Post-update business logic
await this.handleUserUpdated(existingUser, updatedUser);
return updatedUser;
}
// Business logic methods
private async validateUserCreation(data: CreateUserDto): Promise<void> {
// Check email uniqueness
const emailExists = await this.repository.exists({ email: data.email });
if (emailExists) {
throw new ConflictException('Email address already exists');
}
// Validate business rules
if (data.age && data.age < 18) {
throw new ValidationException({
age: ['User must be at least 18 years old']
});
}
// Check domain restrictions
if (data.email.endsWith('@competitor.com')) {
throw new ValidationException({
email: ['Email domain not allowed']
});
}
}
private async validateUserUpdate(user: User, data: UpdateUserDto): Promise<void> {
// Check email uniqueness if email is being changed
if (data.email && data.email !== user.email) {
const emailExists = await this.repository.exists({
email: data.email,
id: { not: user.id }
});
if (emailExists) {
throw new ConflictException('Email address already exists');
}
}
// Business rule: Cannot deactivate admin users
if (data.active === false && user.role === 'admin') {
throw new ValidationException({
active: ['Cannot deactivate admin users']
});
}
}
private prepareUserData(data: CreateUserDto): CreateUserDto {
return {
...data,
email: data.email.toLowerCase().trim(),
name: data.name.trim(),
active: true,
createdAt: new Date(),
lastLoginAt: null
};
}
private prepareUpdateData(existingUser: User, data: UpdateUserDto): UpdateUserDto {
const updateData = { ...data };
// Business rule: Update modified timestamp
updateData.updatedAt = new Date();
// Business rule: Normalize email
if (updateData.email) {
updateData.email = updateData.email.toLowerCase().trim();
}
return updateData;
}
private async handleUserCreated(user: User): Promise<void> {
// Send welcome email
await this.emailService.sendWelcomeEmail(user.email, user.name);
// Create audit log
await this.auditService.log({
action: 'USER_CREATED',
entityId: user.id,
entityType: 'User',
details: { email: user.email, name: user.name }
});
}
private async handleUserUpdated(oldUser: User, newUser: User): Promise<void> {
// Detect significant changes
const significantChanges = this.detectSignificantChanges(oldUser, newUser);
if (significantChanges.length > 0) {
// Send notification email
await this.emailService.sendProfileUpdateNotification(
newUser.email,
significantChanges
);
// Create audit log
await this.auditService.log({
action: 'USER_UPDATED',
entityId: newUser.id,
entityType: 'User',
details: { changes: significantChanges }
});
}
}
// Custom business methods
async activateUser(id: string): Promise<User> {
const user = await this.findById(id);
if (user.active) {
throw new ConflictException('User is already active');
}
const activatedUser = await this.update(id, {
active: true,
activatedAt: new Date()
});
await this.emailService.sendAccountActivatedEmail(user.email);
return activatedUser;
}
async deactivateUser(id: string, reason?: string): Promise<User> {
const user = await this.findById(id);
// Business rule: Cannot deactivate admin users
if (user.role === 'admin') {
throw new ValidationException({
role: ['Cannot deactivate admin users']
});
}
const deactivatedUser = await this.update(id, {
active: false,
deactivatedAt: new Date(),
deactivationReason: reason
});
// Cleanup user sessions
await this.sessionService.invalidateUserSessions(id);
return deactivatedUser;
}
async changeUserRole(id: string, newRole: string, changedBy: string): Promise<User> {
const user = await this.findById(id);
// Business validation
if (!this.isValidRoleTransition(user.role, newRole)) {
throw new ValidationException({
role: [`Cannot change role from ${user.role} to ${newRole}`]
});
}
const updatedUser = await this.update(id, { role: newRole });
// Audit role change
await this.auditService.log({
action: 'ROLE_CHANGED',
entityId: id,
entityType: 'User',
performedBy: changedBy,
details: {
oldRole: user.role,
newRole: newRole
}
});
return updatedUser;
}
private isValidRoleTransition(currentRole: string, newRole: string): boolean {
const validTransitions = {
'user': ['moderator'],
'moderator': ['user', 'admin'],
'admin': ['moderator'] // Admins can be demoted
};
return validTransitions[currentRole]?.includes(newRole) || false;
}
}
Advanced Service Patterns
Transaction Management
export class OrderService extends BaseService<Order, CreateOrderDto, UpdateOrderDto> {
constructor(
private orderRepository: OrderRepository,
private inventoryService: InventoryService,
private paymentService: PaymentService,
private emailService: EmailService,
private prisma: PrismaClient
) {
super(orderRepository);
}
async createOrder(data: CreateOrderDto): Promise<Order> {
return this.prisma.$transaction(async (tx) => {
// 1. Validate inventory
await this.inventoryService.validateAvailability(data.items);
// 2. Reserve inventory
await this.inventoryService.reserveItems(data.items, tx);
// 3. Create order
const order = await this.orderRepository.create(data, tx);
// 4. Process payment
const payment = await this.paymentService.processPayment({
orderId: order.id,
amount: order.total,
paymentMethod: data.paymentMethod
}, tx);
// 5. Update inventory
if (payment.status === 'completed') {
await this.inventoryService.commitReservation(data.items, tx);
} else {
await this.inventoryService.releaseReservation(data.items, tx);
throw new PaymentException('Payment processing failed');
}
// 6. Send confirmation email
await this.emailService.sendOrderConfirmation(order);
return order;
});
}
}
Service Composition
export class UserManagementService {
constructor(
private userService: UserService,
private profileService: ProfileService,
private notificationService: NotificationService,
private auditService: AuditService
) {}
async createCompleteUser(userData: CreateUserDto, profileData: CreateProfileDto): Promise<{
user: User;
profile: Profile;
}> {
// Create user first
const user = await this.userService.create(userData);
try {
// Create profile
const profile = await this.profileService.create({
...profileData,
userId: user.id
});
// Set up default notifications
await this.notificationService.createDefaultSettings(user.id);
return { user, profile };
} catch (error) {
// Cleanup on failure
await this.userService.delete(user.id);
throw error;
}
}
async deleteUserCompletely(userId: string): Promise<void> {
// Check if user can be deleted
const user = await this.userService.findById(userId);
// Business rule: Cannot delete users with active orders
const hasActiveOrders = await this.orderService.hasActiveOrders(userId);
if (hasActiveOrders) {
throw new ConflictException('Cannot delete user with active orders');
}
// Delete in correct order
await this.notificationService.deleteUserSettings(userId);
await this.profileService.deleteByUserId(userId);
await this.auditService.anonymizeUserLogs(userId);
await this.userService.delete(userId);
}
}
Event-Driven Services
import { EventEmitter } from 'events';
export class UserService extends BaseService<User, CreateUserDto, UpdateUserDto> {
private eventEmitter = new EventEmitter();
async create(data: CreateUserDto): Promise<User> {
const user = await super.create(data);
// Emit event for other services to handle
this.eventEmitter.emit('user.created', { user, timestamp: new Date() });
return user;
}
async update(id: string, data: UpdateUserDto): Promise<User> {
const oldUser = await this.findById(id);
const updatedUser = await super.update(id, data);
// Emit event with before/after data
this.eventEmitter.emit('user.updated', {
oldUser,
newUser: updatedUser,
timestamp: new Date()
});
return updatedUser;
}
// Event subscription methods
onUserCreated(callback: (event: { user: User; timestamp: Date }) => void) {
this.eventEmitter.on('user.created', callback);
}
onUserUpdated(callback: (event: { oldUser: User; newUser: User; timestamp: Date }) => void) {
this.eventEmitter.on('user.updated', callback);
}
}
// Usage in other services
export class EmailService {
constructor(private userService: UserService) {
// Subscribe to user events
this.userService.onUserCreated(this.handleUserCreated.bind(this));
this.userService.onUserUpdated(this.handleUserUpdated.bind(this));
}
private async handleUserCreated(event: { user: User; timestamp: Date }) {
await this.sendWelcomeEmail(event.user.email, event.user.name);
}
private async handleUserUpdated(event: { oldUser: User; newUser: User; timestamp: Date }) {
if (event.oldUser.email !== event.newUser.email) {
await this.sendEmailChangeNotification(event.oldUser.email, event.newUser.email);
}
}
}
Error Handling in Services
Business Exception Handling
export class UserService extends BaseService<User, CreateUserDto, UpdateUserDto> {
async create(data: CreateUserDto): Promise<User> {
try {
// Business validation
await this.validateBusinessRules(data);
// Create user
const user = await super.create(data);
// Post-creation logic
await this.handlePostCreation(user);
return user;
} catch (error) {
// Handle different error types
if (error instanceof ValidationException) {
// Re-throw validation errors as-is
throw error;
}
if (error instanceof ConflictException) {
// Re-throw business conflicts as-is
throw error;
}
if (error instanceof DatabaseException) {
// Transform database errors to business errors
throw new BusinessException('Failed to create user account', {
originalError: error.message
});
}
// Handle unexpected errors
throw new UnexpectedError('User creation failed unexpectedly', {
originalError: error
});
}
}
private async validateBusinessRules(data: CreateUserDto): Promise<void> {
const errors: Record<string, string[]> = {};
// Email validation
if (!this.isValidEmailDomain(data.email)) {
errors.email = ['Email domain is not allowed'];
}
// Age validation
if (data.dateOfBirth && !this.isValidAge(data.dateOfBirth)) {
errors.dateOfBirth = ['User must be at least 18 years old'];
}
// Username validation
if (await this.isUsernameTaken(data.username)) {
errors.username = ['Username is already taken'];
}
if (Object.keys(errors).length > 0) {
throw new ValidationException(errors);
}
}
}
Service Testing Patterns
Unit Testing Services
import { UserService } from './user.service';
import { UserRepository } from './user.repository';
import { EmailService } from './email.service';
describe('UserService', () => {
let userService: UserService;
let mockUserRepository: jest.Mocked<UserRepository>;
let mockEmailService: jest.Mocked<EmailService>;
beforeEach(() => {
mockUserRepository = {
create: jest.fn(),
findById: jest.fn(),
exists: jest.fn(),
update: jest.fn(),
delete: jest.fn()
} as any;
mockEmailService = {
sendWelcomeEmail: jest.fn(),
sendProfileUpdateNotification: jest.fn()
} as any;
userService = new UserService(mockUserRepository, mockEmailService);
});
describe('create', () => {
it('should create user with valid data', async () => {
const userData = {
name: 'John Doe',
email: 'john@example.com',
age: 25
};
const createdUser = { id: '1', ...userData, active: true };
mockUserRepository.exists.mockResolvedValue(false);
mockUserRepository.create.mockResolvedValue(createdUser);
mockEmailService.sendWelcomeEmail.mockResolvedValue(undefined);
const result = await userService.create(userData);
expect(result).toEqual(createdUser);
expect(mockUserRepository.exists).toHaveBeenCalledWith({ email: userData.email });
expect(mockEmailService.sendWelcomeEmail).toHaveBeenCalledWith(userData.email, userData.name);
});
it('should throw ConflictException for duplicate email', async () => {
const userData = {
name: 'John Doe',
email: 'john@example.com',
age: 25
};
mockUserRepository.exists.mockResolvedValue(true);
await expect(userService.create(userData)).rejects.toThrow(ConflictException);
expect(mockUserRepository.create).not.toHaveBeenCalled();
});
it('should throw ValidationException for underage user', async () => {
const userData = {
name: 'John Doe',
email: 'john@example.com',
age: 16
};
mockUserRepository.exists.mockResolvedValue(false);
await expect(userService.create(userData)).rejects.toThrow(ValidationException);
expect(mockUserRepository.create).not.toHaveBeenCalled();
});
});
});
Integration Testing
describe('UserService Integration', () => {
let userService: UserService;
let testDb: TestDatabase;
beforeAll(async () => {
testDb = await setupTestDatabase();
userService = new UserService(
new UserRepository(testDb.prisma),
new EmailService()
);
});
afterAll(async () => {
await testDb.cleanup();
});
beforeEach(async () => {
await testDb.reset();
});
it('should create user and send welcome email', async () => {
const userData = {
name: 'John Doe',
email: 'john@example.com',
age: 25
};
const user = await userService.create(userData);
expect(user.id).toBeDefined();
expect(user.email).toBe(userData.email.toLowerCase());
expect(user.active).toBe(true);
// Verify user exists in database
const dbUser = await testDb.prisma.user.findUnique({
where: { id: user.id }
});
expect(dbUser).toBeTruthy();
});
});
Best Practices
1. Single Responsibility
Each service should have a single, well-defined responsibility:
// Good: Focused responsibility
export class UserService {
// Only user-related business logic
}
export class EmailService {
// Only email-related operations
}
// Avoid: Mixed responsibilities
export class UserEmailService {
// Mixing user management and email operations
}
2. Dependency Injection
Use dependency injection for testability and flexibility:
// Good: Dependencies injected
export class UserService {
constructor(
private userRepository: UserRepository,
private emailService: EmailService,
private auditService: AuditService
) {}
}
// Avoid: Hard-coded dependencies
export class UserService {
private userRepository = new UserRepository();
private emailService = new EmailService();
}
3. Error Handling
Handle errors appropriately at the service layer:
// Good: Proper error handling
async create(data: CreateUserDto): Promise<User> {
try {
await this.validateBusinessRules(data);
return await this.repository.create(data);
} catch (error) {
if (error instanceof ValidationException) {
throw error; // Re-throw business errors
}
throw new BusinessException('User creation failed');
}
}
// Avoid: Letting all errors bubble up
async create(data: CreateUserDto): Promise<User> {
return this.repository.create(data); // No error handling
}
4. Transaction Management
Use transactions for operations that affect multiple entities:
// Good: Transactional operations
async createUserWithProfile(userData: CreateUserDto, profileData: CreateProfileDto): Promise<User> {
return this.prisma.$transaction(async (tx) => {
const user = await this.userRepository.create(userData, tx);
await this.profileRepository.create({ ...profileData, userId: user.id }, tx);
return user;
});
}
Related
- Repository Pattern - Data access layer
- BaseService - Base service implementation
- IService Interface - Service contract
- Dependency Injection - DI patterns
- Error Handling - Exception management