Skip to main content

What is a Repository?

A Repository is an abstraction that mediates between the domain layer and the data mapping layer. It provides a collection-like interface for accessing domain aggregates while hiding the complexity of database operations.
// Repository abstracts away persistence details
const user = await userRepository.findById("user-123");
user.changeName("New Name");
await userRepository.save(user);

// The domain code doesn't know about:
// - Database connections
// - SQL/ORM queries  
// - Data mapping
// - Which database is being used

Why Use Repositories?

Domain Isolation

Domain code stays free of persistence concerns

Testability

Easy to mock or use in-memory implementations for testing

Flexibility

Switch databases or ORMs without changing domain code

Consistency

Centralized place for query logic and data access patterns

Repository Types

The library provides several base classes for different use cases:

Repository (Full CRUD)

Complete read and write operations:
import { Repository, Aggregate, Criteria, PaginatedResult } from "@woltz/rich-domain";

abstract class Repository<TDomain extends Aggregate<any>> {
  abstract find(criteria?: Criteria<TDomain>): Promise<PaginatedResult<TDomain>>;
  abstract findById(id: string): Promise<TDomain | null>;
  abstract save(entity: TDomain): Promise<void>;
  abstract delete(entity: TDomain): Promise<void>;
  abstract count(criteria?: Criteria<TDomain>): Promise<number>;
  abstract exists(id: string): Promise<boolean>;
}

ReadRepository

Read-only operations (useful for CQRS read models):
abstract class ReadRepository<TDomain extends Aggregate<any>> {
  abstract find(criteria?: Criteria<TDomain>): Promise<PaginatedResult<TDomain>>;
  abstract findById(id: string): Promise<TDomain | null>;
  abstract count(criteria?: Criteria<TDomain>): Promise<number>;
  abstract exists(id: string): Promise<boolean>;
}

WriteRepository

Write-only operations:
abstract class WriteRepository<TDomain extends Aggregate<any>> {
  abstract save(entity: TDomain): Promise<void>;
  abstract delete(entity: TDomain): Promise<void>;
}

Implementing a Repository

Step 1: Define Your Aggregate

import { Aggregate, Id } from "@woltz/rich-domain";

interface UserProps {
  id: Id;
  name: string;
  email: string;
  status: "active" | "inactive";
}

class User extends Aggregate<UserProps> {
  get name() { return this.props.name; }
  get email() { return this.props.email; }
  get status() { return this.props.status; }

  changeName(name: string) {
    this.props.name = name;
  }

  activate() {
    this.props.status = "active";
  }
}

Step 2: Create Mappers

Mappers transform data between domain models and persistence format:
import { Mapper, Id } from "@woltz/rich-domain";

// Domain → Persistence
class UserToPersistenceMapper extends Mapper<User, UserRecord> {
  build(user: User): UserRecord {
    return {
      id: user.id.value,
      name: user.name,
      email: user.email,
      status: user.status,
      created_at: new Date(),
      updated_at: new Date(),
    };
  }
}

// Persistence → Domain
class UserToDomainMapper extends Mapper<UserRecord, User> {
  build(record: UserRecord): User {
    return new User({
      id: Id.from(record.id),
      name: record.name,
      email: record.email,
      status: record.status as "active" | "inactive",
    });
  }
}

Step 3: Implement Repository

Here’s a database-agnostic implementation skeleton:
import { Repository, Criteria, PaginatedResult } from "@woltz/rich-domain";

class UserRepository extends Repository<User> {
  constructor(
    private db: YourDatabaseClient,  // Any database client
    private mapperToDomain: UserToDomainMapper,
    private mapperToPersistence: UserToPersistenceMapper
  ) {
    super();
  }

  async findById(id: string): Promise<User | null> {
    // Implement using your database client
    const record = await this.db.findOne("users", { id });
    return record ? this.mapperToDomain.build(record) : null;
  }

  async find(criteria: Criteria<User>): Promise<PaginatedResult<User>> {
    // Convert criteria to your database's query format
    const query = this.buildQuery(criteria);
    const pagination = criteria.getPagination();

    const [records, total] = await Promise.all([
      this.db.query("users", query, pagination),
      this.db.count("users", query),
    ]);

    const users = records.map((r) => this.mapperToDomain.build(r));
    return PaginatedResult.create(users, pagination, total);
  }

  async save(user: User): Promise<void> {
    const data = this.mapperToPersistence.build(user);
    
    if (user.isNew()) {
      await this.db.insert("users", data);
    } else {
      await this.db.update("users", user.id.value, data);
    }
  }

  async delete(user: User): Promise<void> {
    await this.db.delete("users", user.id.value);
  }

  async exists(id: string): Promise<boolean> {
    return this.db.exists("users", { id });
  }

  async count(criteria?: Criteria<User>): Promise<number> {
    const query = criteria ? this.buildQuery(criteria) : {};
    return this.db.count("users", query);
  }

  private buildQuery(criteria: Criteria<User>): object {
    // Convert criteria filters/orders to your database format
    // This is ORM/database-specific
  }
}
For complete implementations with specific ORMs, see:

Using the Repository

Finding Entities

