Files
gruperly/.agents/skills/clean-code-principles/rules/pattern-repository.md
2026-09-04 16:49:24 -03:00

9.3 KiB

id, title, category, priority, tags, related
id title category priority tags related
pattern-repository Design Pattern - Repository design-patterns high
design-patterns
repository
data-access
separation-of-concerns
solid-dip-abstractions
solid-srp-class
core-separation-concerns

Repository Pattern

The Repository pattern abstracts data persistence, providing a collection-like interface for accessing domain objects. It separates business logic from data access, makes code testable, and allows swapping storage implementations without changing application code.

Bad Example

// ❌ Data access mixed with business logic
class OrderService {
  async createOrder(userId: string, items: CartItem[]) {
    // Business logic
    const total = items.reduce((sum, i) => sum + i.price * i.quantity, 0);

    // Direct database access - tightly coupled
    const result = await pool.query(
      `INSERT INTO orders (user_id, total, status, created_at)
       VALUES ($1, $2, $3, NOW()) RETURNING *`,
      [userId, total, 'pending']
    );

    const orderId = result.rows[0].id;

    // More SQL scattered in business logic
    for (const item of items) {
      await pool.query(
        `INSERT INTO order_items (order_id, product_id, quantity, price)
         VALUES ($1, $2, $3, $4)`,
        [orderId, item.productId, item.quantity, item.price]
      );
    }

    return result.rows[0];
  }

  async getOrdersByUser(userId: string) {
    // SQL everywhere
    const result = await pool.query(
      `SELECT o.*, json_agg(oi.*) as items
       FROM orders o
       LEFT JOIN order_items oi ON o.id = oi.order_id
       WHERE o.user_id = $1
       GROUP BY o.id`,
      [userId]
    );
    return result.rows;
  }
}

Problems:

  • Business logic mixed with SQL
  • Hard to test without real database
  • Changing database requires rewriting service
  • SQL scattered across codebase

Good Example

Define Repository Interface

// ✅ Repository interface - contract for data access
interface OrderRepository {
  find(id: string): Promise<Order | null>;
  findByUser(userId: string): Promise<Order[]>;
  findByStatus(status: OrderStatus): Promise<Order[]>;
  save(order: Order): Promise<Order>;
  delete(id: string): Promise<void>;
}

interface OrderItemRepository {
  findByOrder(orderId: string): Promise<OrderItem[]>;
  saveMany(items: OrderItem[]): Promise<OrderItem[]>;
  deleteByOrder(orderId: string): Promise<void>;
}

Implement Repository

// ✅ PostgreSQL implementation
class PostgresOrderRepository implements OrderRepository {
  constructor(private db: Pool) {}

  async find(id: string): Promise<Order | null> {
    const result = await this.db.query(
      'SELECT * FROM orders WHERE id = $1',
      [id]
    );
    return result.rows[0] ? this.mapToOrder(result.rows[0]) : null;
  }

  async findByUser(userId: string): Promise<Order[]> {
    const result = await this.db.query(
      'SELECT * FROM orders WHERE user_id = $1 ORDER BY created_at DESC',
      [userId]
    );
    return result.rows.map(this.mapToOrder);
  }

  async findByStatus(status: OrderStatus): Promise<Order[]> {
    const result = await this.db.query(
      'SELECT * FROM orders WHERE status = $1',
      [status]
    );
    return result.rows.map(this.mapToOrder);
  }

  async save(order: Order): Promise<Order> {
    if (order.id) {
      return this.update(order);
    }
    return this.insert(order);
  }

  private async insert(order: Order): Promise<Order> {
    const result = await this.db.query(
      `INSERT INTO orders (user_id, total, status, created_at)
       VALUES ($1, $2, $3, NOW()) RETURNING *`,
      [order.userId, order.total, order.status]
    );
    return this.mapToOrder(result.rows[0]);
  }

  private async update(order: Order): Promise<Order> {
    const result = await this.db.query(
      `UPDATE orders SET total = $1, status = $2, updated_at = NOW()
       WHERE id = $3 RETURNING *`,
      [order.total, order.status, order.id]
    );
    return this.mapToOrder(result.rows[0]);
  }

  async delete(id: string): Promise<void> {
    await this.db.query('DELETE FROM orders WHERE id = $1', [id]);
  }

  private mapToOrder(row: any): Order {
    return new Order({
      id: row.id,
      userId: row.user_id,
      total: parseFloat(row.total),
      status: row.status as OrderStatus,
      createdAt: row.created_at,
      updatedAt: row.updated_at,
    });
  }
}

Clean Service Layer

// ✅ Service focuses on business logic only
class OrderService {
  constructor(
    private orderRepository: OrderRepository,
    private orderItemRepository: OrderItemRepository,
    private productRepository: ProductRepository,
  ) {}

