Codoloper

معماری هگزاگونال (Ports and Adapters): جدا کردن منطق اصلی برنامه از دنیای بیرون

معماری هگزاگونال (Ports and Adapters): جدا کردن منطق اصلی برنامه از دنیای بیرون | عکس

یه سناریوی آشنا رو تصور کن: تصمیم می‌گیرن از PostgreSQL به یه دیتابیس دیگه سوییچ کنن و متوجه می‌شی این وابستگی تا عمق منطق اصلی برنامه نفوذ کرده. معماری هگزاگونال دقیقاً برای جلوگیری از این مشکل طراحی شده. این وابستگی میتونی هر چیزی باشه، یک کتابخونه، لایه API ها و ...

ایده‌ی اصلی

این معماری که اولین بار توسط Alistair Cockburn معرفی شد، می‌گه منطق اصلی کسب‌وکار (business logic یا domain) باید کاملاً مستقل از هر چیز بیرونی باشه؛ چه دیتابیس، چه فریم‌ورک وب، چه API خارجی، چه message queue. این جداسازی از طریق دو مفهوم اصلی اتفاق می‌افته: port و adapter.

Port یه interface است که مشخص می‌کنه domain چه چیزی از دنیای بیرون نیاز داره یا چه چیزی به دنیای بیرون ارائه می‌ده؛ ولی هیچ جزئیات پیاده‌سازی توش نیست. مثلاً یه port می‌تونه بگه «من به یه راه برای پیدا کردن کاربر با ایمیل نیاز دارم»، بدون این‌که بگه این کار با SQL انجام می‌شه یا با یه فایل JSON.

Adapter پیاده‌سازی واقعی اون portه؛ کدی که واقعاً می‌دونه چطور به PostgreSQL وصل بشه، یا چطور یه HTTP request به یه API خارجی بفرسته.

چرا اسمش «هگزاگونال»ه

اسم این معماری از یه شکل شش‌ضلعی میاد که Cockburn برای نشون دادن domain استفاده کرد؛ نه به این خاطر که دقیقاً شش تا port لازمه، بلکه چون شکل شش‌ضلعی جا برای چندین ضلع (port) مختلف می‌ده، بدون این‌که مثل یه مربع محدود به بالا/پایین/چپ/راست باشه. نکته‌ی اصلی این شکل صرفاً بصریه: نشون می‌ده domain می‌تونه از چند جهت مختلف با دنیای بیرون در ارتباط باشه، و هر جهت از طریق یه port مشخص کنترل می‌شه.

دو نوع port: driving و driven

معمولاً port ها به دو دسته تقسیم می‌شن:

Driving port (یا primary/inbound): چیزی که دنیای بیرون رو قادر می‌کنه با domain حرف بزنه. مثلاً یه interface که می‌گه «چطور می‌شه یه سفارش جدید ثبت کرد». یه controller وب یا یه CLI command، از طریق این port به domain دستور می‌ده.

Driven port (یا secondary/outbound): چیزی که domain برای انجام کارش به دنیای بیرون نیاز داره. مثلاً یه interface برای ذخیره‌ی سفارش تو دیتابیس، یا فرستادن ایمیل تأیید.

# Driven port: domain فقط این interface رو می‌شناسه
from abc import ABC, abstractmethod

class OrderRepository(ABC):
    @abstractmethod
    def save(self, order: 'Order') -> None:
        pass

    @abstractmethod
    def find_by_id(self, order_id: str) -> 'Order | None':
        pass


class NotificationSender(ABC):
    @abstractmethod
    def send_order_confirmation(self, order: 'Order') -> None:
        pass

پیاده‌سازی: هسته‌ی domain

هسته‌ی domain فقط به این port ها وابسته‌ست، نه به هیچ پیاده‌سازی خاصی:

class Order:
    def __init__(self, order_id: str, customer_email: str, total: float):
        self.order_id = order_id
        self.customer_email = customer_email
        self.total = total
        self.status = "pending"


class PlaceOrderService:
    def __init__(self, repository: OrderRepository, notifier: NotificationSender):
        self.repository = repository
        self.notifier = notifier

    def place_order(self, order_id: str, customer_email: str, total: float) -> Order:
        if total <= 0:
            raise ValueError("مبلغ سفارش باید مثبت باشه")

        order = Order(order_id, customer_email, total)
        self.repository.save(order)
        self.notifier.send_order_confirmation(order)
        return order

این کلاس PlaceOrderService هیچ ایده‌ای نداره که آیا OrderRepository واقعاً یه دیتابیس PostgreSQL رو صدا می‌زنه یا فقط یه dictionary تو حافظه‌ست. برای همینه که تست کردنش خیلی ساده‌ست:

