Skip to main content
The @ooneex/repository component is a thin data access layer. You register a repository class with a decorator, implement the IRepository contract, and resolve it from the container. The contract standardizes how repositories open and close their data source and return paginated, searchable results — so every repository in your app reads and writes the same way, regardless of the database underneath.

Why this component

  • One contract for every repository. IRepository<T, TCriteria> defines open, close, and find — implement it the same way for any data source.
  • Paginated by default. find returns a FilterResultType<T> with resources, total, totalPages, page, and limit.
  • Search built in. Every find accepts an optional q query string alongside your own criteria fields.
  • Type-safe. Generics carry your entity type T and criteria type TCriteria through the whole API.
  • Container-managed. Register a repository with a decorator and resolve (or inject) it anywhere, with the scope you choose.

How it works

A repository implements the IRepository interface and is registered with @decorator.repository(). The decorator adds the class to the @ooneex/container, so you resolve it instead of constructing it by hand. The contract is intentionally small:
MethodPurpose
open()Open or initialize the data source connection; returns the underlying connection.
close()Close the data source connection.
find(criteria)Return entities matching criteria as a paginated FilterResultType<T>.
find always merges your own filter fields with three reserved keys:
FieldDefaultPurpose
page1Page number for pagination.
limit10Items per page.
qFree-text search query.
The result is always shaped the same way:
type FilterResultType<T> = {
  resources: T[];
  total: number;
  totalPages: number;
  page: number;
  limit: number;
};

Decorator and usage

@decorator.repository(scope?)

Registers a repository class with the container. It accepts an optional scope (defaults to EContainerScope.Singleton).
ScopeBehavior
SingletonOne instance shared across all resolutions (default).
TransientA new instance is created on every resolution.
RequestA new instance per request context.
Implement the IRepository<T, TCriteria> contract on your class:
import { decorator, type IRepository } from "@ooneex/repository";
import type { FilterResultType } from "@ooneex/types";

interface User {
  id: string;
  email: string;
  name: string;
}

interface UserCriteria {
  email?: string;
  name?: string;
  isActive?: boolean;
}

@decorator.repository()
class UserRepository implements IRepository<User, UserCriteria> {
  private connection: unknown = null;

  public async open(): Promise<unknown> {
    this.connection = await this.createConnection();
    return this.connection;
  }

  public async close(): Promise<void> {
    this.connection = null;
  }

  public async find(
    criteria: UserCriteria & { page?: number; limit?: number; q?: string },
  ): Promise<FilterResultType<User>> {
    const { page = 1, limit = 10, q, ...filters } = criteria;

    const users = await this.queryUsers(filters, q);
    const total = await this.countUsers(filters, q);

    return {
      resources: users,
      total,
      totalPages: Math.ceil(total / limit),
      page,
      limit,
    };
  }
}
Resolve it from the container and run a paginated, filtered query:
import { container } from "@ooneex/container";

const userRepository = container.get(UserRepository);

await userRepository.open();

const result = await userRepository.find({
  isActive: true,
  page: 1,
  limit: 20,
  q: "john",
});

result.resources; // User[]
result.total; // total matching rows
result.totalPages; // number of pages

await userRepository.close();
Pass a scope when you need a fresh instance per resolution:
import { decorator, type IRepository } from "@ooneex/repository";
import { EContainerScope } from "@ooneex/container";

@decorator.repository(EContainerScope.Transient)
class TransientRepository implements IRepository {
  // each container.get(...) returns a new instance
}

Best practices

  • Implement the full contract. Always provide open, close, and find so callers can treat every repository identically.
  • Keep pagination defaults sane. Default page to 1 and limit to a bounded value; never return an unbounded result set.
  • Always compute totalPages from total. Use Math.ceil(total / limit) so callers can paginate reliably.
  • Spread reserved keys out of your filters. Destructure page, limit, and q before building the query, then apply the rest as filter fields.
  • Honor q consistently. Apply the same search predicate to both the result query and the count, so totals match the page.
  • Resolve, don’t construct. Pull repositories from the container (or inject them) instead of using new, so scope and dependencies are wired correctly.
  • Pick the scope deliberately. Use Singleton for shared, stateless repositories; Transient or Request when each consumer needs isolated state.

CLI command

Scaffold a repository class and its test file with the generator. It writes the class under modules/<module>/src/repositories/<Name>Repository.ts, a spec under modules/<module>/tests/repositories/, and installs @ooneex/repository if it is missing.
# Interactive: prompts for the name
ooneex repository:create

