معماری Repository در DDD با TypeScript: چرا برای هر Aggregate Root فقط یک Repository بسازیم؟

مقدمه: دام اشتباه رایج

در دنیای توسعه نرم‌افزار، یک باور غلط به شدت شایع شده است: “هر Entity باید Repository خودش را داشته باشد.” این تفکر که از معماری سنتی CRUD نشأت می‌گیرد، وقتی وارد حوزه Domain-Driven Design می‌شود، به یک فاجعه تبدیل می‌گردد.

مشکل اساسی چیست؟

تقلیل مدل غنی دامنه به لایه CRUD

وقتی برای هر Entity یک Repository می‌سازید، در واقع:

· منطق کسب‌وکار را به عملیات دیتابیسی تقلیل می‌دهید
· روابط معنادار بین موجودیت‌ها را نادیده می‌گیرید
· یکپارچگی داده‌ها را به خطر می‌اندازید

// ❌ طراحی اشتباه - Repository برای هر Entity
interface IOrderRepository { /* ... */ }
interface IOrderItemRepository { /* ... */ }
interface ICustomerRepository { /* ... */ }
interface IAddressRepository { /* ... */ }

// ✅ طراحی صحیح - Repository فقط برای Aggregate Root
interface IOrderRepository { /* ... */ }
interface ICustomerRepository { /* ... */ }

راه حل: قاعده طلایی DDD

هر Aggregate Root یک Repository

Aggregate Root مدلی است که:

· مرزهای یکپارچگی را تعریف می‌کند
· نقطه ورود به مجموعه‌ای از موجودیت‌های مرتبط است
· می‌تواند مستقیماً و تنها با ID خود لود شود

تست 30 ثانیه‌ای تشخیص Aggregate Root

سؤال کلیدی:

“آیا این شیء می‌تواند مستقیماً و تنها با ID خود از دیتابیس لود شود، یا برای معنا پیدا کردن نیاز به یک Parent دارد؟”

سناریوهای عملی:

مورد ۱: سفارش (Order) و آیتم‌های سفارش (OrderItems)

// ✅ Order یک Aggregate Root است
// می‌تواند مستقیم با OrderId لود شود
const order = await orderRepository.getById(orderId);

// ❌ OrderItem یک Aggregate Root نیست
// بدون Order معنا ندارد - نیاز به Parent دارد
// const orderItem = await orderItemRepository.getById(itemId); // اشتباه!

مورد ۲: مشتری (Customer) و آدرس‌ها (Addresses)

// ✅ Customer یک Aggregate Root است
const customer = await customerRepository.getById(customerId);

// آدرس‌ها از طریق Customer دسترسی می‌یابند
const addresses = customer.getAddresses();

مزایای رعایت این اصل

۱. حفظ یکپارچگی کسب‌وکار

// Domain Models
class OrderItem {
  constructor(
    public readonly productId: string,
    public readonly price: number,
    public readonly quantity: number
  ) {
    if (quantity <= 0) {
      throw new Error("Quantity must be positive");
    }
  }
}

class Order implements AggregateRoot {
  private readonly items: OrderItem[] = [];
  private status: OrderStatus = OrderStatus.PENDING;

  constructor(public readonly id: string) {}

  addItem(productId: string, price: number, quantity: number): void {
    // منطق کسب‌وکار در یک مکان متمرکز شده
    const item = new OrderItem(productId, price, quantity);
    this.items.push(item);
  }

  getItems(): ReadonlyArray<OrderItem> {
    return [...this.items];
  }

  markAsPaid(): void {
    this.status = OrderStatus.PAID;
  }
}

۲. ساده‌سازی لایه دیتا اکسس

// Repository Interface
interface AggregateRoot {
  id: string;
}

interface Repository<T extends AggregateRoot> {
  getById(id: string): Promise<T | null>;
  save(aggregate: T): Promise<void>;
  delete(id: string): Promise<void>;
}

// Order Repository Implementation
class OrderRepository implements Repository<Order> {
  async getById(orderId: string): Promise<Order | null> {
    // لود کردن کامل Aggregate در یک کوئری
    const orderData = await db.orders
      .findOne({ 
        _id: orderId 
      })
      .populate('items');

    if (!orderData) return null;

    return this.mapToDomain(orderData);
  }