class InMemoryOrderRepository(OrderRepository):
    def __init__(self):
        self.orders = {}

    def save(self, order):
        self.orders[order.order_id] = order

    def find_by_id(self, order_id):
        return self.orders.get(order_id)


class FakeNotifier(NotificationSender):
    def __init__(self):
        self.sent = []

    def send_order_confirmation(self, order):
        self.sent.append(order)


def test_place_order_saves_and_notifies():
    repo = InMemoryOrderRepository()
    notifier = FakeNotifier()
    service = PlaceOrderService(repo, notifier)

    order = service.place_order("ord_1", "ali@example.com", 150.0)

    assert repo.find_by_id("ord_1") == order
    assert len(notifier.sent) == 1

آداپترهای واقعی

حالا وقتی زمان استفاده‌ی واقعی می‌رسه، همون port ها رو با پیاده‌سازی واقعی adapt می‌کنیم:

import psycopg2

class PostgresOrderRepository(OrderRepository):
    def __init__(self, connection):
        self.connection = connection

    def save(self, order):
        with self.connection.cursor() as cursor:
            cursor.execute(
                "INSERT INTO orders (id, email, total, status) VALUES (%s, %s, %s, %s)",
                (order.order_id, order.customer_email, order.total, order.status)
            )
        self.connection.commit()

    def find_by_id(self, order_id):
        with self.connection.cursor() as cursor:
            cursor.execute("SELECT * FROM orders WHERE id = %s", (order_id,))
            row = cursor.fetchone()
            return Order(*row) if row else None


class EmailNotifier(NotificationSender):
    def __init__(self, smtp_client):
        self.smtp_client = smtp_client

    def send_order_confirmation(self, order):
        self.smtp_client.send(
            to=order.customer_email,
            subject="تأیید سفارش",
            body=f"سفارش {order.order_id} با موفقیت ثبت شد."
        )

خود PlaceOrderService بدون هیچ تغییری، هم با نسخه‌ی تستی کار می‌کنه و هم با نسخه‌ی واقعی؛ فقط adapter ای که موقع wiring کردن اپلیکیشن بهش تزریق می‌شه فرق می‌کنه.

این معماری با Dependency Inversion Principle چه ربطی داره

اگه مقاله‌ی SOLID رو خونده باشی، احتمالاً این الگو برات آشناست؛ معماری هگزاگونال عملاً یه اعمال سیستماتیک از Dependency Inversion Principle تو سطح کل اپلیکیشنه. به‌جای این‌که domain به یه پیاده‌سازی خاص (مثل PostgreSQL) وابسته باشه، هم domain و هم پیاده‌سازی، به یه abstraction مشترک (port) وابسته‌ن. جهت وابستگی همیشه به سمت داخل (به سمت domain) است؛ هیچ‌وقت برعکس.

کجا این معماری واقعاً ارزش داره

این معماری برای اپلیکیشن‌هایی معنی داره که منطق کسب‌وکار پیچیده و قابل‌تغییری دارن، یا احتمال داره زیرساخت‌هاشون (دیتابیس، پیام‌رسان، API های خارجی) تو طول زمان عوض بشه. سیستم‌های مالی، پلتفرم‌های e-commerce با قوانین قیمت‌گذاری پیچیده، یا هر جایی که تیم می‌خواد تست‌های سریع و ایزوله داشته باشه، از این جداسازی سود می‌بره.

در مقابل، برای یه CRUD ساده که فقط داره داده رو از دیتابیس می‌خونه و نمایش می‌ده، بدون منطق کسب‌وکار پیچیده‌ای، اضافه کردن لایه‌های port و adapter فقط پیچیدگی اضافه می‌کنه بدون فایده‌ی متناسب. مثل خیلی از الگوهای معماری دیگه، سؤال درست این نیست که «آیا این الگو خوبه؟»، بلکه اینه که «آیا پیچیدگی پروژه‌م این سرمایه‌گذاری رو توجیه می‌کنه؟»

اطلاعات نویسنده
عرفان دهقانی
نوشته ها در
تبلیغات
کامنت جدید

برای ثبت کامنت وارد شوید

برای اینکه بتوانید زیر این پست کامنت بگذارید، باید وارد حساب کاربری خود شوید.

برای ادامه، وارد حساب خود شوید

بعد از ورود، دوباره به همین پست برمی‌گردید و می‌توانید کامنتتان را ثبت کنید.

ورود به حساب
کامنت‌ها

نظرات کاربران

دیدگاه‌هایی که برای این نوشته ثبت شده‌اند.

هنوز کامنتی برای این پست ثبت نشده است.