معماری هگزاگونال (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 فقط پیچیدگی اضافه میکنه بدون فایدهی متناسب. مثل خیلی از الگوهای معماری دیگه، سؤال درست این نیست که «آیا این الگو خوبه؟»، بلکه اینه که «آیا پیچیدگی پروژهم این سرمایهگذاری رو توجیه میکنه؟»