// Find by ID
const user = await userRepository.findById("user-123");

// Find with criteria
const activeUsers = await userRepository.find(
  Criteria.create<User>()
    .whereEquals("status", "active")
    .orderByDesc("createdAt")
    .paginate(1, 20)
);

// Check existence
const exists = await userRepository.exists("user-123");

// Count
const count = await userRepository.count(
  Criteria.create<User>().whereEquals("status", "active")
);

Saving Entities

// Create new entity
const user = new User({
  name: "John Doe",
  email: "john@example.com",
  status: "active",
});
await userRepository.save(user);  // INSERT

// Update existing entity
const user = await userRepository.findById("user-123");
user.changeName("Jane Doe");
await userRepository.save(user);  // UPDATE

Deleting Entities

const user = await userRepository.findById("user-123");
await userRepository.delete(user);

Custom Query Methods

Add domain-specific query methods to your repository:
class UserRepository extends Repository<User> {
  // ... base implementation

  async findByEmail(email: string): Promise<User | null> {
    const record = await this.db.findOne("users", { email });
    return record ? this.mapperToDomain.build(record) : null;
  }

  async findActiveUsers(page: number, limit: number): Promise<PaginatedResult<User>> {
    return this.find(
      Criteria.create<User>()
        .whereEquals("status", "active")
        .orderByDesc("createdAt")
        .paginate(page, limit)
    );
  }

  async findRecentlyJoined(days: number): Promise<User[]> {
    const since = new Date();
    since.setDate(since.getDate() - days);

    const result = await this.find(
      Criteria.create<User>()
        .where("createdAt", "greaterThan", since)
        .orderByDesc("createdAt")
    );

    return result.data;
  }

  async countByStatus(status: "active" | "inactive"): Promise<number> {
    return this.count(
      Criteria.create<User>().whereEquals("status", status)
    );
  }
}

Repository with Change Tracking

For aggregates with complex nested structures, use change tracking to efficiently persist only what changed:
class OrderRepository extends Repository<Order> {
  async save(order: Order): Promise<void> {
    const changes = order.getChanges();

    // Skip if no changes
    if (changes.isEmpty() && !order.isNew()) {
      return;
    }

    // Use transaction for consistency
    await this.db.transaction(async (tx) => {
      if (order.isNew()) {
        // Create root aggregate
        await tx.insert("orders", this.mapOrder(order));
      }

      // Process batch operations in correct order
      const batch = changes.toBatchOperations();

      // 1. Deletes (leaf → root for FK constraints)
      for (const del of batch.deletes) {
        await tx.deleteMany(del.entity, { id: { in: del.ids } });
      }

      // 2. Creates (root → leaf for FK constraints)
      for (const create of batch.creates) {
        await tx.insertMany(
          create.entity,
          create.items.map((item) => this.mapItem(item))
        );
      }

      // 3. Updates
      for (const update of batch.updates) {
        for (const item of update.items) {
          await tx.update(update.entity, item.id, item.changedFields);
        }
      }
    });

    // Mark aggregate as clean after successful save
    order.markAsClean();
  }
}
The Prisma Adapter provides PrismaBatchExecutor that handles all this automatically.

Testing with Repositories

In-Memory Implementation

Create an in-memory implementation for unit tests:
class InMemoryUserRepository extends Repository<User> {
  private store = new Map<string, User>();

  async findById(id: string): Promise<User | null> {
    return this.store.get(id) || null;
  }

  async save(user: User): Promise<void> {
    this.store.set(user.id.value, user);
  }

  async delete(user: User): Promise<void> {
    this.store.delete(user.id.value);
  }

  async find(criteria: Criteria<User>): Promise<PaginatedResult<User>> {
    const all = Array.from(this.store.values());
    // PaginatedResult.fromArray applies criteria in-memory
    return PaginatedResult.fromArray(all, criteria);
  }

  async exists(id: string): Promise<boolean> {
    return this.store.has(id);
  }

  async count(criteria?: Criteria<User>): Promise<number> {
    if (!criteria) return this.store.size;
    const result = await this.find(criteria);
    return result.meta.total;
  }

  // Test helpers
  clear(): void {
    this.store.clear();
  }

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

Using in Tests

describe("UserService", () => {
  let userRepository: InMemoryUserRepository;
  let userService: UserService;

  beforeEach(() => {
    userRepository = new InMemoryUserRepository();
    userService = new UserService(userRepository);
  });

  afterEach(() => {
    userRepository.clear();
  });

  it("should create a user", async () => {
    const user = await userService.createUser({
      name: "John",
      email: "john@example.com",
    });

    expect(user.id).toBeDefined();
    expect(await userRepository.exists(user.id.value)).toBe(true);
  });

  it("should find active users", async () => {
    // Setup
    await userRepository.save(new User({ name: "Active", status: "active" }));
    await userRepository.save(new User({ name: "Inactive", status: "inactive" }));

    // Test
    const result = await userRepository.find(
      Criteria.create<User>().whereEquals("status", "active")
    );

    expect(result.data.length).toBe(1);
    expect(result.data[0].name).toBe("Active");
  });
});

Next Steps