Service Layer أم Repository أم Domain Services؟

الأنماط الثلاثة لا تتنافس على المكان نفسه: الأولى تنسّق حالة الاستخدام، والثاني يحمي المجال من تفاصيل التخزين، والثالث يحمل قاعدة تعبر عدة كائنات. معيار من ثلاثة أسئلة وحالة تسعير كاملة بالكود.

أديب الصلوي
كتب بواسطة أديب الصلوي
نشر في 10 سبتمبر 2026
وقت الدراسة 11 دقائق
Service Layer أم Repository أم Domain Services؟
الأنماط الثلاثة لا تتنافس على المكان نفسه: الأولى تنسّق حالة الاستخدام، والثاني يحمي المجال من تفاصيل التخزين، والثالث يحمل قاعدة تعبر عدة كائنات. معيار من ثلاثة أسئلة وحالة تسعير كاملة بالكود.

ملخص المقال: الأنماط الثلاثة لا تتنافس على المكان نفسه: الأولى تنسّق حالة الاستخدام، والثاني يحمي المجال من تفاصيل التخزين، والثالث يحمل قاعدة تعبر عدة كائنات. معيار من ثلاثة أسئلة وحالة تسعير كاملة بالكود.

طريقة القراءة: ابدأ بفهم المشكلة، ثم راجع القرار أو الخطوات العملية في الأقسام التالية، واستفد من المخطط لتصور الأجزاء المرتبطة ببعضها.


ماذا ستخرج به من هذا المقال؟


الأنماط الثلاثة لا تتنافس على المكان نفسه: الأولى تنسّق حالة الاستخدام، والثاني يحمي المجال من تفاصيل التخزين، والثالث يحمل قاعدة تعبر عدة كائنات. معيار من ثلاثة أسئلة وحالة تسعير كاملة بالكود. سنبني الفكرة من المشكلة إلى القرار ثم إلى تطبيق يمكن نقله إلى مشروع حقيقي، مع مخطط بصري ومثال عملي وقائمة تحقق في النهاية.




أكثر سؤال يتكرر في مراجعات الكود هو: أين أضع هذا السطر بالضبط؟ Service Layer وRepository وDomain Service لا تتنافس على المكان نفسه؛ كل واحدة تحل مشكلة مختلفة. المشكلة الحقيقية ليست اختيار النمط الخاطئ، بل تحميل نمط واحد مسؤولية نمط آخر حتى يتحول إلى God Service، أو إنشاء طبقة لا تفعل شيئًا سوى تمرير الاستدعاءات.


في هذا المقال نبني معيارًا واضحًا لتوزيع الكود، ثم نطبقه على حالة تسعير طلب حقيقية بكود TypeScript قابل للتشغيل.


ثلاثة أسئلة تحدد مكان الكود


قبل أن تفتح ملفًا جديدًا، اسأل ثلاثة أسئلة بالترتيب:



  • من ينسّق؟ إن كان الكود يقرر ترتيب الخطوات أو حدود المعاملة أو أي منفذ يُستدعى، فمكانه Service Layer.

  • من يقرر؟ إن كان الكود يقارن مدخلات ويصدر حكمًا — مؤهل أو غير مؤهل، السعر 172.5 أو 200 — فهو قرار مجال.

  • من يخزّن؟ إن كان الكود يتحدث عن صفوف وجداول واستعلامات، فمكانه Repository أو Adapter.


كل سطر لا يجيب عن أحد هذه الأسئلة الثلاثة غالبًا لا ينتمي إلى أي من الطبقات الثلاث، بل إلى مكان آخر تمامًا.


مسؤولية كل تجريد



  • Service Layer: تملك التسلسل — تحمّل، تطلب القرار، تطبّق النتيجة، تحفظ. لا تحتوي شروط عمل قابلة لإعادة الاستخدام، بل تنسيقًا بين خطوات.

  • Aggregate/Entity: يملك القواعد التي تعتمد على حالته الداخلية، مثل «لا يمكن إلغاء طلب مشحون».

  • Domain Service: يحمل قاعدة تعبر عدة كائنات ولا تنتمي طبيعيًا لأي منها، مثل التسعير أو الأهلية الائتمانية.

  • Repository: يعرض واجهة بلغة المجال (findOpenForCustomer, nextInvoiceNumber) بدل تسريب Query Builder إلى الخارج.

  • Read Projection: يمكن أن تتجاوز Repository عندما تكون القراءة بسيطة ولا تحمل أي قاعدة عمل.