# Provide the name
ooneex repository:create --name=User

# Target a module and overwrite
ooneex repository:create --name=User --module=auth --override
OptionDescriptionDefault
--nameRepository class name. The Repository suffix is appended automatically.Prompted if omitted
--moduleTarget module the class is generated into.shared
--overrideOverwrite an existing class without prompting.false
The generated class is a TypeORM-backed repository with open, close, find (paginated + searchable), and full CRUD methods, ready for you to wire up to your entity:
import { inject } from "@ooneex/container";
import type { ITypeormDatabase } from "@ooneex/database";
import { decorator } from "@ooneex/repository";
import type { FilterResultType } from "@ooneex/types";
import type { FindManyOptions, FindOptionsWhere, Repository, SaveOptions, UpdateResult } from "typeorm";
import { UserEntity } from "../entities/UserEntity";

@decorator.repository()
export class UserRepository {
  constructor(
    @inject("database")
    private readonly database: ITypeormDatabase,
  ) {}

  public async open(): Promise<Repository<UserEntity>> {
    return await this.database.open(UserEntity);
  }

  public async close(): Promise<void> {
    await this.database.close();
  }

  public async find(
    criteria: FindManyOptions<UserEntity> & { page?: number; limit?: number; q?: string },
  ): Promise<FilterResultType<UserEntity>> {
    const repository = await this.open();

    const { page = 1, limit = 100, q, ...rest } = criteria;

    let skip: number | undefined;
    const take = limit === 0 ? 100 : limit;

    if (page && page > 0 && limit && limit > 0) {
      skip = (page - 1) * take;
    }

    let findOptions = { ...rest, take, ...(skip !== undefined && { skip }) };
    if (q) {
      findOptions = {
        ...findOptions,
        where: {
          ...rest.where,
          // name: ILike(`%${q}%`),
        },
      };
    }

    const result = await repository.find(findOptions);

    let countWhere = rest.where;
    if (q) {
      countWhere = {
        ...rest.where,
        // name: ILike(`%${q}%`),
      };
    }

    const total = await this.count(countWhere);
    const totalPages = Math.ceil(total / limit);

    return { resources: result, total, totalPages, page, limit };
  }

  public async findOne(id: string): Promise<UserEntity | null> {
    const repository = await this.open();
    return await repository.findOne({ where: { id } });
  }

  public async findOneBy(criteria: FindOptionsWhere<UserEntity>): Promise<UserEntity | null> {
    const repository = await this.open();
    return await repository.findOne({ where: criteria });
  }

  public async create(entity: UserEntity, options?: SaveOptions): Promise<UserEntity> {
    const repository = await this.open();
    return await repository.save(entity, options);
  }

  public async createMany(entities: UserEntity[], options?: SaveOptions): Promise<UserEntity[]> {
    const repository = await this.open();
    return await repository.save(entities, options);
  }

  public async update(entity: Partial<UserEntity> & { id: string }): Promise<UpdateResult> {
    const repository = await this.open();
    return await repository.update(entity.id, entity);
  }

  public async updateMany(entities: (Partial<UserEntity> & { id: string })[]): Promise<UpdateResult[]> {
    const repository = await this.open();
    return await Promise.all(entities.map((entity) => repository.update(entity.id, entity)));
  }

  public async delete(
    criteria: FindOptionsWhere<UserEntity> | FindOptionsWhere<UserEntity>[],
  ): Promise<UpdateResult> {
    const repository = await this.open();
    return await repository.softDelete(criteria);
  }

  public async count(criteria?: FindOptionsWhere<UserEntity> | FindOptionsWhere<UserEntity>[]): Promise<number> {
    const repository = await this.open();
    return await repository.count(criteria ? { where: criteria } : {});
  }
}
See repository:create for the full command reference.

Use with Claude and Codex

The generator ships a matching repository:create skill. It runs the scaffold and then guides your AI agent through completing the repository — verifying the entity import path, adjusting the find search fields, and trimming or adding CRUD methods to fit the entity. Initialize the skills once for your agent:
ooneex claude:init
Then ask Claude in natural language — it maps the request to the generator, runs it, and fills in the implementation:
Prompt
Create a repository for the User entity.
For example, the prompt above maps to repository:create --name=User, then completes the CRUD methods against the User entity.