Skip to content

Repository Pattern in NestJS ​

Implementing domain-focused data access in NestJS applications

Implements: Repository Pattern | Dependency Inversion

Overview ​

Repositories in NestJS provide a clean abstraction between your domain logic and data persistence. They operate on domain entities, hide storage implementation details, and enable easy testing through dependency injection.

Repository Architecture ​

Interface Definition (Port) ​

Repository interfaces are defined in the domain or use case layer:

typescript
// domain/interfaces/user-repository.interface.ts
export interface UserRepositoryInterface {
  save(user: User): Promise<User>;
  findById(id: UserId): Promise<User | null>;
  findByEmail(email: Email): Promise<User | null>;
  findActiveUsers(): Promise<User[]>;
  delete(id: UserId): Promise<void>;
  exists(email: Email): Promise<boolean>;
}

Implementation (Adapter) ​

Concrete implementations live in the infrastructure layer:

typescript
// infrastructure/repositories/prisma-user.repository.ts
@Injectable()
export class PrismaUserRepository implements UserRepositoryInterface {
  constructor(private readonly prisma: PrismaService) {}

  async save(user: User): Promise<User> {
    const data = this.toPersistence(user);
    
    const saved = await this.prisma.user.upsert({
      where: { id: data.id },
      create: data,
      update: data,
    });

    return this.toDomain(saved);
  }

  async findById(id: UserId): Promise<User | null> {
    const record = await this.prisma.user.findUnique({
      where: { id: id.toString() },
    });

    return record ? this.toDomain(record) : null;
  }

  async findByEmail(email: Email): Promise<User | null> {
    const record = await this.prisma.user.findUnique({
      where: { email: email.getValue() },
    });

    return record ? this.toDomain(record) : null;
  }

  private toPersistence(user: User): any {
    return {
      id: user.getId().toString(),
      email: user.getEmail().getValue(),
      name: user.getName(),
      status: user.getStatus(),
      created_at: user.getCreatedAt(),
      updated_at: user.getUpdatedAt(),
    };
  }

  private toDomain(record: any): User {
    return User.reconstitute(
      new UserId(record.id),
      new Email(record.email),
      record.name,
      record.status,
      record.created_at,
      record.updated_at,
    );
  }
}

Data Mapping Strategies ​

Domain to Persistence Mapping ​

Convert rich domain objects to flat database records:

typescript
@Injectable()
export class OrderRepository implements OrderRepositoryInterface {
  private toPersistence(order: Order): any {
    // Flatten nested value objects
    const { amount, currency } = order.getTotal();
    
    return {
      id: order.getId().toString(),
      customer_id: order.getCustomerId().toString(),
      status: order.getStatus(),
      total_amount: amount,
      total_currency: currency,
      // Handle collections
      items: order.getItems().map(item => ({
        product_id: item.getProductId(),
        quantity: item.getQuantity(),
        price: item.getPrice().getAmount(),
        currency: item.getPrice().getCurrency(),
      })),
      // Serialize complex value objects
      shipping_address: JSON.stringify(order.getShippingAddress()),
      metadata: order.getMetadata(),
    };
  }
}

Persistence to Domain Mapping ​

Reconstruct domain entities from database records:

typescript
private toDomain(record: any): Order {
  // Reconstruct value objects
  const orderId = new OrderId(record.id);
  const customerId = new CustomerId(record.customer_id);
  const total = new Money(record.total_amount, record.total_currency);
  
  // Map collections
  const items = record.items.map((item: any) => 
    OrderItem.reconstitute(
      item.product_id,
      item.quantity,
      new Money(item.price, item.currency),
    )
  );
  
  // Deserialize complex objects
  const shippingAddress = Address.fromJSON(
    JSON.parse(record.shipping_address)
  );
  
  return Order.reconstitute(
    orderId,
    customerId,
    items,
    total,
    shippingAddress,
    record.status,
    record.metadata,
  );
}

Aggregate Repositories ​

Repositories should operate on aggregate roots, managing the entire aggregate:

typescript
// domain/interfaces/organization-repository.interface.ts
export interface OrganizationRepositoryInterface {
  save(organization: Organization): Promise<void>;
  findById(id: OrganizationId): Promise<Organization | null>;
  findBySlug(slug: string): Promise<Organization | null>;
}

// infrastructure/repositories/organization.repository.ts
@Injectable()
export class PrismaOrganizationRepository implements OrganizationRepositoryInterface {
  constructor(private readonly prisma: PrismaService) {}