كيف يمر قرار تسعير عبر الطبقات الثلاث


يصل الطلب إلى Controller، فيفوّضه فورًا إلى Application Service. تحمّل هذه الخدمة الطلب والمنتجات والعروض عبر Repositories، ثم تسأل PricingPolicy عن القرار. تعيد الخدمة سعرًا محسوبًا، وتطبقه على الكيان وتحفظه. لا تفتح خدمة التسعير قاعدة بيانات ولا ترسل بريدًا.


مسار قرار التسعير بين الطبقات

Controller يفوّض، وApplication Service ينسّق ويحمّل عبر Repositories، وPricingPolicy يقرر بلا I/O، ثم يعود القرار ليُطبَّق على الكيان ويُحفظ.

  1. Controller
  2. ←
  3. Application Service
  4. ←
  5. Repositories
  6. ←
  7. PricingPolicy
  8. ←
  9. Order Aggregate
  10. ←
  11. قاعدة البيانات

خطوة بخطوة: ضع كل سطر في مكانه


الخطوة 1: اكتب حالة الاستخدام كتسلسل صريح


حالة الاستخدام يجب أن تُقرأ كقائمة خطوات، لا كصندوق أسود. من يقرأ الدالة يجب أن يعرف الترتيب: ماذا نحمّل، ماذا نسأل، ماذا نطبّق، وماذا نحفظ.


export class PlaceOrder {
constructor(
private readonly orders: OrderRepository,
private readonly catalog: ProductCatalog,
private readonly pricing: PricingPolicy,
private readonly promos: PromotionRepository,
) {}

async execute(ctx: RequestContext, cmd: PlaceOrderCommand): Promise<OrderView> {
const products = await this.catalog.loadMany(ctx.tenantId, cmd.lines.map((l) => l.productId));
const activePromos = await this.promos.activeFor(ctx.tenantId, ctx.now);

const order = Order.draft(ctx.tenantId, ctx.userId);
for (const line of cmd.lines) order.addLine(products[line.productId], line.quantity);

// القرار لا يُتخذ هنا، بل يُطلب من خدمة المجال
const quote = this.pricing.quote({
lines: order.lines,
customer: cmd.customer,
promos: activePromos,
at: ctx.now,
});
order.applyQuote(quote);

await this.orders.save(ctx.tenantId, order);
return order.toView();
}
}

النتيجة المتوقعة: يمكنك قراءة هذا الملف لتفهم سلوك حالة الاستخدام كاملًا في أقل من دقيقة، دون فتح أي ملف آخر.


الخطوة 2: أعطِ الكيان القاعدة التي تعتمد على حالته


القاعدة التي تُقرأ من حالة الكائن نفسه تبقى داخله. نقلها إلى خدمة خارجية يعني أن الكيان يصبح مجرد حامل بيانات، وهذا هو Anemic Model الذي يوزع القواعد على كل مكان إلا المكان الصحيح.


export class Order {
private lines: OrderLine[] = [];
private status: OrderStatus = 'DRAFT';

addLine(product: Product, quantity: number): void {
if (this.status !== 'DRAFT') throw new DomainError('ORDER_NOT_EDITABLE', 409);
if (quantity < 1 || quantity > 999) throw new DomainError('INVALID_QUANTITY', 400);
this.lines.push({ productId: product.id, quantity, unitPrice: product.basePrice });
}

applyQuote(quote: OrderQuote): void {
if (this.status !== 'DRAFT') throw new DomainError('ORDER_NOT_EDITABLE', 409);
this.total = quote.total;
this.discount = quote.discount;
this.tax = quote.tax;
}
}

النتيجة المتوقعة: أي مسار يحاول تعديل طلب مؤكد يُرفض بنفس الخطأ ونفس الرمز، بلا تكرار للشرط في المسار وفي المهمة الخلفية.


