17 KiB
17 KiB
Repository Pattern Migration Guide
Overview
This guide helps you migrate from direct service-to-database access to the Repository pattern, implementing proper Dependency Inversion Principle (DIP) for the ERP-Suite project.
Why Migrate?
Problems with Current Approach
- Tight Coupling: Services directly depend on concrete implementations
- Testing Difficulty: Hard to mock database access
- DIP Violation: High-level modules depend on low-level modules
- Code Duplication: Similar queries repeated across services
Benefits of Repository Pattern
- Loose Coupling: Services depend on interfaces, not implementations
- Testability: Easy to mock repositories for unit tests
- Maintainability: Centralized data access logic
- Flexibility: Swap implementations without changing service code
Architecture
┌─────────────────────────────────────────────────┐
│ Service Layer │
│ (Depends on IRepository<T> interfaces) │
└────────────────────┬────────────────────────────┘
│ (Dependency Inversion)
▼
┌─────────────────────────────────────────────────┐
│ Repository Interfaces │
│ IUserRepository, ITenantRepository, etc. │
└────────────────────┬────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────┐
│ Repository Implementations │
│ UserRepository, TenantRepository, etc. │
└────────────────────┬────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────┐
│ Database (TypeORM) │
└─────────────────────────────────────────────────┘
Step-by-Step Migration
Step 1: Create Repository Implementation
Before (Direct TypeORM in Service):
// services/user.service.ts
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from '@erp-suite/core';
@Injectable()
export class UserService {
constructor(
@InjectRepository(User)
private userRepo: Repository<User>,
) {}
async findByEmail(email: string): Promise<User | null> {
return this.userRepo.findOne({ where: { email } });
}
}
After (Create Repository):
// repositories/user.repository.ts
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import {
User,
IUserRepository,
ServiceContext,
PaginatedResult,
PaginationOptions,
} from '@erp-suite/core';
@Injectable()
export class UserRepository implements IUserRepository {
constructor(
@InjectRepository(User)
private readonly ormRepo: Repository<User>,
) {}
async findById(ctx: ServiceContext, id: string): Promise<User | null> {
return this.ormRepo.findOne({
where: { id, tenantId: ctx.tenantId },
});
}
async findByEmail(ctx: ServiceContext, email: string): Promise<User | null> {
return this.ormRepo.findOne({
where: { email, tenantId: ctx.tenantId },
});
}
async findByTenantId(ctx: ServiceContext, tenantId: string): Promise<User[]> {
return this.ormRepo.find({
where: { tenantId },
});
}
async findActiveUsers(
ctx: ServiceContext,
filters?: PaginationOptions,
): Promise<PaginatedResult<User>> {
const page = filters?.page || 1;
const pageSize = filters?.pageSize || 20;
const [data, total] = await this.ormRepo.findAndCount({
where: { tenantId: ctx.tenantId, status: 'active' },
skip: (page - 1) * pageSize,
take: pageSize,
});
return {
data,
meta: {
page,
pageSize,
totalRecords: total,
totalPages: Math.ceil(total / pageSize),
},
};
}
async updateLastLogin(ctx: ServiceContext, userId: string): Promise<void> {
await this.ormRepo.update(
{ id: userId, tenantId: ctx.tenantId },
{ lastLoginAt: new Date() },
);
}
async updatePasswordHash(
ctx: ServiceContext,
userId: string,
passwordHash: string,
): Promise<void> {
await this.ormRepo.update(
{ id: userId, tenantId: ctx.tenantId },
{ passwordHash },
);
}
// Implement remaining IRepository<User> methods...
async create(ctx: ServiceContext, data: Partial<User>): Promise<User> {
const user = this.ormRepo.create({
...data,
tenantId: ctx.tenantId,
});
return this.ormRepo.save(user);
}
async update(
ctx: ServiceContext,
id: string,
data: Partial<User>,
): Promise<User | null> {
await this.ormRepo.update(
{ id, tenantId: ctx.tenantId },
data,
);
return this.findById(ctx, id);
}
// ... implement other methods
}
Step 2: Register Repository in Module
// user.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from '@erp-suite/core';
import { UserService } from './services/user.service';
import { UserRepository } from './repositories/user.repository';
import { UserController } from './controllers/user.controller';
@Module({
imports: [TypeOrmModule.forFeature([User])],
providers: [
UserService,
UserRepository,
// Register in RepositoryFactory
{
provide: 'REPOSITORY_FACTORY_SETUP',
useFactory: (userRepository: UserRepository) => {
const factory = RepositoryFactory.getInstance();
factory.register('UserRepository', userRepository);
},
inject: [UserRepository],
},
],
controllers: [UserController],
exports: [UserRepository],
})
export class UserModule {}
Step 3: Update Service to Use Repository
// services/user.service.ts
import { Injectable } from '@nestjs/common';
import {
IUserRepository,
ServiceContext,
RepositoryFactory,
} from '@erp-suite/core';
@Injectable()
export class UserService {
private readonly userRepository: IUserRepository;
constructor() {
const factory = RepositoryFactory.getInstance();
this.userRepository = factory.getRequired<IUserRepository>('UserRepository');
}
async findByEmail(
ctx: ServiceContext,
email: string,
): Promise<User | null> {
return this.userRepository.findByEmail(ctx, email);
}
async getActiveUsers(
ctx: ServiceContext,
page: number = 1,
pageSize: number = 20,
): Promise<PaginatedResult<User>> {
return this.userRepository.findActiveUsers(ctx, { page, pageSize });
}
async updateLastLogin(ctx: ServiceContext, userId: string): Promise<void> {
await this.userRepository.updateLastLogin(ctx, userId);
}
}
Step 4: Alternative - Use Decorator Pattern
// services/user.service.ts (with decorator)
import { Injectable } from '@nestjs/common';
import {
IUserRepository,
InjectRepository,
ServiceContext,
} from '@erp-suite/core';
@Injectable()
export class UserService {
@InjectRepository('UserRepository')
private readonly userRepository: IUserRepository;
async findByEmail(
ctx: ServiceContext,
email: string,
): Promise<User | null> {
return this.userRepository.findByEmail(ctx, email);
}
}
Testing with Repositories
Create Mock Repository
// tests/mocks/user.repository.mock.ts
import { IUserRepository, ServiceContext, User } from '@erp-suite/core';
export class MockUserRepository implements IUserRepository {
private users: User[] = [];
async findById(ctx: ServiceContext, id: string): Promise<User | null> {
return this.users.find(u => u.id === id) || null;
}
async findByEmail(ctx: ServiceContext, email: string): Promise<User | null> {
return this.users.find(u => u.email === email) || null;
}
async create(ctx: ServiceContext, data: Partial<User>): Promise<User> {
const user = { id: 'test-id', ...data } as User;
this.users.push(user);
return user;
}
// Implement other methods as needed
}
Use Mock in Tests
// tests/user.service.spec.ts
import { Test } from '@nestjs/testing';
import { UserService } from '../services/user.service';
import { RepositoryFactory } from '@erp-suite/core';
import { MockUserRepository } from './mocks/user.repository.mock';
describe('UserService', () => {
let service: UserService;
let mockRepo: MockUserRepository;
let factory: RepositoryFactory;
beforeEach(async () => {
mockRepo = new MockUserRepository();
factory = RepositoryFactory.getInstance();
factory.clear(); // Clear previous registrations
factory.register('UserRepository', mockRepo);
const module = await Test.createTestingModule({
providers: [UserService],
}).compile();
service = module.get<UserService>(UserService);
});
afterEach(() => {
factory.clear();
});
it('should find user by email', async () => {
const ctx = { tenantId: 'tenant-1', userId: 'user-1' };
const user = await mockRepo.create(ctx, {
email: 'test@example.com',
fullName: 'Test User',
});
const found = await service.findByEmail(ctx, 'test@example.com');
expect(found).toEqual(user);
});
});
Common Repository Patterns
1. Tenant-Scoped Queries
async findAll(
ctx: ServiceContext,
filters?: PaginationOptions,
): Promise<PaginatedResult<T>> {
// Always filter by tenant
const where = { tenantId: ctx.tenantId };
const [data, total] = await this.ormRepo.findAndCount({
where,
skip: ((filters?.page || 1) - 1) * (filters?.pageSize || 20),
take: filters?.pageSize || 20,
});
return {
data,
meta: {
page: filters?.page || 1,
pageSize: filters?.pageSize || 20,
totalRecords: total,
totalPages: Math.ceil(total / (filters?.pageSize || 20)),
},
};
}
2. Audit Trail Integration
async create(ctx: ServiceContext, data: Partial<T>): Promise<T> {
const entity = this.ormRepo.create({
...data,
tenantId: ctx.tenantId,
createdBy: ctx.userId,
});
const saved = await this.ormRepo.save(entity);
// Log audit trail
await this.auditRepository.logAction(ctx, {
tenantId: ctx.tenantId,
userId: ctx.userId,
action: 'CREATE',
entityType: this.entityName,
entityId: saved.id,
timestamp: new Date(),
});
return saved;
}
3. Complex Queries with QueryBuilder
async findWithRelations(
ctx: ServiceContext,
filters: any,
): Promise<User[]> {
return this.ormRepo
.createQueryBuilder('user')
.leftJoinAndSelect('user.tenant', 'tenant')
.where('user.tenantId = :tenantId', { tenantId: ctx.tenantId })
.andWhere('user.status = :status', { status: 'active' })
.orderBy('user.createdAt', 'DESC')
.getMany();
}
Migration Checklist
For each service:
- Identify all database access patterns
- Create repository interface (or use existing IRepository)
- Implement repository class
- Register repository in module
- Update service to use repository
- Create mock repository for tests
- Update tests to use mock repository
- Verify multi-tenancy filtering
- Add audit logging if needed
- Document any custom repository methods
Repository Factory Best Practices
1. Initialize Once at Startup
// main.ts or app.module.ts
import { RepositoryFactory } from '@erp-suite/core';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// Initialize factory with all repositories
const factory = RepositoryFactory.getInstance();
// Repositories are registered in their respective modules
console.log(
`Registered repositories: ${factory.getRegisteredNames().join(', ')}`,
);
await app.listen(3000);
}
2. Use Dependency Injection
// Prefer constructor injection with factory
@Injectable()
export class MyService {
private readonly userRepo: IUserRepository;
private readonly tenantRepo: ITenantRepository;
constructor() {
const factory = RepositoryFactory.getInstance();
this.userRepo = factory.getRequired('UserRepository');
this.tenantRepo = factory.getRequired('TenantRepository');
}
}
3. Testing Isolation
describe('MyService', () => {
let factory: RepositoryFactory;
beforeEach(() => {
factory = RepositoryFactory.getInstance();
factory.clear(); // Ensure clean slate
factory.register('UserRepository', mockUserRepo);
});
afterEach(() => {
factory.clear(); // Clean up
});
});
Troubleshooting
Error: "Repository 'XYZ' not found in factory registry"
Cause: Repository not registered before being accessed.
Solution: Ensure repository is registered in module providers:
{
provide: 'REPOSITORY_FACTORY_SETUP',
useFactory: (repo: XYZRepository) => {
RepositoryFactory.getInstance().register('XYZRepository', repo);
},
inject: [XYZRepository],
}
Error: "Repository 'XYZ' is already registered"
Cause: Attempting to register a repository that already exists.
Solution: Use replace() instead of register(), or check if already registered:
const factory = RepositoryFactory.getInstance();
if (!factory.has('XYZRepository')) {
factory.register('XYZRepository', repo);
}
Circular Dependency Issues
Cause: Services and repositories depend on each other.
Solution: Use forwardRef() or restructure dependencies:
@Injectable()
export class UserService {
constructor(
@Inject(forwardRef(() => UserRepository))
private userRepo: UserRepository,
) {}
}
Advanced Patterns
Generic Repository Base Class
// repositories/base.repository.ts
import { Repository } from 'typeorm';
import { IRepository, ServiceContext } from '@erp-suite/core';
export abstract class BaseRepositoryImpl<T> implements IRepository<T> {
constructor(protected readonly ormRepo: Repository<T>) {}
async findById(ctx: ServiceContext, id: string): Promise<T | null> {
return this.ormRepo.findOne({
where: { id, tenantId: ctx.tenantId } as any,
});
}
// Implement common methods once...
}
// Use in specific repositories
export class UserRepository extends BaseRepositoryImpl<User> implements IUserRepository {
async findByEmail(ctx: ServiceContext, email: string): Promise<User | null> {
return this.ormRepo.findOne({
where: { email, tenantId: ctx.tenantId },
});
}
}
Repository Composition
// Compose multiple repositories
export class OrderService {
@InjectRepository('OrderRepository')
private orderRepo: IOrderRepository;
@InjectRepository('ProductRepository')
private productRepo: IProductRepository;
@InjectRepository('CustomerRepository')
private customerRepo: ICustomerRepository;
async createOrder(ctx: ServiceContext, data: CreateOrderDto) {
const customer = await this.customerRepo.findById(ctx, data.customerId);
const products = await this.productRepo.findMany(ctx, {
id: In(data.productIds),
});
const order = await this.orderRepo.create(ctx, {
customerId: customer.id,
items: products.map(p => ({ productId: p.id, quantity: 1 })),
});
return order;
}
}
Resources
Support
For questions or issues:
- Check existing implementations in
/apps/shared-libs/core/ - Review test files for usage examples
- Open an issue on the project repository