Featured image of post المعمارية الطبقية ببساطة، متى تحتاجها ولماذا؟

المعمارية الطبقية ببساطة، متى تحتاجها ولماذا؟

طريقة ميسّرة للمبتدئين لاستيعاب كيف تعالج المعمارية الطبقية بعض التحديات الحقيقية في البرمجة

يقرأ الكثير من المبرمجين عن “الأنماط المعمارية” (Architectural Patterns) (مثل المعمارية الطبقية، أو السداسية، إلخ) و"أنماط التصميم" (Design Patterns) (مثل المصنع Factory، أو المراقب Observer، إلخ)، لكنها غالبًا ما تبدو مفاهيمًا مجردة ومنفصلة عن المشاريع البسيطة. فيتساءلون: ما الداعي لكل هذا التعقيد؟

في الواقع، لن تدرك لماذا يُعد نمطٌ ما مفيدًا حتى تتعامل مع المشكلة التي جاء ليحلها.

هذا هو النهج الذي سنتبعه اليوم. سنبدأ بملف واحد “مسطّح” (flat) يحتوي على واجهة برمجة تطبيقات (API) بسيطة. ثم سنواجه تحديات من الواقع العملي تفرض علينا تغيير هذا التصميم البسيط، وتضطرنا إلى إعادة هيكلته (refactoring) خطوة بخطوة. وفي النهاية، سنكون قد “اكتشفنا” بأنفسنا وبتطور تدريجي مدى فائدة المعمارية الطبقية (Layered Architecture).

ملاحظة: هذا المقال مُبسط جدًا. لقد تجاهلنا عن قصد أمورًا مثل معالجة الأخطاء، وتسجيل الأحداث (logging)، والتحقق من البيانات (validation)، والإدارة المثلى لقواعد البيانات. تركيزنا الوحيد ينصب على المعمارية نفسها وسبب أهميتها.

جميع الأمثلة البرمجية مكتوبة بلغة Python، لكن المفهوم بحد ذاته ينطبق على أي لغة. ستجدون أيضًا نسخة بلغة Go في الرابط الموجود أسفل المقال.


أولًا: تطبيق “الملف الواحد”

لنبدأ بواجهة API بسيطة لإدارة جهات الاتصال، موجودة بالكامل في ملف واحد main.py. سنستخدم إطار العمل FastAPI وقاعدة بيانات SQLite.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
# main.py
import sqlite3
from contextlib import asynccontextmanager
from typing import Generator, List

from fastapi import Depends, FastAPI, HTTPException
from pydantic import BaseModel, EmailStr

DATABASE_URL = "contacts.db"

# ---------- Model ----------
# This defines the shape of our data
class Contact(BaseModel):
    id: int | None = None
    first_name: str
    last_name: str
    email: EmailStr

# ---------- Lifespan + App Initialization ----------
# This runs on app startup
@asynccontextmanager
async def lifespan(app: FastAPI):
    conn = sqlite3.connect(DATABASE_URL)
    conn.row_factory = sqlite3.Row
    conn.execute(
        """
        CREATE TABLE IF NOT EXISTS contacts (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            first_name TEXT NOT NULL,
            last_name TEXT NOT NULL,
            email TEXT UNIQUE NOT NULL
        )
        """
    )
    conn.commit()
    conn.close()
    yield
    # No shutdown logic needed for this demo

app = FastAPI(title="Contact Manager", lifespan=lifespan)

# ---------- Dependency for DB Connection ----------
# This gives us a database connection for each request
def get_db() -> Generator[sqlite3.Connection, None, None]:
    conn = sqlite3.connect(DATABASE_URL)
    conn.row_factory = sqlite3.Row
    try:
        yield conn
    finally:
        conn.close()

# ---------- Routes (The "API" part) ----------
@app.get("/contacts", response_model=List[Contact])
def list_contacts(conn: sqlite3.Connection = Depends(get_db)) -> List[Contact]:
    rows = conn.execute("SELECT * FROM contacts").fetchall()
    return [Contact(**dict(row)) for row in rows]

@app.get("/contacts-initials", response_model=List[str])
def list_full_name_initials(conn: sqlite3.Connection = Depends(get_db)) -> List[str]:
    # this is just an example to show that not all operations are simple CRUD operations
    rows = conn.execute("SELECT * FROM contacts").fetchall()
    contacts = [Contact(**dict(row)) for row in rows]
    
    name_initials: List[str] = [
        f"{contact.first_name[0]}. {contact.last_name[0]}."
        for contact in contacts
        if contact.first_name and contact.last_name
    ]
    return name_initials