الخطوة 3: انقل القاعدة العابرة إلى Domain Service


التسعير يعتمد على بنود الطلب وشريحة العميل والعروض النشطة والمنطقة الضريبية. لا ينتمي لأي كيان وحده، لذلك مكانه خدمة مجال. اجعلها دالة نقية بلا قاعدة بيانات وبلا وقت نظام لتصبح قابلة للاختبار بالكامل.


const SEGMENT_RATE = { standard: 0, gold: 0.05, platinum: 0.09 } as const;
const TAX_RATE: Record<string, number> = { 'SA-01': 0.15, 'AE-DXB': 0.05, 'EG-CAI': 0.14 };

export class PricingPolicy {
// دالة نقية: لا قاعدة بيانات، لا ساعة نظام، لا HTTP
quote(input: PricingInput): OrderQuote {
const subtotal = round2(input.lines.reduce((sum, l) => sum + l.unitPrice * l.quantity, 0));
const promoRate = input.promos
.filter((promo) => promo.appliesTo(input.lines, input.at))
.reduce((sum, promo) => sum + promo.rate, 0);
const discountRate = Math.min(SEGMENT_RATE[input.customer.segment] + promoRate, 0.25);
const discount = round2(subtotal * discountRate);
const taxable = round2(subtotal - discount);
const tax = round2(taxable * TAX_RATE[input.customer.taxJurisdiction]);
return { subtotal, discount, tax, total: round2(taxable + tax), discountRate };
}
}

النتيجة المتوقعة: تستطيع أن تختبر سقف الخصم وضريبة كل منطقة بلا قاعدة بيانات وبلا خادم، وتصبح الأخطاء المتعلقة بالتقريب مركزية لا موزعة.


الخطوة 4: أعطِ Repository واجهة بلغة المجال


الواجهة الجيدة تخبر القارئ بما يحتاجه المجال، لا كيف يبدو الجدول. الفرق بين findOpenForCustomer وfind({ where: { status: { in: [...] } } }) هو الفرق بين طبقة تخفي التخزين وطبقة تنقله إلى الخارج.


export interface OrderRepository {
findById(tenantId: string, id: string): Promise<Order | null>;
findOpenForCustomer(tenantId: string, customerId: string): Promise<Order[]>;
save(tenantId: string, order: Order): Promise<void>;
}

export class PrismaOrderRepository implements OrderRepository {
constructor(private readonly db: PrismaClient) {}

async findOpenForCustomer(tenantId: string, customerId: string): Promise<Order[]> {
const rows = await this.db.order.findMany({
where: { tenantId, customerId, status: { in: ['DRAFT', 'CONFIRMED'] } },
});
return rows.map(OrderMapper.toDomain);
}

async save(tenantId: string, order: Order): Promise<void> {
await this.db.order.upsert({
where: { tenantId_id: { tenantId, id: order.id } },
create: OrderMapper.toRow(order),
update: OrderMapper.toRow(order),
});
}
}

النتيجة المتوقعة: يمكنك لاحقًا استبدال Prisma بـ ORM آخر أو بإعادة التخزين عبر HTTP دون تعديل سطر واحد في المجال أو في حالة الاستخدام.


الخطوة 5: اختبر كل طبقة بما يخصها


أخطر خطأ اختباري هو اختبار القاعدة من خلال قاعدة البيانات. اختبار واحد بطيء يغطي ثلاث طبقات لا يخبرك أيها انكسر.


describe('PricingPolicy', () => {
it('caps the combined discount at 25 percent', () => {
const quote = new PricingPolicy().quote({
lines: [{ productId: 'p1', quantity: 2, unitPrice: 100 }],
customer: { segment: 'platinum', taxJurisdiction: 'SA-01' },
promos: [{ rate: 0.3, appliesTo: () => true }],
at: new Date('2026-03-01T00:00:00Z'),
});

expect(quote.subtotal).toBe(200);
expect(quote.discount).toBe(50); // لا 60، لأن السقف 25 بالمئة
expect(quote.tax).toBe(22.5); // 15 بالمئة على 150
expect(quote.total).toBe(172.5);
});
});

