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 repositories with injection tokens:

typescript
// infrastructure/users.module.ts
@Module({
  providers: [
    {
      provide: 'USER_REPOSITORY',
      useClass: PrismaUserRepository,
    },
    // Alternative: Use factory for conditional implementation
    {
      provide: 'ORDER_REPOSITORY',
      useFactory: (config: ConfigService, prisma: PrismaService) => {
        const dbType = config.get('database.type');
        
        switch (dbType) {
          case 'postgres':
            return new PrismaOrderRepository(prisma);
          case 'mongodb':
            return new MongoOrderRepository(config);
          case 'memory':
            return new InMemoryOrderRepository();
          default:
            throw new Error(`Unsupported database type: ${dbType}`);
        }
      },
      inject: [ConfigService, PrismaService],
    },
  ],
  exports: ['USER_REPOSITORY', 'ORDER_REPOSITORY'],
})
export class UsersModule {}

Using in Use Cases

Inject repositories using tokens:

typescript
@Injectable()
export class RegisterUserUseCase {
  constructor(
    @Inject('USER_REPOSITORY')
    private readonly userRepository: UserRepositoryInterface,
    @Inject('EMAIL_SERVICE')
    private readonly emailService: EmailServiceInterface,
  ) {}

  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,
    @Inject('ACCOUNT_REPOSITORY')
    private readonly accountRepository: AccountRepositoryInterface,
  ) {}

  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