هذا التصميم يعمل جيدًا كنموذج أولي. لكن، ماذا يحدث عندما يكبر التطبيق؟


ثانيًا: المشكلة الأولى (تغيير قاعدة البيانات)

بعد العمل بهذه البنية المسطحة لفترة (حتى لو قُسِّمَت إلى عدة ملفات)، قررت شركتك أن تدعم قاعدة بيانات MongoDB. هنا تبدأ المعاناة الحقيقية. يظهر أمامنا احتمالان:

  1. استبدال SQLite بالكامل: كل استدعاء لقاعدة البيانات في المعالجات (handlers) أصبح الآن مرتبطًا بشكل وثيق بـ SQLite. للانتقال إلى MongoDB، سيتعين عليك مراجعة كل دالة، وإعادة كتابة كل استعلام ليناسب MongoDB، ثم إعادة اختبار التطبيق بالكامل. إذا كان تطبيقك يحتوي على مئات العمليات، سيصبح هذا كابوسًا: بطيئًا، وعرضة للأخطاء، ومستحيل الصيانة. وإذا قررت تغيير قاعدة البيانات لاحقًا، ستتكرر نفس المعاناة.
  2. دعم القاعدتين معًا والتبديل بينهما: قد يبدو السماح بالتبديل (مثلًا عبر متغير بيئة DB_TYPE) خيارًا مرنًا، لكنه يتحول سريعًا إلى فوضى. تخيل أنك تضع شروط if/else للتحقق من نوع قاعدة البيانات في كل معالج (handler)!

الحل: نمط المستودع (The Repository Pattern)

يمكننا حل هذا بفصل منطق قواعد البيانات بالكامل عن بقية أجزاء التطبيق.

الفكرة بشكل بسيط:

  • ننشئ فئتين (classes) واحدة لـ SQLite والأخرى لـ MongoDB.
  • نجعلهما تتشاركان نفس “الواجهة” (Interface)، أي أنهما تقدمان نفس مجموعة الدوال (methods) (مثل get_all، إلخ).
  • نضيف دالة “مصنعية” (Factory Function) بسيطة (مثل get_repository()) تقرأ الإعدادات (مثلًا من متغير بيئة) وترجع نسخة من المستودع المناسب.

بعد هذا التعديل، لن تعنى دوال المسارات (routes) بنوع قاعدة البيانات المستخدمة. فقط ستتحدث مع “الواجهة”، والتطبيق يقرر من خلف الكواليس أي قاعدة بيانات سيستخدم.

أولاً، نعرّف “واجهة” (interface) (باستخدام abc في Python) تصف ماذا نريد أن نفعل، وليس كيف.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# repository/interface.py
from abc import ABC, abstractmethod
from typing import List
from models import Contact # (Assuming models.py exists)

class IContactRepository(ABC):
    """
    This is the interface (or "port") for our
    contact data storage.
    """
    @abstractmethod
    def get_all(self) -> List[Contact]:
        pass

الآن، نقوم بإنشاء تطبيقات ملموسة (concrete implementations) لهذه الواجهة.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# repository/sqlite.py
import sqlite3
from typing import List
from models import Contact
from repository.interface import IContactRepository

DATABASE_URL = "contacts.db" # Assume this is configured

class SqliteContactRepository(IContactRepository):
    def get_all(self) -> List[Contact]:
        # All the SQLite-specific code is now hidden in here
        with sqlite3.connect(DATABASE_URL) as conn:
            conn.row_factory = sqlite3.Row
            rows = conn.execute("SELECT * FROM contacts").fetchall()
            return [Contact(**dict(row)) for row in rows]

والأمر نفسه لـ MongoDB:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# repository/mongodb.py
import os
from typing import List
from models import Contact
from repository.interface import IContactRepository
from pymongo import MongoClient

class MongoContactRepository(IContactRepository):
    def get_all(self) -> List[Contact]:
        # All the PyMongo-specific code would go here
        client = MongoClient(os.getenv("MONGO_URI"))
        db = client[os.getenv("DB_NAME")]
        return [Contact(**data) for data in db.contacts.find({}, {"_id": 0})]

أخيرًا، ننشئ “مصنعًا” (factory) يمكن لـ FastAPI استخدامه. هذا المصنع يقرأ متغير البيئة ويمنحنا التطبيق الصحيح.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
# repository/factory.py
import os
from typing import Dict, Type
from repository.interface import IContactRepository
from repository.sqlite import SqliteContactRepository
from repository.mongodb import MongoContactRepository