النتيجة المتوقعة: هذا الاختبار يعمل في مللي ثوانٍ بلا قاعدة بيانات ولا شبكة، ويبقى صالحًا حتى لو غيّرت طريقة التخزين بالكامل.


مثال عملي كامل: تسعير طلب متعدد المصادر


سيناريو تطبيقي

عميل بشريحة platinum في الرياض يضيف منتجين بسعر 100 للوحدة، وتنطبق عليه حملة بخصم 30 بالمئة. تجمع خدمة المجال خصم الشريحة 9 بالمئة مع خصم الحملة فيصل الرقم إلى 39 بالمئة، لكن السقف الموحد 25 بالمئة يخفضه. المجموع الفرعي 200، والخصم 50، والضريبة 22.5 بنسبة 15 بالمئة، والإجمالي 172.5. لو كانت هذه القاعدة موزعة بين Controller وRepository، لأصبح اختلاف التقريب بين مسار الويب ومسار الجوال فرقًا حقيقيًا في الفاتورة، وهو خطأ يُكتشف بعد شكوى عميل لا قبله.


مقارنة: Anemic Model مقابل Rich Model


أين تسكن القواعد؟

Anemic Model

  • القاعدة التي تعتمد على حالة الكيان تسكن الكيان
  • القاعدة العابرة بين الكيانات تسكن خدمة مجال نقية
  • نقطة الدخول الجديدة تستدعي القاعدة نفسها
  • اختبار القاعدة بلا قاعدة بيانات ولا شبكة

Rich Model مع Domain Service


الخلاصة العملية: لا تجعل كل شيء Rich Model دفعة واحدة، لكن ابدأ من القاعدة الأكثر تكرارًا في نظامك. القاعدة التي تُنسخ في مكانين هي أول مرشح للانتقال إلى الكيان أو إلى خدمة المجال.


أخطاء شائعة


أخطاء توزع المسؤوليات خطأً

  • وضع كل استعلام خلف Repository حتى القراءات البسيطة التي لا تحمل قواعد، فتتضخم الطبقة بلا فائدة.
  • استدعاء Repository من داخل كيان المجال، فيصبح الكيان مرتبطًا بالتخزين ويستحيل اختباره وحده.
  • تسمية كل صف بـService دون تحديد مسؤولية، فتصبح المجلدات أسماء لا حدودًا.
  • نقل قاعدة بسيطة تعتمد على حالة الكيان إلى Domain Service وصناعة Anemic Model بالعكس.
  • إعادة تنسيق المعاملة في أكثر من طبقة، فتُفتح معاملة داخل معاملة بلا قصد.
  • تمرير كائن الطلب الكامل إلى PricingPolicy بدل المدخلات التي تحتاجها، فيرتبط القرار بشكل الكيان.

قائمة تحقق سريعة



  • هل يمكنك تسمية مسؤولية كل ملف في سطر واحد بلا كلمة «و»؟

  • هل تقرأ حالة الاستخدام كقائمة خطوات من أعلى إلى أسفل؟

  • هل القاعدة التي تعتمد على حالة الكيان مكتوبة داخل الكيان؟

  • هل خدمة المجال نقية بلا قاعدة بيانات وبلا ساعة نظام؟

  • هل واجهة Repository مكتوبة بلغة المجال لا بلغة الجداول؟

  • هل توجد طبقة لا تفعل شيئًا سوى تمرير الاستدعاءات؟ احذفها.

  • هل اختبار القواعد يعمل بلا قاعدة بيانات؟


الأسئلة الشائعة


هل Repository نمط مطلوب دائمًا؟


لا. Repository مفيد عندما يحمي المجال من تفاصيل التخزين أو عندما تحتاج واجهة بلغة العمل. أما قراءة تقرير بسيطة بلا قواعد، فاستعلام مباشر في طبقة القراءة أوضح وأسرع من تغليفه.


ما الفرق الحقيقي بين Application Service وDomain Service؟


