# 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 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):** ```typescript // 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, ) {} async findByEmail(email: string): Promise { return this.userRepo.findOne({ where: { email } }); } } ``` **After (Create Repository):** ```typescript // 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, ) {} async findById(ctx: ServiceContext, id: string): Promise { return this.ormRepo.findOne({ where: { id, tenantId: ctx.tenantId }, }); } async findByEmail(ctx: ServiceContext, email: string): Promise { return this.ormRepo.findOne({ where: { email, tenantId: ctx.tenantId }, }); } async findByTenantId(ctx: ServiceContext, tenantId: string): Promise { return this.ormRepo.find({ where: { tenantId }, }); } async findActiveUsers( ctx: ServiceContext, filters?: PaginationOptions, ): Promise> { 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 { await this.ormRepo.update( { id: userId, tenantId: ctx.tenantId }, { lastLoginAt: new Date() }, ); } async updatePasswordHash( ctx: ServiceContext, userId: string, passwordHash: string, ): Promise { await this.ormRepo.update( { id: userId, tenantId: ctx.tenantId }, { passwordHash }, ); } // Implement remaining IRepository methods... async create(ctx: ServiceContext, data: Partial): Promise { const user = this.ormRepo.create({ ...data, tenantId: ctx.tenantId, }); return this.ormRepo.save(user); } async update( ctx: ServiceContext, id: string, data: Partial, ): Promise { await this.ormRepo.update( { id, tenantId: ctx.tenantId }, data, ); return this.findById(ctx, id); } // ... implement other methods } ``` ### Step 2: Register Repository in Module ```typescript // 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 ```typescript // 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('UserRepository'); } async findByEmail( ctx: ServiceContext, email: string, ): Promise { return this.userRepository.findByEmail(ctx, email); } async getActiveUsers( ctx: ServiceContext, page: number = 1, pageSize: number = 20, ): Promise> { return this.userRepository.findActiveUsers(ctx, { page, pageSize }); } async updateLastLogin(ctx: ServiceContext, userId: string): Promise { await this.userRepository.updateLastLogin(ctx, userId); } } ``` ### Step 4: Alternative - Use Decorator Pattern ```typescript // 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 { return this.userRepository.findByEmail(ctx, email); } } ``` ## Testing with Repositories ### Create Mock Repository ```typescript // 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 { return this.users.find(u => u.id === id) || null; } async findByEmail(ctx: ServiceContext, email: string): Promise { return this.users.find(u => u.email === email) || null; } async create(ctx: ServiceContext, data: Partial): Promise { const user = { id: 'test-id', ...data } as User; this.users.push(user); return user; } // Implement other methods as needed } ``` ### Use Mock in Tests ```typescript // 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); }); 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 ```typescript async findAll( ctx: ServiceContext, filters?: PaginationOptions, ): Promise> { // 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 ```typescript async create(ctx: ServiceContext, data: Partial): Promise { 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 ```typescript async findWithRelations( ctx: ServiceContext, filters: any, ): Promise { 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 ```typescript // 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 ```typescript // 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 ```typescript 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: ```typescript { 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: ```typescript 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: ```typescript @Injectable() export class UserService { constructor( @Inject(forwardRef(() => UserRepository)) private userRepo: UserRepository, ) {} } ``` ## Advanced Patterns ### Generic Repository Base Class ```typescript // repositories/base.repository.ts import { Repository } from 'typeorm'; import { IRepository, ServiceContext } from '@erp-suite/core'; export abstract class BaseRepositoryImpl implements IRepository { constructor(protected readonly ormRepo: Repository) {} async findById(ctx: ServiceContext, id: string): Promise { return this.ormRepo.findOne({ where: { id, tenantId: ctx.tenantId } as any, }); } // Implement common methods once... } // Use in specific repositories export class UserRepository extends BaseRepositoryImpl implements IUserRepository { async findByEmail(ctx: ServiceContext, email: string): Promise { return this.ormRepo.findOne({ where: { email, tenantId: ctx.tenantId }, }); } } ``` ### Repository Composition ```typescript // 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 - [Dependency Inversion Principle](https://en.wikipedia.org/wiki/Dependency_inversion_principle) - [Repository Pattern](https://martinfowler.com/eaaCatalog/repository.html) - [Factory Pattern](https://refactoring.guru/design-patterns/factory-method) - [ERP-Suite Core Library](/apps/shared-libs/core/README.md) ## 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