Repositories

Provide access to aggregates and read models. Three types aligned with CQRS.

Types

TypeReturnsUsed In
Query RepositoryRead ModelsQueries (display)
Read RepositoryAggregatesCommands (before mutation)
Write Repositoryvoid / IDCommands (persist)

Port Definition (Outbound)

ports/outbound/user.repository.outbound.ts

interface UserQueryRepositoryOutboundPort {
  findPaginated(options: PaginationOptions): Promise<PaginatedResult<UserReadModel>>;
}

interface UserReadRepositoryOutboundPort {
  findById(id: UserId): Promise<UserAggregate | null>;
  findByEmail(email: Email): Promise<UserAggregate | null>;
}

interface UserWriteRepositoryOutboundPort {
  save(user: UserAggregate): Promise<void>;
  delete(id: UserId): Promise<void>;
}

Implementation

Extend BaseOutboundAdapter for automatic error wrapping:

infra/outbound-adapters/user/user.repository.ts
import { BaseOutboundAdapter } from '@cosmneo/onion-lasagna/backend/core/onion-layers';

class UserRepository
  extends BaseOutboundAdapter
  implements
    UserQueryRepositoryOutboundPort,
    UserReadRepositoryOutboundPort,
    UserWriteRepositoryOutboundPort
{
  constructor(private readonly persistence: UserPersistence) {
    super();
  }

  async findById(id: UserId): Promise<UserAggregate | null> {
    const row = await this.persistence.findById(id.value);
    if (!row) return null;
    return UserAggregate.reconstitute(row);
  }

  async save(user: UserAggregate): Promise<void> {
    await this.persistence.upsert({
      id: user.id.value,
      email: user.email.value,
      name: user.name,
    });
  }
}

Note: BaseOutboundAdapter automatically wraps all methods with error handling, converting any thrown errors to InfraError.


Usage in Use Cases

// Query use case
class FindUsersQuery {
  constructor(private readonly queryRepo: UserQueryRepositoryOutboundPort) {}

  async execute(input: FindUsersInput): Promise<FindUsersOutput> {
    return this.queryRepo.findPaginated(input.pagination);
  }
}

// Command use case
class CreateUserCommand {
  constructor(
    private readonly readRepo: UserReadRepositoryOutboundPort,
    private readonly writeRepo: UserWriteRepositoryOutboundPort,
  ) {}

  async execute(input: CreateUserInput): Promise<void> {
    const existing = await this.readRepo.findByEmail(Email.create(input.email));
    if (existing) throw new EmailAlreadyExistsError(input.email);

    const user = UserAggregate.create(input);
    await this.writeRepo.save(user);
  }
}

Rules

  • ✅ Define ports in BC, implement in infrastructure
  • ✅ Return Aggregates from Read Repository
  • ✅ Return Read Models from Query Repository
  • ✅ Hydrate aggregates in repository (not caller)
  • ❌ Don't put business logic in repositories