Application Service تنسّق ولا تقرر: تحمّل، تستدعي، تحفظ. وDomain Service تقرر ولا تنسّق: تستقبل مدخلات مجال وتعيد حكمًا بلا أي I/O. إذا وجدت استدعاء Repository داخل Domain Service، فأنت أمام Application Service متنكرة.


متى أحذف طبقة Service كاملة؟


عندما لا تفعل سوى return this.repo.find(...). الطبقة التي لا تحمي حدًا ولا تضيف تنسيقًا تزيد عدد الملفات وتُصعّب التتبع دون مقابل، والحذف هنا قرار هندسي لا تفريط.


الخلاصة


ما يجب أن يبقى معك

  • Service Layer تنسّق، والكيان يقرر ما يعتمد على حالته، وDomain Service يقرر ما يعبر الكيانات.
  • Repository يعرض واجهة بلغة المجال لا بلغة الجداول.
  • القاعدة النقية تُختبر بلا قاعدة بيانات وبلا شبكة.
  • الطبقة التي تمرّر الاستدعاءات فقط تُحذف بدل أن تُصان.
  • تكرار القاعدة في مكانين هو الإشارة الأولى إلى مكانها الصحيح.

الخطوة التالية


بعد أن عرفت أين تسكن كل قاعدة، يبقى سؤال ما يحدث عندما ترفض القاعدة الطلب: أي خطأ يعود للعميل، وهل يستطيع أن يعرف إن كان بإمكانه إعادة المحاولة؟ نناقش ذلك في المقال التالي عن تصميم Error Handling موحد في الأنظمة الكبيرة.


كيف تستخدم هذا المقال في عمل حقيقي؟


لا تنقل الحل حرفيًا. ابدأ بتحديد السياق والقيود في نظامك، ثم اختر الجزء الذي ينطبق عليك، طبّقه على نطاق صغير، وقِس النتيجة قبل التوسع. إذا كان القرار يؤثر على الأمان أو البيانات أو التكاملات، دوّن الافتراضات وحدود المسؤولية بوضوح حتى يستطيع شخص آخر مراجعتها.




مخطط يربط عناصر الموضوع

مخطط توضيحي: Service Layer أم Repository أم Domain Services؟
flowchart LR
A["طلب المستخدم"]
B["التحقق من المدخلات"]
C["خدمة الأعمال"]
D["المعاملات والتخزين"]
E["الاستجابة والمراقبة"]
A --> B
B --> C
C --> D
D --> E

التسلسل: طلب المستخدم ← التحقق من المدخلات ← خدمة الأعمال ← المعاملات والتخزين ← الاستجابة والمراقبة.

سيناريو تطبيقي ومراجعة

افترض أنك مسؤول عن تطبيق ما ورد في هذا المقال داخل فريق عمل. ابدأ بتحديد الهدف الذي يقيس النجاح في موضوع Service Layer أم Repository أم Domain Services؟، ثم اكتب المدخلات المطلوبة والقيود والصلاحيات، وجرب حالة طبيعية وأخرى استثنائية قبل الاعتماد.

  1. حدد الافتراضات التي تحتاج إلى تأكيد في بيئتك.
  2. حوّل القرارات الأساسية الواردة أعلاه إلى خطوات أو اختبارات قابلة للتنفيذ.
  3. وثّق طريقة التراجع أو التصحيح إذا فشل أحد الاختبارات.

أسئلة للمراجعة

كيف أبدأ بتطبيق هذا الموضوع؟

اختر حالة استخدام واحدة، وطبّق أصغر جزء يحقق قيمة قابلة للقياس؛ ثم قارن النتيجة بالمتطلبات.

كيف أتحقق من جودة الحل؟

اختبر المسار المعتاد ومسار الخطأ، وراجع الأمان وسلامة البيانات والأداء وفق طبيعة الموضوع، ثم أعد الاختبار بعد أي تعديل.

التعليقات

الرد على التعليق
الموافقة على التعليقات

سيكون تعليقك مرئيًا بعد موافقة المسؤول.

Service Layer أم Repository أم Domain Services؟
أنت تدرس
Service Layer أم Repository أم Domain Services؟
وقت الدراسة 11 دقائق