تطبيق نمط Repository في Python لفصل منطق الأعمال عن طبقة الوصول إلى البيانات
مقدمة
يُعد تطبيق نمط Repository في Python لفصل منطق الأعمال عن طبقة الوصول إلى البيانات من الأساليب المهمة لبناء تطبيقات قابلة للصيانة والاختبار والتوسع. ففي كثير من المشاريع الصغيرة يبدأ المطور بكتابة استعلامات قاعدة البيانات مباشرة داخل الدوال التي تنفذ قواعد العمل، مثل إنشاء طلب جديد أو احتساب رصيد العميل أو التحقق من توفر منتج. ومع نمو المشروع، يصبح هذا التشابك سبباً في صعوبة التعديل والاختبار، لأن تغيير قاعدة البيانات أو مكتبة الوصول إليها قد يفرض تعديلات واسعة على منطق التطبيق.
يعالج نمط Repository هذه المشكلة عبر إنشاء طبقة وسيطة تقدّم واجهة موحّدة للتعامل مع الكيانات المخزنة. وبدلاً من أن يعرف منطق الأعمال تفاصيل SQL أو ORM أو ملفات JSON أو واجهات البرمجة الخارجية، فإنه يتعامل مع كائن Repository يوفّر عمليات مثل الإضافة والبحث والحذف والحفظ. وبهذه الطريقة يصبح مصدر البيانات تفصيلاً قابلاً للاستبدال، لا جزءاً أساسياً من قواعد العمل.
ما هو نمط Repository وما المشكلة التي يحلها؟
المستودع، أو Repository، هو كائن يمثل مجموعة من الكيانات الموجودة في مصدر تخزين ما، ويقدّم لها واجهة أقرب إلى التعامل مع مجموعة كائنات في الذاكرة. فعلى سبيل المثال، يمكن أن يوفّر مستودع العملاء عمليات مثل get_by_id وadd وlist_active، من دون أن يكشف إن كانت البيانات محفوظة في PostgreSQL أو SQLite أو MongoDB أو حتى قائمة مؤقتة داخل الذاكرة.
لا يعني ذلك أن المستودع يلغي طبقة قاعدة البيانات، بل يعني أنه يحاصر تفاصيلها في مكان محدد. فإذا كان التطبيق يستدعي جلسة SQLAlchemy أو يكتب استعلامات SQL في كل خدمة من خدماته، فإن قواعد العمل تصبح مرتبطة مباشرة بتقنية التخزين. أما عند استخدام Repository، فتتجه الاعتمادية بالعكس: تعتمد طبقة البنية التحتية على الواجهة التي تحتاجها طبقة التطبيق، بينما لا تعتمد طبقة التطبيق على مكتبة قاعدة بيانات بعينها.
تظهر فائدة هذا الفصل بوضوح عند تنفيذ اختبارات الوحدة. فاختبار خدمة إنشاء طلب لا ينبغي أن يتطلب تشغيل خادم PostgreSQL حقيقي في كل مرة. يمكن تمرير مستودع وهمي داخل الذاكرة، ثم اختبار القاعدة التجارية نفسها بسرعة وباستقلال عن الشبكة والجداول والمعاملات. وهذه المرونة من أهم أسباب انتشار النمط في التطبيقات المؤسسية والخدمات الخلفية.
تحديد حدود الطبقات والمسؤوليات
لكي ينجح التصميم، ينبغي تحديد مسؤولية كل طبقة بدقة. تمثل طبقة المجال الكيانات وقواعدها الجوهرية، مثل العميل والطلب والمنتج. أما طبقة التطبيق أو الخدمات فتنسّق حالات الاستخدام، مثل تسجيل عميل أو تنفيذ عملية شراء. وتحتوي طبقة البنية التحتية على التنفيذ الفعلي للوصول إلى البيانات، كاستعلامات SQLAlchemy أو الاتصال بواجهة خارجية.
المستودع يقع عند الحد الفاصل بين طبقة التطبيق والبنية التحتية. لذلك يجب أن تكون واجهته موجهة إلى احتياجات المجال، لا إلى تفاصيل قاعدة البيانات. فمن الأفضل أن تسمّي دالة مثل find_available_by_sku إذا كانت تعبّر عن حاجة أعمال واضحة، بدلاً من كشف دالة عامة تنفذ أي استعلام خام. كما لا ينبغي أن يتحول المستودع إلى مكان لوضع كل قواعد العمل؛ وظيفته الأساسية هي الاستعلام والتخزين واسترجاع الكيانات.
في المثال التالي نعرّف كياناً بسيطاً لمنتج، ثم نحدد واجهة مجردة لمستودع المنتجات. استخدام الفئة المجردة يوضّح العقد المطلوب من أي تنفيذ لاحق، سواء استُخدمت قاعدة بيانات حقيقية أو تخزين مؤقت.
from dataclasses import dataclass
from abc import ABC, abstractmethod
from typing import Optional
@dataclass
class Product:
id: int
sku: str
name: str
quantity: int
def reserve(self, amount: int) -> None:
if amount <= 0:
raise ValueError("يجب أن تكون الكمية موجبة")
if self.quantity < amount:
raise ValueError("الكمية المتاحة غير كافية")
self.quantity -= amount
class ProductRepository(ABC):
@abstractmethod
def get_by_sku(self, sku: str) -> Optional[Product]:
pass
@abstractmethod
def add(self, product: Product) -> None:
pass
@abstractmethod
def save(self, product: Product) -> None:
pass
يحتوي الكيان هنا على قاعدة أعمال أساسية هي عدم السماح بحجز كمية أكبر من المخزون المتاح. وهذه القاعدة لا تعرف شيئاً عن الجداول أو الاستعلامات. وفي المقابل، لا تتضمن واجهة المستودع كيفية تنفيذ البحث أو الحفظ، بل تصف النتيجة المطلوبة فقط.
بناء تنفيذ داخل الذاكرة للاختبار والتطوير
أبسط تنفيذ للمستودع هو التخزين داخل قاموس في الذاكرة. ورغم أنه ليس مناسباً لحفظ البيانات الدائم في بيئة الإنتاج، فإنه مفيد جداً لاختبارات الوحدة والتجارب الأولية. كما أنه يبرهن على أن خدمة الأعمال لا تحتاج إلى تغيير عند استبدال مصدر التخزين.
class InMemoryProductRepository(ProductRepository):
def __init__(self) -> None:
self._products: dict[str, Product] = {}
def get_by_sku(self, sku: str) -> Optional[Product]:
return self._products.get(sku)
def add(self, product: Product) -> None:
if product.sku in self._products:
raise ValueError("المنتج موجود مسبقاً")
self._products[product.sku] = product
def save(self, product: Product) -> None:
self._products[product.sku] = product
من المهم الانتباه إلى أن هذا المثال يعيد الكائن نفسه المخزن في القاموس. في بعض الأنظمة قد يكون هذا السلوك كافياً، لكنه قد يسبب تعديلات غير مقصودة إذا احتاج التطبيق إلى عزل الكائنات أو دعم التراجع عن التغييرات. عندئذ يمكن إعادة نسخ من الكيانات أو استخدام وحدة عمل لإدارة دورة حياة الكائنات والمعاملات.
كما يجب أن تعكس أسماء الدوال لغة المجال. فإذا كان المنتج يُعرّف برمز فريد، فإن get_by_sku أوضح من دالة عامة باسم find. أما الاستعلامات المعقدة، مثل البحث عن المنتجات منخفضة المخزون، فيمكن إضافتها إلى الواجهة متى كانت تمثل حاجة متكررة وحقيقية في التطبيق.
استخدام المستودع داخل خدمة منطق الأعمال
بعد تعريف العقد والتنفيذ، تأتي الخطوة الأهم: حقن المستودع في خدمة التطبيق بدلاً من إنشائه داخل الخدمة. هذا يسمى حقن الاعتماديات، وهو ما يجعل الخدمة تعتمد على التجريد لا على التنفيذ المحدد. لا ينبغي مثلاً أن تنشئ خدمة الحجز محرك SQLAlchemy أو جلسة قاعدة بيانات بنفسها، لأن ذلك يعيد الربط القوي الذي نريد التخلص منه.
class InventoryService:
def __init__(self, products: ProductRepository) -> None:
self._products = products
def reserve_product(self, sku: str, amount: int) -> Product:
product = self._products.get_by_sku(sku)
if product is None:
raise LookupError("المنتج غير موجود")
product.reserve(amount)
self._products.save(product)
return product
تنسّق خدمة InventoryService حالة الاستخدام: تبحث عن المنتج، وتتحقق من وجوده، ثم تطلب من الكيان تنفيذ قاعدة الحجز، وأخيراً تحفظ النتيجة. لا توجد داخلها أي معرفة بنوع قاعدة البيانات أو بنية جدول المنتجات. لذلك يمكن تشغيلها مع المستودع داخل الذاكرة كما يلي:
repository = InMemoryProductRepository()
repository.add(Product(id=1, sku="BOOK-101", name="كتاب Python", quantity=8))
service = InventoryService(repository)
updated_product = service.reserve_product("BOOK-101", 3)
print(updated_product.quantity) # 5
هذا التنظيم يحقق فصلاً واضحاً: الكيان يحمي قواعد المجال، والخدمة تنفذ حالة الاستخدام، والمستودع يتولى التخزين والاسترجاع. وعند ظهور متطلبات جديدة، مثل إرسال إشعار بعد الحجز، يمكن إضافة خدمة إشعارات إلى طبقة التطبيق من دون تلويث المستودع بمنطق لا يخص البيانات.
تنفيذ مستودع حقيقي باستخدام SQLAlchemy
في بيئة الإنتاج يمكن تنفيذ الواجهة نفسها باستخدام SQLAlchemy. الهدف ليس نسخ جميع تفاصيل ORM إلى الطبقات العليا، بل وضعها داخل تنفيذ بنية تحتية محدد. في المثال التالي نفترض وجود نموذج SQLAlchemy باسم ProductModel مرتبط بجدول المنتجات، وأن جلسة قاعدة البيانات تُمرر إلى المستودع من الخارج.
from sqlalchemy import select
from sqlalchemy.orm import Session
class SqlAlchemyProductRepository(ProductRepository):
def __init__(self, session: Session) -> None:
self._session = session
def get_by_sku(self, sku: str) -> Optional[Product]:
statement = select(ProductModel).where(ProductModel.sku == sku)
row = self._session.execute(statement).scalar_one_or_none()
if row is None:
return None
return Product(
id=row.id,
sku=row.sku,
name=row.name,
quantity=row.quantity,
)
def add(self, product: Product) -> None:
model = ProductModel(
id=product.id,
sku=product.sku,
name=product.name,
quantity=product.quantity,
)
self._session.add(model)
def save(self, product: Product) -> None:
model = self._session.get(ProductModel, product.id)
if model is None:
raise LookupError("تعذر حفظ منتج غير موجود")
model.quantity = product.quantity
model.name = product.name
يبين المثال ضرورة اتخاذ قرار واضح بشأن التحويل بين كيان المجال ونموذج ORM. يمكن استخدام الكيان نفسه كنموذج ORM في المشاريع البسيطة، لكن الفصل بينهما غالباً أنسب في المجالات المعقدة، لأنه يمنع تسرب خصائص SQLAlchemy وعلاقاتها وحالات جلساتها إلى منطق الأعمال. هذا التحويل له كلفة إضافية، لكنه يمنح استقلالاً أكبر عند تغير تقنية التخزين أو تصميم المخطط.
المعاملات ونمط وحدة العمل
لا ينبغي أن يكون المستودع مسؤولاً دائماً عن تنفيذ commit بعد كل عملية حفظ، لأن حالة الاستخدام الواحدة قد تعدل عدة كيانات يجب حفظها معاً أو التراجع عنها جميعاً عند الفشل. هنا يظهر دور نمط وحدة العمل، أو Unit of Work، الذي يجمع المستودعات ضمن حدود معاملة واحدة.
مثلاً، عند إنشاء طلب شراء قد نحتاج إلى حجز مخزون المنتج، وإنشاء سجل الطلب، وتسجيل عملية دفع. إذا نجح أول إجراء وفشل الثالث، فإن تثبيت التغييرات بعد كل مستودع قد يترك النظام في حالة غير متسقة. الأفضل أن تدير طبقة التطبيق المعاملة كاملة، ثم تستدعي الحفظ النهائي مرة واحدة.
class SqlAlchemyUnitOfWork:
def __init__(self, session: Session) -> None:
self.session = session
self.products = SqlAlchemyProductRepository(session)
def commit(self) -> None:
self.session.commit()
def rollback(self) -> None:
self.session.rollback()
def reserve_and_commit(uow: SqlAlchemyUnitOfWork, sku: str, amount: int) -> None:
try:
product = uow.products.get_by_sku(sku)
if product is None:
raise LookupError("المنتج غير موجود")
product.reserve(amount)
uow.products.save(product)
uow.commit()
except Exception:
uow.rollback()
raise
يفصل هذا الأسلوب مسؤولية الوصول إلى البيانات عن مسؤولية حدود المعاملة. ويمكن لوحدة العمل أن تحتوي مستودعات متعددة، مما يجعل حالات الاستخدام المركبة أكثر وضوحاً واتساقاً.
اختبار منطق الأعمال وتجنب الأخطاء الشائعة
أحد أكبر مكاسب نمط Repository هو القدرة على اختبار قواعد الأعمال بمستودع داخل الذاكرة. يركز الاختبار التالي على السلوك المطلوب: يجب أن ينقص المخزون عند الحجز، ويجب أن يُرفض الحجز عندما تتجاوز الكمية المتاحة.
def test_reserving_product_reduces_quantity() -> None:
repository = InMemoryProductRepository()
repository.add(Product(1, "PEN-10", "قلم", 10))
service = InventoryService(repository)
product = service.reserve_product("PEN-10", 4)
assert product.quantity == 6
def test_reserving_more_than_available_raises_error() -> None:
repository = InMemoryProductRepository()
repository.add(Product(1, "PEN-10", "قلم", 2))
service = InventoryService(repository)
try:
service.reserve_product("PEN-10", 3)
assert False
except ValueError:
assert True
مع ذلك، لا ينبغي استخدام النمط بصورة آلية في كل مشروع. من الأخطاء الشائعة إنشاء مستودع عام ضخم يحتوي عشرات الدوال غير المترابطة، أو تمرير كائنات ORM إلى طبقة المجال مباشرة ثم الادعاء بوجود فصل حقيقي. ومن الأخطاء أيضاً وضع قواعد مثل احتساب الخصومات أو التحقق من صلاحية الطلب داخل المستودع، إذ ينبغي أن تبقى هذه القواعد في الكيانات أو الخدمات.
كذلك لا يغني المستودع عن اختبارات التكامل. فاختبارات الوحدة تثبت صحة منطق الأعمال بسرعة، لكن ينبغي إضافة اختبارات تتحقق من أن تنفيذ SQLAlchemy والاستعلامات والفهارس والمعاملات تعمل فعلاً مع قاعدة البيانات المستهدفة. الجمع بين النوعين هو النهج الأكثر موثوقية.
خاتمة
يوفر نمط Repository في Python وسيلة عملية لعزل منطق الأعمال عن تفاصيل التخزين والوصول إلى البيانات. من خلال تعريف واجهات تعبّر عن احتياجات المجال، وحقن تنفيذات قابلة للاستبدال، تصبح الخدمات أسهل في الاختبار والتطوير والصيانة. كما يتيح دمجه مع نمط وحدة العمل إدارة المعاملات المعقدة بطريقة أكثر اتساقاً.
لا تكمن قيمة النمط في زيادة عدد الطبقات بحد ذاتها، بل في جعل الاعتماديات واضحة ومحدودة. ابدأ بمستودعات صغيرة موجهة إلى كيانات وحالات استخدام محددة، واستخدم تنفيذاً داخل الذاكرة للاختبارات، ثم أضف تنفيذ قاعدة البيانات عند الحاجة. بهذه الخطوات يتحول مصدر البيانات من قيد مفروض على منطق التطبيق إلى تفصيل مرن يمكن تغييره بأقل أثر ممكن.
تعليقات
إرسال تعليق