repositories_map: Dict[str, Type[IContactRepository]] = {
    "sqlite": SqliteContactRepository,
    "mongodb": MongoContactRepository,
}

def get_repository() -> IContactRepository:
    """
    Dependency factory to get the correct
    repository based on environment.
    """
    db_type = os.environ.get("DB_TYPE", "sqlite")
    _repo_cls = repositories_map[db_type]
    _repo_instance = _repo_cls()
    return _repo_instance

الآن، انظر كيف أصبحت دوال المسارات في ملف main.py نظيفة ومبسطة:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# main.py
from fastapi import Depends, FastAPI, HTTPException
from models import Contact
from repository.factory import get_repository
from repository.interface import IContactRepository

# ... (app setup, lifespan, etc.)

@app.get("/contacts", response_model=List[Contact])
def list_contacts(
    repo: IContactRepository = Depends(get_repository)
) -> List[Contact]:
    return repo.get_all()

@app.get("/contacts-initials", response_model=List[str])
def list_full_name_initials(
    repo: IContactRepository = Depends(get_repository)
) -> List[str]:
    contacts = repo.get_all()
    name_initials: List[str] = [
        f"{contact.first_name[0]}. {contact.last_name[0]}."
        for contact in contacts
        if contact.first_name and contact.last_name
    ]
    return name_initials

لم تعد دوال المسارات تحتوي على أي منطق متعلق بقاعدة البيانات. بل فقط تعتمد على الواجهة IContactRepository. يمكننا الآن تبديل قاعدة بياناتنا بمجرد تغيير متغير بيئة، دون أن نلمس كود دوال المسارات نهائيًا.


ثالثًا: المشكلة الثانية (إضافة “نقاط دخول” جديدة)

لاحقًا، يُطلب منك تشغيل نفس التطبيق على منصات مختلفة: كدالة على AWS Lambda، أو Azure Functions، أو GCP Cloud Functions، مع الإبقاء على FastAPI للتشغيل المحلي (on-prem).

لكن أين يوجد “منطق العمل” (Business Logic) الخاص بنا؟ فلنلقِ نظرة على الدالة list_full_name_initials.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
@app.get("/contacts-initials", response_model=List[str])
def list_full_name_initials(
    repo: IContactRepository = Depends(get_repository)
) -> List[str]:
    
    # --- This is our Business Logic ---
    contacts = repo.get_all()
    name_initials: List[str] = [
        f"{contact.first_name[0]}. {contact.last_name[0]}."
        for contact in contacts
        if contact.first_name and contact.last_name
    ]
    # --- End Business Logic ---
    
    return name_initials

إذا أردنا بناء معالج (handler) لخدمة Lambda، سنضطر إلى نسخ ولصق هذا المنطق:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# lambda_handler.py
from typing import List, Dict, Any
from repository.factory import get_repository

def handler(event: Dict[str, Any], context: Any) -> Dict[str, Any]:
    repo = get_repository()
    
    # --- COPY-PASTED Business Logic ---
    contacts = repo.get_all()
    name_initials: List[str] = [
        f"{contact.first_name[0]}. {contact.last_name[0]}."
        for contact in contacts
        if contact.first_name and contact.last_name
    ]
    # --- End Business Logic ---
    
    return {"statusCode": 200, "body": name_initials}

إذا واصلت وضع منطق العمل داخل كل معالج (handler)، فسينتهي بك الأمر بتكرار نفس الكود في كل مكان.

هذا سيء جدًا. فمثلًا إذا احتجنا لتغيير طريقة حساب الأحرف الأولى من الاسم، سيتعين علينا تغييرها في مكانين (أو أكثر). بالتالي ستكون الصيانة والاختبار وتصحيح الأخطاء عملية مؤلمة وغير عملية.

الحل: طبقة الخدمة (The Service Layer)

الحل الطبيعي هو عزل “منطق العمل” ووضعه في فئة (class) مستقلة، سنطلق عليها “طبقة الخدمة” (Service Layer) (أو “طبقة العمل” Business Layer). وظيفة هذه الطبقة هي إدارة وتنفيذ المهام.

الأهم من ذلك، أن طبقة الخدمة الجديدة هذه ستعتمد على واجهة المستودع (Repository interface)، وليس على FastAPI أو أي اتصال مباشر بقاعدة البيانات.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
# services/contact_service.py
from typing import List
from models import Contact
from repository.interface import IContactRepository