  async save(order: Order): Promise<void> {
    await db.orders.findOneAndUpdate(
      { _id: order.id },
      this.mapToPersistence(order),
      { upsert: true }
    );
  }

  private mapToDomain(data: any): Order {
    const order = new Order(data._id);
    // بازسازی وضعیت دامنه از داده‌های persistence
    data.items.forEach((item: any) => {
      order.addItem(item.productId, item.price, item.quantity);
    });
    return order;
  }
}

۳. بهبود Performance

· کاهش تعداد کوئری‌های دیتابیس
· جلوگیری از N+1 Query Problem
· بارگذاری کامل Aggregate در یک درخواست

پیامدهای نقض این اصل

۱. آنمی دامنه (Domain Anemia)

// ❌ مدل‌های ضعیف بدون منطق کسب‌وکار
interface Order {
  id: string;
  items: OrderItem[];
}

interface OrderItem {
  id: string;
  productId: string;
  quantity: number;
  price: number;
}

// منطق کسب‌وکار پراکنده در سرویس‌ها
class OrderService {
  constructor(
    private orderRepository: IOrderRepository,
    private orderItemRepository: IOrderItemRepository, // ❌ اشتباه!
    private productRepository: IProductRepository
  ) {}

  async addItemToOrder(orderId: string, productId: string, quantity: number): Promise<void> {
    const order = await this.orderRepository.getById(orderId);
    const product = await this.productRepository.getById(productId);

    // منطق در سرویس، نه در دامنه
    if (quantity <= 0) throw new Error("Quantity must be positive");

    const item: OrderItem = {
      id: generateId(),
      productId,
      quantity,
      price: product.price
    };

    await this.orderItemRepository.save(item); // ❌ نقض یکپارچگی
  }
}

۲. مشکلات یکپارچگی داده‌ها

// ❌ احتمال ناسازگاری داده‌ها
const order = await orderRepository.getById(orderId);
const items = await orderItemRepository.getByOrderId(orderId);

// اگر items جداگانه لود شوند، ممکن است با order همگام نباشند

۳. پیچیدگی غیرضروری

· Repository های متعدد برای مدیریت
· تراکنش‌های پیچیده برای حفظ یکپارچگی
· رابط‌های کاربری پیچیده‌تر

الگوهای پیاده‌سازی صحیح

۱. طراحی بر اساس محدوده‌های کسب‌وکار

// Base Interfaces
interface AggregateRoot {
  id: string;
}

interface Repository<T extends AggregateRoot> {
  getById(id: string): Promise<T | null>;
  save(aggregate: T): Promise<void>;
  delete(id: string): Promise<void>;
}

// Order Aggregate
class Order implements AggregateRoot {
  constructor(
    public readonly id: string,
    private customerId: string,
    private items: OrderItem[] = [],
    private status: OrderStatus = OrderStatus.PENDING
  ) {}

  addItem(productId: string, price: number, quantity: number): void {
    const existingItem = this.items.find(item => item.productId === productId);

    if (existingItem) {
      existingItem.increaseQuantity(quantity);
    } else {
      this.items.push(new OrderItem(productId, price, quantity));
    }
  }

  getTotal(): number {
    return this.items.reduce((total, item) => total + item.getTotal(), 0);
  }

  // سایر متدهای دامنه...
}

// Order Repository
interface IOrderRepository extends Repository<Order> {
  findByCustomerId(customerId: string): Promise<Order[]>;
  findPendingOrders(): Promise<Order[]>;
}

class MongoDBOrderRepository implements IOrderRepository {
  async getById(id: string): Promise<Order | null> {
    const data = await db.collection('orders').findOne({ _id: id });
    return data ? this.mapToDomain(data) : null;
  }

  async save(order: Order): Promise<void> {
    const data = this.mapToPersistence(order);
    await db.collection('orders').updateOne(
      { _id: order.id },
      { $set: data },
      { upsert: true }
    );
  }

  private mapToDomain(data: any): Order {
    const order = new Order(data._id, data.customerId);
    // بازسازی وضعیت دامنه
    data.items.forEach((item: any) => {
      order.addItem(item.productId, item.price, item.quantity);
    });
    return order;
  }
}

