erp-suite/apps/shared-libs/core/MIGRATION_GUIDE.md

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