class ContactService:
    def __init__(self, repo: IContactRepository):
        # It depends on the interface, not a concrete type
        self.repo = repo

    def get_all_contacts(self) -> List[Contact]:
        return self.repo.get_all()

    def get_contact_initials(self) -> List[str]:
        # All our business logic is now in *one* place.
        contacts = self.repo.get_all()
        name_initials: List[str] = [
            f"{contact.first_name[0]}. {contact.last_name[0]}."
            for contact in contacts
            if contact.first_name and contact.last_name
        ]
        return name_initials

انظر الآن إلى ملف main.py. لقد أصبح صغيرًا وبسيطًا للغاية.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# main.py
from fastapi import Depends, FastAPI, HTTPException
from models import Contact
from services.contact_service import ContactService
from repository.factory import get_repository # Added
from repository.interface import IContactRepository # Added

app = FastAPI() # ... etc.

@app.get("/contacts", response_model=List[Contact])
def list_contacts(
    repo: IContactRepository = Depends(get_repository)
) -> List[Contact]:
    # for simplicity we didn't create a factory for the service
    # the service layer is where your business rules live
    service = ContactService(repo=repo)
    return service.get_all_contacts()

@app.get("/contacts-initials", response_model=List[str])
def list_full_name_initials(
    repo: IContactRepository = Depends(get_repository)
) -> List[str]:
    # for simplicity we didn't create a factory for the service
    service = ContactService(repo=repo)
    # All logic is hidden inside the service
    return service.get_contact_initials()

وماذا عن معالج Lambda؟ أصبح بسيطًا بنفس القدر، ويعيد استخدام نفس الكود تمامًا!

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# lambda_handler.py
from repository.factory import get_repository
from services.contact_service import ContactService
from typing import Dict, Any # Added

def handler(event: Dict[str, Any], context: Any) -> Dict[str, Any]:
    # Manually create the service (or use a DI container)
    repo = get_repository()
    service = ContactService(repo=repo)
    
    # Call the *same* business logic method
    initials = service.get_contact_initials()
    
    return {"statusCode": 200, "body": initials}

إذا غيّرنا منطق حساب الأحرف الأولى في ContactService، سيُطبّق التعديل تلقائيًا على تطبيق FastAPI، وعلى دالة Lambda، وعلى أي مكان آخر يستخدم هذه الخدمة.


رابعًا: الطبقات نشأت بشكل تلقائي

تهانينا! لقد خطوت للتو نحو تطبيق المعمارية الطبقية (Layered Architecture).

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

  • طبقة العرض (Presentation Layer)

    • تُعرف أيضًا بـ: Controllers, Handlers, Adapters.
    • المسؤولية: التعامل مع مدخلات ومخرجات المستخدم (مثل HTTP، واجهة الأوامر CLI).
  • طبقة العمل (Business Layer)

    • تُعرف أيضًا بـ: Services, Application, Use Cases.
    • المسؤولية: احتواء قواعد العمل الأساسية (core business rules) وسير الإجراءات.
  • طبقة الوصول للبيانات (Data Access Layer)

    • تُعرف أيضًا بـ: Repositories, DAO, Persistence.
    • المسؤولية: التخاطب مع قواعد البيانات، وإخفاء تفاصيل تخزين البيانات.
  • طبقة النماذج (Models Layer)

    • تُعرف أيضًا بـ: Entities, DTOs, Schemas
    • المسؤولية: تحديد هياكل البيانات وقواعد التحقق الخاصة بها.

قد تحتاج أيضًا لإضافة طبقات أخرى عند الضرورة، على سبيل المثال:

  • طبقة البنية التحتية (Infrastructure Layer): للمهام المساعدة التي تخدم كل الطبقات مثل تسجيل الأحداث (logging)، أو التخزين المؤقت (caching)، أو المصادقة (authentication)، أو قراءة الإعدادات.
  • طبقة التكامل (Integration Layer): للتعامل مع الأنظمة الخارجية (مثل إرسال البريد الإلكتروني، أو استدعاء خدمات خارجية).

فهم تدفق الاعتماديات (Dependency Flow)

تتبع المعمارية الطبقية تدفقًا من الأعلى للأسفل في الاعتماديات (Dependencies):

تدفق الاعتماديات

