agentsclimarketplace

Repository pattern

Skill Methasit-Pun/ts-ddd-clean-architecture/skills/backend-engineer/repository-pattern

TypeScript DDD Clean Architecture Skills

Install
npx -y skills add Methasit-Pun/ts-ddd-clean-architecture --skill repository-pattern

Assembled from the repository path, not quoted from the project. Check it against their README if it does not work.

One thing to look at

  • 0 stars0 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.

What its author says it does

Copied from the file, not written here

Design and implement the Repository pattern in a TypeScript DDD clean architecture project — define repository interfaces in the domain layer and implementations in the infrastructure layer. Trigger when the user says "create a repository", "implement persistence", "add database access", "wire up TypeORM/Prisma/Drizzle", "implement the repository interface", "add Unit of Work", "how do I persist this aggregate", or when connecting domain aggregates to any data store. Also trigger when reviewing persistence code that may be violating the dependency rule.

SKILL.md

6.7 KB, ~1.4k tokens by cl100k_base, as published. Nobody here has run it

Repository Pattern — TypeScript DDD

Repositories are the bridge between the domain and persistence. The interface lives in the domain layer; the implementation lives in infrastructure. This is the dependency inversion principle in action — domain depends on nothing outside itself.

Core rule

The domain defines what it needs. Infrastructure decides how to deliver it.


Folder structure

src/[context]/
  domain/
    repositories/
      I[AggregateName]Repository.ts    ← interface (domain owns this)
  infrastructure/
    persistence/
      [AggregateName]Repository.ts     ← concrete implementation
      [AggregateName]OrmModel.ts       ← ORM/DB mapping model (infrastructure only)
      mappers/
        [AggregateName]Mapper.ts       ← maps between ORM model ↔ domain aggregate

Domain repository interface

Keep it minimal — only the operations the domain actually needs:

// src/[context]/domain/repositories/IOrderRepository.ts
export interface IOrderRepository {
  findById(id: OrderId): Promise<Order | null>;
  save(order: Order): Promise<void>;
  delete(id: OrderId): Promise<void>;
}

Interface rules:

  • Parameters and return types use domain types only (Aggregates, Value Objects, Domain IDs)
  • No ORM types, no database primitives, no Promise<any>
  • Method names reflect the ubiquitous language, not SQL (findById, not selectByPrimaryKey)
  • save handles both insert and update (upsert semantics) — callers don't think about DB state

ORM model (infrastructure only)

The ORM model is a plain persistence concern — it never crosses into domain:

// src/[context]/infrastructure/persistence/OrderOrmModel.ts
// Example with TypeORM
@Entity('orders')
export class OrderOrmModel {
  @PrimaryColumn('uuid') id: string;
  @Column() customerId: string;
  @Column() status: string;
  @CreateDateColumn() createdAt: Date;
  @OneToMany(() => OrderItemOrmModel, item => item.order, { cascade: true, eager: true })
  items: OrderItemOrmModel[];
}

Mapper

The mapper translates between the persistence model and the domain aggregate. It lives in infrastructure and knows about both sides:

// src/[context]/infrastructure/persistence/mappers/OrderMapper.ts
export class OrderMapper {
  static toDomain(raw: OrderOrmModel): Order {
    return Order.reconstitute({      // use a factory method, not the constructor
      id: new OrderId(raw.id),
      customerId: new CustomerId(raw.customerId),
      status: OrderStatus.fromString(raw.status),
      items: raw.items.map(OrderItemMapper.toDomain),
      createdAt: raw.createdAt,
    });
  }

  static toPersistence(order: Order): OrderOrmModel {
    const model = new OrderOrmModel();
    model.id = order.id.value;
    model.customerId = order.customerId.value;
    model.status = order.status.value;
    model.items = order.items.map(OrderItemMapper.toPersistence);
    return model;
  }
}

Mapper rules:

  • Aggregates should expose a reconstitute static factory that bypasses constructor invariants (we trust DB data)
  • Never call new Order() with raw DB strings — always go through the mapper
  • The mapper is the only place that knows the ORM model schema

Repository implementation

// src/[context]/infrastructure/persistence/OrderRepository.ts
@Injectable()
export class OrderRepository implements IOrderRepository {
  constructor(
    @InjectRepository(OrderOrmModel)
    private readonly ormRepo: TypeOrmRepository<OrderOrmModel>,
  ) {}

  async findById(id: OrderId): Promise<Order | null> {
    const model = await this.ormRepo.findOne({ where: { id: id.value } });
    return model ? OrderMapper.toDomain(model) : null;
  }

  async save(order: Order): Promise<void> {
    const model = OrderMapper.toPersistence(order);
    await this.ormRepo.save(model);
  }

  async delete(id: OrderId): Promise<void> {
    await this.ormRepo.delete({ id: id.value });
  }
}

Unit of Work (when needed)

Use Unit of Work when a command must persist changes to multiple aggregates atomically:

// src/shared/application/ports/IUnitOfWork.ts
export interface IUnitOfWork {
  begin(): Promise<void>;
  commit(): Promise<void>;
  rollback(): Promise<void>;
  getRepository<T>(token: symbol): T;
}

Only introduce Unit of Work when you have a real multi-aggregate transaction need. Adding it preemptively is over-engineering.


Prisma variant

If using Prisma instead of TypeORM, the same pattern applies — just different infrastructure wiring:

export class OrderRepository implements IOrderRepository {
  constructor(private readonly prisma: PrismaClient) {}

  async findById(id: OrderId): Promise<Order | null> {
    const raw = await this.prisma.order.findUnique({
      where: { id: id.value },
      include: { items: true },
    });
    return raw ? OrderMapper.toDomain(raw) : null;
  }

  async save(order: Order): Promise<void> {
    const data = OrderMapper.toPrismaCreate(order);
    await this.prisma.order.upsert({
      where: { id: order.id.value },
      create: data,
      update: data,
    });
  }
}

Read repository (for CQRS queries)

Separate the write repository from the read repository — queries should never load full aggregates:

// src/[context]/domain/repositories/IOrderReadRepository.ts
export interface IOrderReadRepository {
  findById(id: string): Promise<OrderDetailResult | null>;
  findByCustomer(customerId: string): Promise<OrderSummaryResult[]>;
}

The read repository can return plain objects/interfaces — not domain aggregates. See backend-engineer/cqrs for full CQRS guidance.


Common mistakes

MistakeFix
ORM model used as domain entityCreate a separate domain aggregate and use a mapper
Repository contains business logicMove invariant checks into the aggregate
Interface accepts ORM types as paramsReplace with domain Value Objects
findAll() with no paginationAdd findMany(criteria, pagination) instead
Repository loads child aggregates of another aggregateEach aggregate has its own repository — never load cross-boundary

What ships with it

Read from the repository

Just SKILL.md. No reference files, no scripts.

Keep looking

Skills are one crate of 327,069. Ordering is by how many stacks a row turns up in, so the top of any crate is what has actually been picked rather than what has the most stars.