  async save(organization: Organization): Promise<void> {
    const data = this.toPersistence(organization);
    
    // Save aggregate root and all related entities in a transaction
    await this.prisma.$transaction(async (tx) => {
      // Save organization
      await tx.organization.upsert({
        where: { id: data.id },
        create: data.organization,
        update: data.organization,
      });

      // Delete removed members
      await tx.member.deleteMany({
        where: {
          organization_id: data.id,
          id: { notIn: data.memberIds },
        },
      });

      // Upsert current members
      for (const member of data.members) {
        await tx.member.upsert({
          where: { id: member.id },
          create: member,
          update: member,
        });
      }

      // Handle other aggregate parts...
    });
  }

  async findById(id: OrganizationId): Promise<Organization | null> {
    const record = await this.prisma.organization.findUnique({
      where: { id: id.toString() },
      include: {
        members: true,
        settings: true,
        // Include all aggregate parts
      },
    });

    if (!record) {
      return null;
    }

    return this.toDomain(record);
  }

  private toDomain(record: any): Organization {
    const members = record.members.map((m: any) =>
      Member.reconstitute(
        new MemberId(m.id),
        new UserId(m.user_id),
        m.role,
        m.joined_at,
      )
    );

    const settings = OrganizationSettings.reconstitute(
      record.settings.timezone,
      record.settings.language,
      record.settings.features,
    );

    return Organization.reconstitute(
      new OrganizationId(record.id),
      record.name,
      record.slug,
      members,
      settings,
      record.created_at,
    );
  }
}

Query Optimization ​

Lazy Loading vs Eager Loading ​

typescript
export class ProductRepository implements ProductRepositoryInterface {
  // Simple find - minimal data
  async findById(id: ProductId): Promise<Product | null> {
    const record = await this.prisma.product.findUnique({
      where: { id: id.toString() },
    });

    return record ? this.toDomain(record) : null;
  }

  // Find with relations - eager load when needed
  async findByIdWithReviews(id: ProductId): Promise<ProductWithReviews | null> {
    const record = await this.prisma.product.findUnique({
      where: { id: id.toString() },
      include: {
        reviews: {
          orderBy: { created_at: 'desc' },
          take: 10,
        },
        category: true,
      },
    });

    return record ? this.toDomainWithReviews(record) : null;
  }
}

Specification Pattern ​

Implement complex queries with specifications:

typescript
// domain/specifications/active-premium-users.specification.ts
export class ActivePremiumUsersSpecification {
  constructor(
    private readonly minPurchases: number = 5,
    private readonly since: Date = subMonths(new Date(), 3),
  ) {}

  toQuery() {
    return {
      status: 'ACTIVE',
      subscription_tier: 'PREMIUM',
      purchases: {
        count: { gte: this.minPurchases },
      },
      last_login: { gte: this.since },
    };
  }
}

// Repository implementation
export class UserRepository {
  async findBySpecification(spec: Specification): Promise<User[]> {
    const records = await this.prisma.user.findMany({
      where: spec.toQuery(),
    });

    return records.map(this.toDomain);
  }
}

// Usage in use case
const activeUsers = await this.userRepository.findBySpecification(
  new ActivePremiumUsersSpecification(10)
);

Dependency Injection ​

Module Registration ​

Register repository implementations directly as providers:

typescript
// infrastructure/users.module.ts
@Module({
  providers: [
    PrismaUserRepository,
    PrismaOrderRepository,
    RegisterUserUseCase,
  ],
  exports: [PrismaUserRepository, PrismaOrderRepository],
})
export class UsersModule {}

Using in Use Cases ​

Inject the concrete repository class directly:

typescript
@Injectable()
export class RegisterUserUseCase {
  constructor(
    private readonly userRepository: PrismaUserRepository,
    private readonly emailService: SesEmailService,
  ) {}

  async execute(request: RegisterUserRequest): Promise<User> {
    // Check if user exists
    const existingUser = await this.userRepository.findByEmail(
      new Email(request.email)
    );

    if (existingUser) {
      throw new UserAlreadyExistsError();
    }

    // Create and save new user
    const user = User.create(
      new Email(request.email),
      request.name,
      request.password,
    );

    await this.userRepository.save(user);
    await this.emailService.sendWelcomeEmail(user);

    return user;
  }
}

Testing Repositories ​

In-Memory Implementation ​

Create test doubles for unit testing:

typescript
// test/repositories/in-memory-user.repository.ts
export class InMemoryUserRepository implements UserRepositoryInterface {
  private users = new Map<string, User>();

  async save(user: User): Promise<User> {
    this.users.set(user.getId().toString(), user);
    return user;
  }

  async findById(id: UserId): Promise<User | null> {
    return this.users.get(id.toString()) || null;
  }