۲. استفاده از Specification Pattern

// Specification Pattern
interface Specification<T> {
  isSatisfiedBy(candidate: T): boolean;
  and(other: Specification<T>): Specification<T>;
  or(other: Specification<T>): Specification<T>;
}

class OrderStatusSpecification implements Specification<Order> {
  constructor(private readonly status: OrderStatus) {}

  isSatisfiedBy(order: Order): boolean {
    return order.getStatus() === this.status;
  }

  and(other: Specification<Order>): Specification<Order> {
    return new AndSpecification(this, other);
  }

  or(other: Specification<Order>): Specification<Order> {
    return new OrSpecification(this, other);
  }
}

// Repository با قابلیت Specification
interface ISpecificationRepository<T extends AggregateRoot> extends Repository<T> {
  findBySpecification(spec: Specification<T>): Promise<T[]>;
}

مطالعه موردی: سیستم فروشگاه آنلاین

طراحی صحیح Aggregate Roots:

// ✅ Aggregate Roots
class Product implements AggregateRoot {
  constructor(
    public readonly id: string,
    private name: string,
    private price: number,
    private stock: number
  ) {}

  decreaseStock(quantity: number): void {
    if (this.stock < quantity) {
      throw new Error('Insufficient stock');
    }
    this.stock -= quantity;
  }
}

class Customer implements AggregateRoot {
  constructor(
    public readonly id: string,
    private email: string,
    private addresses: CustomerAddress[] = []
  ) {}

  addAddress(address: CustomerAddress): void {
    if (this.addresses.length >= 5) {
      throw new Error('Cannot add more than 5 addresses');
    }
    this.addresses.push(address);
  }
}

class Order implements AggregateRoot {
  // implementation as above
}

// ❌ این‌ها Aggregate Root نیستند
class OrderItem {
  // بخشی از Order
}

class CustomerAddress {
  // بخشی از Customer
}

class CartItem {
  // بخشی از ShoppingCart
}

سرویس‌های دامنه با طراحی صحیح:

class OrderApplicationService {
  constructor(
    private orderRepository: IOrderRepository,
    private productRepository: IProductRepository,
    private customerRepository: ICustomerRepository
  ) {}

  async createOrder(customerId: string, items: Array<{productId: string, quantity: number}>): Promise<string> {
    const customer = await this.customerRepository.getById(customerId);
    if (!customer) {
      throw new Error('Customer not found');
    }

    const order = new Order(generateId(), customerId);

    for (const item of items) {
      const product = await this.productRepository.getById(item.productId);
      if (!product) {
        throw new Error(`Product ${item.productId} not found`);
      }

      order.addItem(product.id, product.getPrice(), item.quantity);
      product.decreaseStock(item.quantity);

      await this.productRepository.save(product);
    }

    await this.orderRepository.save(order);
    return order.id;
  }
}

جمع‌بندی

قوانین طلایی:

  1. هر Aggregate Root فقط یک Repository
  2. موجودیت‌های child نباید Repository مستقل داشته باشند
  3. همیشه از طریق Aggregate Root به موجودیت‌های داخلی دسترسی پیدا کنید
  4. تغییرات را در سطح Aggregate commit کنید

پرسش‌های باز برای تیم‌تان:

· چه Repository هایی در پروژه فعلی دارید که برای non-Aggregate Root ساخته شده‌اند؟
· این طراحی چه مشکلاتی ایجاد کرده است؟
· چگونه می‌توانید طراحی را refactor کنید؟

// نمونه refactoring
// قبل:
interface IOrderItemRepository { // ❌
  getById(id: string): Promise<OrderItem>;
  save(item: OrderItem): Promise<void>;
}

// بعد:
interface IOrderRepository { // ✅
  getById(id: string): Promise<Order>;
  save(order: Order): Promise<void>;
  // OrderItem از طریق Order مدیریت می‌شود
}

این اصل ساده اما قدرتمند می‌تواند تفاوت بین یک معماری قابل نگهداری و یک کدبیس پیچیده و شکننده را ایجاد کند. با تمرین و درک عمیق مفاهیم DDD، می‌توانید از این اشتباه رایج اجتناب کنید.

سبد خرید
پیمایش به بالا