  async createOrder(userId: string, items: CartItem[]): Promise<Order> {
    // Pure business logic
    const total = this.calculateTotal(items);

    const order = new Order({
      userId,
      total,
      status: OrderStatus.Pending,
    });

    // Repository handles persistence
    const savedOrder = await this.orderRepository.save(order);

    const orderItems = items.map(item => new OrderItem({
      orderId: savedOrder.id,
      productId: item.productId,
      quantity: item.quantity,
      price: item.price,
    }));

    await this.orderItemRepository.saveMany(orderItems);

    return savedOrder;
  }

  async cancelOrder(orderId: string): Promise<Order> {
    const order = await this.orderRepository.find(orderId);

    if (!order) {
      throw new OrderNotFoundException(orderId);
    }

    if (!order.canBeCancelled()) {
      throw new InvalidOrderStateException('Order cannot be cancelled');
    }

    order.cancel();
    return this.orderRepository.save(order);
  }

  private calculateTotal(items: CartItem[]): number {
    return items.reduce((sum, item) => sum + item.price * item.quantity, 0);
  }
}

In-Memory Repository for Testing

// ✅ In-memory implementation for tests
class InMemoryOrderRepository implements OrderRepository {
  private orders: Map<string, Order> = new Map();
  private idCounter = 1;

  async find(id: string): Promise<Order | null> {
    return this.orders.get(id) ?? null;
  }

  async findByUser(userId: string): Promise<Order[]> {
    return Array.from(this.orders.values())
      .filter(order => order.userId === userId);
  }

  async findByStatus(status: OrderStatus): Promise<Order[]> {
    return Array.from(this.orders.values())
      .filter(order => order.status === status);
  }

  async save(order: Order): Promise<Order> {
    if (!order.id) {
      order.id = String(this.idCounter++);
    }
    this.orders.set(order.id, order);
    return order;
  }

  async delete(id: string): Promise<void> {
    this.orders.delete(id);
  }

  // Test helper methods
  clear(): void {
    this.orders.clear();
  }

  seed(orders: Order[]): void {
    orders.forEach(order => this.orders.set(order.id, order));
  }
}

Easy Testing

// ✅ Unit tests without database
describe('OrderService', () => {
  let orderService: OrderService;
  let orderRepository: InMemoryOrderRepository;
  let orderItemRepository: InMemoryOrderItemRepository;

  beforeEach(() => {
    orderRepository = new InMemoryOrderRepository();
    orderItemRepository = new InMemoryOrderItemRepository();
    orderService = new OrderService(
      orderRepository,
      orderItemRepository,
      new InMemoryProductRepository(),
    );
  });

  it('creates order with calculated total', async () => {
    const items = [
      { productId: '1', quantity: 2, price: 10 },
      { productId: '2', quantity: 1, price: 25 },
    ];

    const order = await orderService.createOrder('user-1', items);

    expect(order.total).toBe(45);
    expect(order.status).toBe(OrderStatus.Pending);
  });

  it('cancels pending order', async () => {
    orderRepository.seed([
      new Order({ id: '1', userId: 'user-1', status: OrderStatus.Pending }),
    ]);

    const order = await orderService.cancelOrder('1');

    expect(order.status).toBe(OrderStatus.Cancelled);
  });

  it('throws when cancelling shipped order', async () => {
    orderRepository.seed([
      new Order({ id: '1', userId: 'user-1', status: OrderStatus.Shipped }),
    ]);

    await expect(orderService.cancelOrder('1'))
      .rejects.toThrow(InvalidOrderStateException);
  });
});

Generic Repository Base

// ✅ Generic base for common operations
interface Repository<T, ID> {
  find(id: ID): Promise<T | null>;
  findAll(): Promise<T[]>;
  save(entity: T): Promise<T>;
  delete(id: ID): Promise<void>;
  exists(id: ID): Promise<boolean>;
}

abstract class BasePostgresRepository<T, ID> implements Repository<T, ID> {
  constructor(
    protected db: Pool,
    protected tableName: string,
  ) {}

  async find(id: ID): Promise<T | null> {
    const result = await this.db.query(
      `SELECT * FROM ${this.tableName} WHERE id = $1`,
      [id]
    );
    return result.rows[0] ? this.mapToEntity(result.rows[0]) : null;
  }

  async exists(id: ID): Promise<boolean> {
    const result = await this.db.query(
      `SELECT 1 FROM ${this.tableName} WHERE id = $1`,
      [id]
    );
    return result.rows.length > 0;
  }

  protected abstract mapToEntity(row: any): T;
}

Why

  • Separation of concerns
  • Business logic free of persistence details
  • Easy to test with in-memory implementations
  • Swap databases without changing services
  • Centralized query logic
  • Consistent data access patterns
  • Single place to add caching, logging