  async findByEmail(email: Email): Promise<User | null> {
    for (const user of this.users.values()) {
      if (user.getEmail().equals(email)) {
        return user;
      }
    }
    return null;
  }

  async findActiveUsers(): Promise<User[]> {
    return Array.from(this.users.values()).filter(
      user => user.getStatus() === UserStatus.ACTIVE
    );
  }

  async delete(id: UserId): Promise<void> {
    this.users.delete(id.toString());
  }

  async exists(email: Email): Promise<boolean> {
    return (await this.findByEmail(email)) !== null;
  }

  // Test helper methods
  clear(): void {
    this.users.clear();
  }

  getAll(): User[] {
    return Array.from(this.users.values());
  }
}

Integration Testing ​

Test real repository implementations:

typescript
describe('PrismaUserRepository', () => {
  let repository: PrismaUserRepository;
  let prisma: PrismaService;

  beforeAll(async () => {
    const module = await Test.createTestingModule({
      imports: [PrismaModule],
      providers: [PrismaUserRepository],
    }).compile();

    repository = module.get<PrismaUserRepository>(PrismaUserRepository);
    prisma = module.get<PrismaService>(PrismaService);
  });

  beforeEach(async () => {
    await prisma.user.deleteMany();
  });

  describe('save', () => {
    it('should persist a new user', async () => {
      const user = User.create(
        new Email('test@example.com'),
        'Test User',
        'password123',
      );

      const saved = await repository.save(user);

      expect(saved.getId()).toEqual(user.getId());
      expect(saved.getEmail()).toEqual(user.getEmail());

      const found = await repository.findById(user.getId());
      expect(found).toBeDefined();
    });

    it('should update an existing user', async () => {
      const user = User.create(
        new Email('test@example.com'),
        'Test User',
        'password123',
      );
      
      await repository.save(user);
      
      user.changeName('Updated Name');
      await repository.save(user);

      const found = await repository.findById(user.getId());
      expect(found?.getName()).toBe('Updated Name');
    });
  });
});

Advanced Patterns ​

Unit of Work ​

Manage transactions across multiple repositories:

typescript
@Injectable()
export class PrismaUnitOfWork {
  constructor(private readonly prisma: PrismaService) {}

  async execute<T>(
    work: (tx: Prisma.TransactionClient) => Promise<T>
  ): Promise<T> {
    return this.prisma.$transaction(work);
  }
}

// Usage in use case
@Injectable()
export class TransferMoneyUseCase {
  constructor(
    private readonly unitOfWork: PrismaUnitOfWork,
    private readonly accountRepository: PrismaAccountRepository,
  ) {}

  async execute(request: TransferMoneyRequest): Promise<void> {
    await this.unitOfWork.execute(async (tx) => {
      const fromAccount = await this.accountRepository.findById(
        request.fromAccountId,
        tx, // Pass transaction context
      );
      
      const toAccount = await this.accountRepository.findById(
        request.toAccountId,
        tx,
      );

      fromAccount.withdraw(request.amount);
      toAccount.deposit(request.amount);

      await this.accountRepository.save(fromAccount, tx);
      await this.accountRepository.save(toAccount, tx);
    });
  }
}

Caching Layer ​

Add caching without changing the interface:

typescript
@Injectable()
export class CachedUserRepository implements UserRepositoryInterface {
  constructor(
    private readonly repository: PrismaUserRepository,
    @Inject(CACHE_MANAGER)
    private readonly cache: Cache,
  ) {}

  async findById(id: UserId): Promise<User | null> {
    const cacheKey = `user:${id.toString()}`;
    
    // Check cache
    const cached = await this.cache.get<User>(cacheKey);
    if (cached) {
      return cached;
    }

    // Fetch from database
    const user = await this.repository.findById(id);
    
    if (user) {
      await this.cache.set(cacheKey, user, 300); // 5 minutes
    }

    return user;
  }

  async save(user: User): Promise<User> {
    const saved = await this.repository.save(user);
    
    // Invalidate cache
    const cacheKey = `user:${user.getId().toString()}`;
    await this.cache.del(cacheKey);
    
    return saved;
  }
}

Best Practices ​

DO ✅ ​

  • Return domain entities from repository methods
  • Hide persistence details within the repository
  • Use transactions for aggregate consistency
  • Map between domains and persistence models explicitly
  • Test with both in-memory and real implementations

DON'T ❌ ​

  • Expose database queries in repository interfaces
  • Return database models from repository methods
  • Create generic repositories for everything
  • Mix read and write operations indiscriminately
  • Leak ORM-specific types through the interface

Next Steps ​