يجب أن تتدفق الاعتماديات دائمًا إلى الأسفل، وليس العكس. على سبيل المثال، يجب ألّا تقوم طبقة المستودع (Repository) باستيراد أي شيء من FastAPI أو إرسال استجابات HTTP.

  • طبقة العرض (مثل main.py أو lambda_handler.py):
    • تعرف عن: طبقة الخدمة (Service Layer).
    • لا تعرف عن: كيفية عمل قاعدة البيانات.
  • طبقة العمل (مثل services/contact_service.py):
    • تعرف عن: واجهة المستودع (Repository Interface) والنماذج (Models).
    • لا تعرف عن: FastAPI، أو HTTP، أو JSON، أو ما إذا كانت قاعدة البيانات SQLite أم Mongo.
  • طبقة الوصول للبيانات (مثل repository/sqlite.py):
    • تعرف عن: تفاصيل قاعدة البيانات (SQL، Mongo، إلخ).
    • لا تعرف عن: منطق العمل (هي لا تعرف لماذا تطلب البيانات)، أو طبقة العرض.

الفوائد الكبيرة

  1. قابلية الاختبار (Testability): هو أحد أكبر المكاسب. فأنت لا تحتاج قاعدة بيانات حقيقية عند اختبار منطق عمل ContactService! كل ما عليك هو تمرير مستودع “مزيف” (Mock Repository) لها.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# tests/test_service.py
class MockContactRepository(IContactRepository):
    def get_all(self) -> List[Contact]:
        return [
            Contact(id=1, first_name="Test", last_name="User", email="test@user.com")
        ]
    # ... other methods


def test_initials_logic() -> None:
    mock_repo = MockContactRepository()
    service = ContactService(repo=mock_repo)

    initials = service.get_contact_initials()

    assert initials == ["T. U."]
  1. قابلية الصيانة (Maintainability): التغييرات في طبقة نادرًا ما تؤثر على الطبقات الأخرى. هل تريد تغيير قاعدة البيانات من SQLite إلى PostgreSQL؟ كل ما عليك فعله هو تعديل ملف واحد (repository/postgres.py) وتحديث المصنع (Factory). طبقة الخدمة وطبقة العرض لا تتغيران.
  2. إعادة الاستخدام (Reusability): كما رأينا، يمكنك استخدام ContactService نفسها في تطبيق FastAPI، أو دالة Lambda، أو حتى في برنامج يعمل من سطر الأوامر (CLI)، كل ذلك دون أي تغيير في منطق العمل.
  3. قابلية التوسع (Scalability): مع نمو النظام، تتيح لك إضافة طبقات جديدة (مثل “البنية التحتية”) الحفاظ على فصل الاهتمامات (separation of concerns) وإبقاء النظام سهل الإدارة.
  4. قابلية القراءة (Readability): كل طبقة لها غرض واضح ومحدد، مما يجعل فهم المشروع وتتبعه أسهل.

تحسينات أخرى

ستحتاج غالبًا إلى تغييرات إضافية عند العمل على تطبيق حقيقي، مثل:

  • إنشاء مستودعات وخدمات متخصصة (مثل UserRepository, OrderService).
  • حقن (Inject) المستودعات المحددة التي تحتاجها كل خدمة (لتقييد الصلاحيات).
  • إضافة المزيد من “التجريد” (Abstraction) عند الضرورة. في مثالنا، كانت الواجهة IContactRepository ضرورية لأنها حلت مشكلة “تبديل قاعدة البيانات”. لكننا لم ننشئ واجهة IContactService لطبقة الخدمة ContactService لأننا نملك تطبيقًا واحدًا فقط لها. (بعض المدارس المعمارية تفضل تجريد كل شيء دائمًا، لكننا لن ندخل في هذا النقاش هنا. هذا المقال يعرض حالة استخدمت التجريد وأخرى لم تستخدمه).
  • تجريد عملية الاتصال بقاعدة البيانات، بحيث لا تضطر المستودعات لفتح الاتصال وإغلاقه في كل دالة.
  • إضافة طبقة (Domain) منفصلة للنماذج (models.py) لتستخدمها جميع الطبقات.

خاتمة

المعمارية (Architecture) هي مجموعة من الأنماط التي تطورت كحلول عملية لمشاكل شائعة ومؤلمة. في المرة القادمة التي تبدأ فيها مشروعًا، قد لا تحتاج لكل هذه الطبقات من البداية. لكن بمعرفتك للمعاناة التي تحلها هذه الأنماط، ستعرف تمامًا متى ولماذا يجب عليك تطبيقها.

يمكنك استكشاف المثال الكامل هنا (ملحوظة: هناك اختلاف بسيط بين نسختي Python و Go بغاية زيادة التنوع في المثال):

مبني باستخدام Hugo
قالب Stack مصمم من Jimmy