Architecture & Design · معماری و طراحی سنیورSenior ~39 دقیقه مطالعه~33 min read
معماری Hexagonal، Clean و لایهایHexagonal, Clean & Layered Architecture
یاد میگیری که معماریهای لایهای، Hexagonal و Clean در واقع سه بیان از یک قانون واحدند — «وابستگیها رو به داخل، به سمت دامنه» — و قدمبهقدم میبینی چطور این را در یک اپ واقعی Spring Boot سیمکشی کنی، کِی ارزشش را دارد و کِی مهندسیِ بیش از حد است.You'll learn that Layered, Hexagonal, and Clean architectures are three expressions of one rule — dependencies point inward toward the domain — and walk step by step through wiring it into a real Spring Boot app, when it pays off, and when it's overkill.
پیشنیاز:Prerequisites: طراحی دامنهمحور (DDD)Domain-Driven Design
بیشتر بحثهایی که دربارهی معماری نرمافزار میشنوی پر از نمودارهای ششضلعی، حلقههای تودرتو و اسمهای قلمبه است. خبر خوب این است: زیر همهی این ظاهر، فقط یک ایده وجود دارد. اگر آن یک ایده را واقعاً بفهمی، بقیه را میتوانی خودت از نو بسازی. بیا همان یک ایده را با حوصله و از صفر بسازیم.
در این فصل اینها را یاد میگیری:
- آن یک قانون مشترک همهی این معماریها (قانون وابستگی) و اینکه چرا اصلاً وجود دارد.
- سه سبک — لایهای، Hexagonal (پورتها و آداپتورها) و Onion/Clean — و تفاوت واقعیشان.
- نقشهی دقیق لایهها در قالب یک جدول مرجع.
- یک اپ کامل Spring Boot، سر تا ته، با کدِ واقعیِ دامنه، پورت، آداپتور و تست.
- چطور این قانون را با ArchUnit و ماژولبندی اجبار کنی تا فرسوده نشود.
- دامهای رایج، و مهمترین قضاوت ارشد: کِی این کار مهندسیِ بیش از حد است.
- پانزده سؤال مصاحبه با پاسخ کامل.
بخش صفر — کلماتی که باید بشناسی
قبل از هر چیز، چند واژه را از ریشه باز کنیم تا در ادامه هیچکدام «سرد» رها نشوند.
- وابستگی (dependency): وقتی کلاس A، کلاس B را
importمیکند یا از آن استفاده میکند، میگوییم A به B وابسته است. اگر B عوض شود، A هم ممکن است بشکند. جهت این پیکان (چهکسی به چهکسی وابسته است) قلبِ کل این فصل است. - دامنه (domain): قلب کسبوکار تو — قوانین واقعیِ کارت. مثلاً «سفارش باید حداقل یک قلم داشته باشد» یا «فقط سفارش NEW را میشود پرداخت کرد». اینها ربطی به پایگاهداده یا HTTP ندارند؛ اگر شرکتات کاغذ و قلم هم کار میکرد باز هم درست بودند.
- زیرساخت (infrastructure): همهی چیزهای فنیِ قابلتعویض — پایگاهداده، فریمورک وب، صف پیام، سرویس پرداخت. اینها ابزارند، نه کسبوکار.
- پورت (port): یک رابط (interface) جاوا که خودِ اپ مالکش است. مثل یک «پریز برق» روی دیوار: شکلش را اپ تعیین میکند، و هرکس بخواهد وصل شود باید با همان شکل بیاید.
- آداپتور (adapter): کدی که یک پورت را به دنیای واقعی وصل میکند — یا آن را پیاده میکند یا آن را فراخوانی میکند. مثل دوشاخهای که به پریز میرود.
- کوپلینگ (coupling): میزان گرهخوردگی دو قطعه کد به هم. کوپلینگ زیاد یعنی نمیتوانی یکی را بدون دستزدن به دیگری عوض کنی.
پیکان وابستگی را مثل «چهکسی به چهکسی زنگ میزند تا کارش راه بیفتد» تصور نکن؛ بلکه مثل «چهکسی اسمِ چهکسی را میداند» تصور کن. در زمان اجرا شاید دامنه باعث شود کد پایگاهداده اجرا شود، ولی در زمان کامپایل دامنه حتی اسم پایگاهداده را هم نمیداند. همین جداییِ «چهکسی اسم چهکسی را میداند» است که همهچیز را ممکن میکند.
مدل ذهنی: همهاش یک قانون است
یک سفارت را تصور کن. داخل سفارت، قانونِ کشور خودت حاکم است — نه قانون کشور میزبان. کارمندان سفارت با دنیای بیرون فقط از پشت یک باجهی مشخص و با قواعد خودشان حرف میزنند. هرچه بیرون است — زبان محلی، پول محلی، ادارات محلی — باید خودش را با قواعد باجه تطبیق دهد تا بتواند با داخل کار کند. سفارت هرگز خودش را با ادارهی پستِ شهر میزبان وفق نمیدهد؛ برعکس است. دامنهی تو همان داخل سفارت است. قوانین کسبوکار قانونِ کشور خودتاند. باجه همان پورت است. دنیای بیرون (DB، وب، Kafka) باید با آداپتور خودش را با باجه وفق دهد.
اگر نمودارها و تبلیغات را کنار بزنیم، معماریهای لایهای (layered)، Hexagonal (پورتها و آداپتورها) و Onion/Clean سه پاسخ به یک پرسش واحدند: وابستگیهای کد منبع (source dependencies) به کدام سمت اشاره میکنند؟
پاسخ برنده که میان Hexagonal و Clean مشترک است، قانون وابستگی (Dependency Rule) است:
وابستگیهای کد رو به داخل و به سمت دامنه (domain) اشاره میکنند و دامنه به هیچ چیز بیرونی وابسته نیست.
یعنی منطق کسبوکار تو نباید Spring، JPA، Jackson، Kafka یا java.sql را import کند. هر چیز دیگر — پورتها، آداپتورها، یوزکیسها (use cases)، مَپرها (mappers) — فقط ماشینآلاتی هستند برای اجرای همین یک قانون. اگر بفهمی چرا دامنه نباید از پایگاهداده خبر داشته باشد، بقیه را میتوانی خودت استنتاج کنی.
چرا این کار میارزد؟ سود ملموس است:
- میتوانی منطق کسبوکار را بدون Spring context و بدون پایگاهداده یونیتتست (unit-test) کنی — یعنی تستهایی که در چند میلیثانیه اجرا میشوند.
- میتوانی Postgres را با MongoDB یا REST را با gRPC عوض کنی، بدون آنکه حتی یک قانون کسبوکار را دست بزنی.
- کدی که به دلایل کسبوکاری تغییر میکند، بهطور فیزیکی از کدی که به دلایل فنی تغییر میکند جدا میشود.
هزینهاش چیست؟ غیرمستقیمسازی (indirection) و تشریفات — یعنی کلاسها و لایههای بیشتر که وسط راه میایستند. قضاوت مهندس ارشد دقیقاً همین است: دانستن اینکه کِی این معامله میارزد.
وابستگیها رو به داخل، به سمت دامنه. دامنه به هیچکس وابسته نیست؛ همه به دامنه وابستهاند. اگر فقط همین یک جمله را از کل فصل نگه داری، میتوانی پورت، آداپتور، یوزکیس و مَپر را خودت بازآفرینی کنی.
مقایسهی سه سبک
لایهای کلاسیک / n-tier
یک ساختمان اداری را تصور کن: طبقهی بالا فروش، وسط حسابداری، پایین انبار. هر طبقه فقط با طبقهی زیرِ خودش کار دارد. فروش به حسابداری تکیه میکند، حسابداری به انبار. حالا یک مشکل: اگر انبار (پایگاهداده) نقشهاش را عوض کند، این تغییر مثل ترک در دیوار از پایین تا بالا بالا میآید و همهی طبقات را میلرزاند.
پیشفرضی که اکثر تیمها سراغش میروند:
┌─────────────────────────┐
│ Controller / Web │ لایه ارائه
├─────────────────────────┤
│ Service │ منطق کسبوکار
├─────────────────────────┤
│ Repository / DAO │ ماندگاری
├─────────────────────────┤
│ Database │
└─────────────────────────┘
وابستگیها رو به پایین اشاره میکنند
هر لایه به لایهی زیرین وابسته است. نقص کشنده اینجاست: لایهی کسبوکار به لایهی ماندگاری (persistence) وابسته است. Service تو Repository را import میکند، و آن Repository به JPA و به schema پایگاهداده گره خورده است. پس دامنهی تو بهطور گذرا (transitively — یعنی با یک واسطه) از پایگاهداده خبر دارد. زنجیره این است: Service → Repository → JPA → schema.
در عمل بدتر هم میشود: کلاسهای @Entity مربوط به JPA به بالا نشت میکنند و «مدل دامنه»ی تو میشوند. آنوقت قوانین کسبوکارت بر اساس نیازهای Hibernate شکل میگیرند — سازندهی بدون آرگومانِ public، setterهای mutable (قابلتغییر)، پروکسیهای lazy-loading، گرافهای @ManyToOne — نه بر اساس خودِ کسبوکار.
وقتی entityهای JPA همان مدل دامنهات میشوند، به یک anemic domain model (مدل دامنهی کمخون) میرسی: کلاسهایی پر از getter/setter و خالی از قانون. «کمخون» یعنی رفتار و منطق واقعی در آن نیست؛ فقط داده است. آنوقت قوانین کسبوکار در سرویسها پخش میشوند و کسی نمیتواند تضمین کند یک سفارشِ نامعتبر ساخته نمیشود. لایهای غلط نیست — فقط دربارهی جهت وابستگی بهقدر کافی سختگیر نیست.
Hexagonal (پورتها و آداپتورها)، آلیستر کاکبرن، ۲۰۰۵
بینش کلیدیِ کاکبرن این بود: بالا و پایینی وجود ندارد، فقط درون (inside) — یعنی اپلیکیشن — و بیرون (outside) — یعنی هر چیز دیگر: UI، DB، بروکرهای پیام، سرویسهای دیگر. اپلیکیشن منحصراً از طریق پورتها (رابطهایی که خودش مالکشان است) با دنیای بیرون حرف میزند، و دنیای بیرون از طریق آداپتورها — که آن پورتها را پیاده یا فراخوانی میکنند — وصل میشود.
سمت راننده (primary) سمت راندهشده (secondary)
کنشگرانی که اپ را استفاده میکنند چیزهایی که اپ استفاده میکند
REST ─┐ ┌─ JPA / Postgres
CLI ─┤→ [پورت ورودی] → APPLICATION → [پورت خروجی] ├─ Kafka
gRPC ─┘ (inbound) (دامنه + (outbound) └─ Payment API
یوزکیسها)
اجازه بده دو نوع پورت را دقیق از هم جدا کنم، چون در مصاحبه دقیقاً همینجا آدمها گیر میکنند:
- پورتهای راننده / اولیه / ورودی (driving / primary / inbound): رابطهایی که اپلیکیشن در معرض میگذارد تا بیرون آن را «براند» — مثل
PlaceOrderUseCase. آداپتور اینجا (مثلاً یک REST controller) پورت را فراخوانی میکند. یعنی بیرون به داخل فرمان میدهد. - پورتهای راندهشده / ثانویه / خروجی (driven / secondary / outbound): رابطهایی که اپلیکیشن نیاز دارد تا خودش به بیرون دسترسی پیدا کند — مثل
OrderRepository،PaymentGateway. آداپتور اینجا (مثلاً یک JPA repository) پورت را پیاده میکند. یعنی داخل، بیرون را به کار میگیرد.
پورت همان پریز روی دیوار است. شکل و ولتاژش را خانه (اپلیکیشن) تعیین میکند. یک آداپتورِ ورودی مثل کلید چراغ است که به پریز فرمان میدهد. یک آداپتورِ خروجی مثل یخچالی است که دوشاخهاش را به پریز میزند تا از خانه برق بگیرد. در هر دو حالت، شکلِ اتصال را خانه دیکته میکند، نه یخچال. اگر یخچالت را عوض کنی، پریز دستنخورده میماند — دقیقاً همان چیزی که برای پایگاهدادهات میخواهی.
حالا وارونگیِ حیاتی: پورت خروجی توسط دامنه تعریف میشود ولی در زیرساخت (infrastructure) پیادهسازی میشود. این همان اصل وارونگی وابستگی (Dependency Inversion Principle) است که فیزیکی شده — پیکان میان دامنه و پایگاهداده برعکس شده تا حالا پایگاهداده به دامنه وابسته باشد، نه برعکس.
عادی: دامنه → پایگاهداده (دامنه اسم DB را میداند). بد است.
وارونه: دامنه یک رابط تعریف میکند (OrderRepository)، و کلاس JPA مینویسد implements OrderRepository. حالا پیکانِ کامپایل از سمت JPA به سمت دامنه میرود. دامنه دیگر اسم JPA را نمیداند. پیکان وارونه شد.
Onion / Clean (جفری پالرمو ۲۰۰۸ / رابرت مارتین ۲۰۱۲)
همان قانون، اینبار ترسیمشده بهصورت حلقههای هممرکز:
┌───────────────────────────────────────┐
│ فریمورکها و درایورها (web, DB, UI) │ ← آداپتورها
│ ┌─────────────────────────────────┐ │
│ │ آداپتورهای رابط (controller, │ │
│ │ presenter, gateway) │ │
│ │ ┌───────────────────────────┐ │ │
│ │ │ یوزکیسها (application) │ │ │
│ │ │ ┌─────────────────────┐ │ │ │
│ │ │ │ موجودیتها (domain) │ │ │ │
│ │ │ └─────────────────────┘ │ │ │
│ │ └───────────────────────────┘ │ │
│ └─────────────────────────────────┘ │
└───────────────────────────────────────┘
وابستگیها فقط رو به داخل اشاره میکنند
Hexagonal و Clean عمدتاً یک معماری واحدند در سطوح بزرگنماییِ متفاوت. Clean یک حلقهی صریح یوزکیس بین موجودیتها و آداپتورها اضافه میکند و ساختارهای دادهای «عبور از مرز» (مدلهای request/response) را نامگذاری میکند. Hexagonal بر تقارن سمت راننده و راندهشده تأکید دارد. در یک پروژهی واقعی Spring Boot معمولاً آنها را ادغام میکنی: واژگانِ پورت/آداپتور از Hexagonal، لایهی یوزکیس از Clean، و موجودیتهای دامنه در هسته.
لایهها، دقیق و مرجعگونه
این جدول را مثل یک نقشهی راه پیش چشمت نگه دار. برای هر لایه سه چیز مهم است: چه دارد، به چه مجاز است وابسته باشد، و از چه نباید اصلاً خبر داشته باشد.
| لایه | شامل | میتواند وابسته باشد به | نباید بشناسد |
|---|---|---|---|
| دامنه (Domain) | موجودیتها، آبجکتهای مقداری (value object)، سرویسهای دامنه، رویدادهای دامنه، ثابتهای کسبوکار (invariants)، رابطهای پورت خروجی | فقط JDK (و شاید کتابخانههای کوچک دامنه) | Spring, JPA, HTTP, JSON, SQL |
| اپلیکیشن (Application) | سرویسهای یوزکیس، رابطهای پورت ورودی، هماهنگسازی، مرز تراکنش | دامنه | فریمورکهای web/DB (جز از طریق پورت) |
| آداپتور (ورودی) | REST controller، GraphQL، CLI، listenerهای پیام، DTOها، مَپرهای DTO↔دامنه | اپلیکیشن (پورت ورودی)، دامنه | جزئیات DB |
| آداپتور (خروجی) | JPA repository، HTTP client، Kafka producer، موجودیتهای JPA، مَپرهای entity↔دامنه | دامنه (پورت خروجی) | لایه web |
| پیکربندی / bootstrap | @Configuration اسپرینگ، سیمکشی، main |
همه چیز | — |
دو واژهی جدید در جدول را باز کنم:
- آبجکت مقداری (value object): آبجکتی که هویتش فقط به مقدارش است، نه به یک شناسه. مثلاً
OrderId— دوOrderIdبا یک UUID یکسان، «همان»اند. مثل اسکناس: دو اسکناس ۱۰۰۰ تومانی برایت فرقی ندارند. - ثابت کسبوکار (invariant): قانونی که همیشه باید درست بماند. مثل «سفارش همیشه حداقل یک قلم دارد». کار دامنه این است که نگذارد هرگز نقض شود.
@Entity مربوط به JPA و موجودیت دامنه دو کلاس متفاوتاند. Order دامنه یک آبجکت جاوای خالص است که ثابتها را در سازنده و متدهایش اجرا میکند. OrderJpaEntity یک شکل ماندگاری است با سازندهی بدون آرگومان، @Id و getter/setter. یک مَپر آنها را در مرز آداپتور به هم تبدیل میکند.
بله، کد بیشتری است. آن کد دقیقاً بهای بیروننگهداشتنِ نیازهای Hibernate از قوانین کسبوکار است. این تکرارِ رفتار نیست، فقط تکرارِ شکل است.
یک چیدمان پکیجِ واقعی در Spring Boot
دو قرارداد رایج داریم. پکیجبندی بر اساس فیچر (package-by-feature) با زیرپکیجهای hexagonal بهترین مقیاسپذیری را دارد — یعنی بهجای اینکه همهی controllerها یکجا و همهی repositoryها جای دیگر باشند، هر فیچر (اینجا orders) پکیج خودش را دارد و درونش لایهبندی میشود:
com.acme.orders
├── domain # صفر import فریمورک
│ ├── model
│ │ ├── Order.java # aggregate غنی، ثابتها را اجرا میکند
│ │ ├── OrderId.java # value object
│ │ ├── OrderLine.java
│ │ └── OrderStatus.java
│ ├── event
│ │ └── OrderPlaced.java
│ └── port
│ └── out
│ ├── OrderRepository.java # پورت خروجی (رابط)
│ └── PaymentGateway.java # پورت خروجی (رابط)
│
├── application
│ ├── port
│ │ └── in
│ │ └── PlaceOrderUseCase.java # پورت ورودی (رابط)
│ └── service
│ └── PlaceOrderService.java # یوزکیس را پیاده میکند
│
└── adapter
├── in
│ └── web
│ ├── OrderController.java
│ ├── PlaceOrderRequest.java # DTO
│ └── OrderWebMapper.java
└── out
└── persistence
├── OrderJpaEntity.java
├── OrderJpaRepository.java # رابط Spring Data
├── OrderPersistenceAdapter.java # OrderRepository را پیاده میکند
└── OrderPersistenceMapper.java
یک واژهی تازه: aggregate یعنی یک خوشه از آبجکتهای دامنه که بهعنوان یک واحدِ یکپارچه رفتار میکنند و یک «ریشه» دارند (اینجا Order) که تنها درِ ورودیِ تغییرِ آن خوشه است. یعنی برای دستزدن به OrderLineها باید از Order رد شوی.
برای اجبار قانون وابستگی بهجای صرفاً امیدواری، پروژه را به ماژولهای Maven/Gradle تقسیم کن (domain، application، bootstrap) طوری که ماژول domain عملاً Spring را در classpath خودش نداشته باشد و نتواند در برابرش کامپایل شود. یا یک تست ArchUnit اضافه کن (پایینتر نشانت میدهم). خطاهای کامپایل و شکست CI تنها نردههای محافظِ ماندگارند؛ code review نیست — چون آدم خسته میشود و از قلم میاندازد.
مثال پورت و آداپتور، سر تا ته
حالا از تئوری به کدِ واقعی میرویم. دنبال کن که در هر فایل، پیکانِ import به کدام سمت است.
دامنه: یک موجودیت غنی و پورتهای خروجیاش
اول خودِ Order. توجه کن هیچ annotationای ندارد — نه @Entity، نه @Component. قوانین در سازنده و متدها زندگی میکنند:
package com.acme.orders.domain.model;
import java.util.List;
import java.util.Objects;
// دامنه خالص. بدون annotation. ثابتها در سازنده و متدها زندگی میکنند.
public final class Order {
private final OrderId id;
private final String customerId;
private final List<OrderLine> lines;
private OrderStatus status;
public Order(OrderId id, String customerId, List<OrderLine> lines) {
if (lines == null || lines.isEmpty())
throw new IllegalArgumentException("order must have at least one line");
this.id = Objects.requireNonNull(id);
this.customerId = Objects.requireNonNull(customerId);
this.lines = List.copyOf(lines); // کپی دفاعی -> غیرقابلتغییر
this.status = OrderStatus.NEW;
}
public long totalCents() {
return lines.stream().mapToLong(OrderLine::subtotalCents).sum();
}
// یک قانون کسبوکار واقعی، قابل یونیتتست با صفر زیرساخت.
public void markPaid() {
if (status != OrderStatus.NEW)
throw new IllegalStateException("only NEW orders can be paid, was " + status);
this.status = OrderStatus.PAID;
}
public OrderId id() { return id; }
public String customerId() { return customerId; }
public List<OrderLine> lines(){ return lines; }
public OrderStatus status() { return status; }
}
سه چیز را با هم ببین: سازنده اجازه نمیدهد سفارشِ بیقلم ساخته شود (ثابت)؛ List.copyOf یک کپی دفاعی میسازد تا کسی از بیرون لیست را عوض نکند (غیرقابلتغییر = immutable)؛ و markPaid قبل از تغییر حالت، شرط را چک میکند. این «غنیبودن» یک موجودیت است — رفتار دارد، نه فقط داده.
حالا پورتهای خروجی. اینها فقط رابطاند و در خودِ دامنه تعریف میشوند:
package com.acme.orders.domain.port.out;
import com.acme.orders.domain.model.Order;
import com.acme.orders.domain.model.OrderId;
import java.util.Optional;
// پورت خروجی: توسط دامنه تعریف میشود، توسط زیرساخت پیاده میشود.
public interface OrderRepository {
Order save(Order order);
Optional<Order> findById(OrderId id);
}
package com.acme.orders.domain.port.out;
import com.acme.orders.domain.model.OrderId;
public interface PaymentGateway {
// یک مرجع provider برمیگرداند؛ در شکست PaymentDeclinedException پرتاب میکند.
String charge(OrderId orderId, long amountCents);
}
دقت کن: OrderRepository میگوید «من میخواهم یک Order ذخیره کنم» — به زبان دامنه. نمیگوید چطور، با کدام SQL، در کدام جدول. آن جزئیات کارِ آداپتور است.
اپلیکیشن: پورت ورودی + سرویس یوزکیس
پورت ورودی، تنها دری است که بیرون مجاز است از آن سفارش ثبت کند:
package com.acme.orders.application.port.in;
import com.acme.orders.domain.model.OrderId;
import java.util.List;
// پورت ورودی: تنها راهی که بیرون مجاز است سفارش ثبت کند.
public interface PlaceOrderUseCase {
OrderId placeOrder(Command command);
// یک آبجکت command اختصاصی -> DTO وب هرگز به دامنه نمیرسد.
record Command(String customerId, List<Item> items) {
public record Item(String sku, int qty, long unitPriceCents) {}
}
}
آن Command یک آبجکتِ ورودیِ اختصاصی است. چرا نه مستقیم DTOِ وب؟ چون اگر DTOِ وب تا اینجا بیاید، فریمورک وب به دلِ اپ رخنه کرده. Command مرزی است بین وب و کسبوکار.
و حالا خودِ سرویس، جایی که هماهنگی اتفاق میافتد:
package com.acme.orders.application.service;
import com.acme.orders.application.port.in.PlaceOrderUseCase;
import com.acme.orders.domain.model.*;
import com.acme.orders.domain.port.out.OrderRepository;
import com.acme.orders.domain.port.out.PaymentGateway;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.UUID;
@Service // تنها جایی که Spring مجاز است کد اپلیکیشن را لمس کند.
public class PlaceOrderService implements PlaceOrderUseCase {
private final OrderRepository orders; // به PORT وابسته است، نه JPA
private final PaymentGateway payments;
public PlaceOrderService(OrderRepository orders, PaymentGateway payments) {
this.orders = orders;
this.payments = payments;
}
@Override
@Transactional // مرز تراکنش متعلق به یوزکیس است، نه دامنه
public OrderId placeOrder(Command cmd) {
var lines = cmd.items().stream()
.map(i -> new OrderLine(i.sku(), i.qty(), i.unitPriceCents()))
.toList();
var order = new Order(new OrderId(UUID.randomUUID()), cmd.customerId(), lines);
payments.charge(order.id(), order.totalCents()); // پورت خروجی
order.markPaid(); // قانون دامنه
return orders.save(order).id(); // پورت خروجی
}
}
توجه کن PlaceOrderService چه چیزی را import نمیکند: نه @Entity، نه JpaRepository، نه RestTemplate، نه HttpServletRequest. فقط آبجکتهای دامنه را از طریق رابطها هماهنگ میکند. کل هدف همین است. تنها ردِ پای Spring اینجا دو annotation است: @Service (تا Spring این کلاس را بسازد و تزریق کند) و @Transactional (مرز تراکنش).
تراکنش یعنی «یا همهی این کارها با هم انجام شود یا هیچکدام». این یک واحد کار (unit of work) است. مرز واحد کار دقیقاً همان یوزکیس است — «ثبت سفارش» یک عملیاتِ اتمی است. دامنه نباید اصلاً بداند تراکنش وجود دارد؛ آن یک دغدغهی زیرساختی است. برای همین @Transactional روی سرویس مینشیند، نه روی Order.
آداپتور خروجی: JPA پورت را پیاده میکند
اینجا جایی است که پیکانِ وارونگی را با چشم میبینی — این کلاس implements OrderRepository میکند، یعنی به دامنه وابسته است:
package com.acme.orders.adapter.out.persistence;
import com.acme.orders.domain.model.Order;
import com.acme.orders.domain.model.OrderId;
import com.acme.orders.domain.port.out.OrderRepository;
import org.springframework.stereotype.Component;
import java.util.Optional;
@Component
public class OrderPersistenceAdapter implements OrderRepository {
private final OrderJpaRepository jpa;
private final OrderPersistenceMapper mapper;
public OrderPersistenceAdapter(OrderJpaRepository jpa, OrderPersistenceMapper mapper) {
this.jpa = jpa;
this.mapper = mapper;
}
@Override
public Order save(Order order) {
OrderJpaEntity saved = jpa.save(mapper.toJpa(order)); // دامنه -> entity
return mapper.toDomain(saved); // entity -> دامنه
}
@Override
public Optional<Order> findById(OrderId id) {
return jpa.findById(id.value()).map(mapper::toDomain);
}
}
و entityِ JPA، جایی که همهی دغدغههای Hibernate محبوس میشوند:
package com.acme.orders.adapter.out.persistence;
import jakarta.persistence.*;
import java.util.UUID;
@Entity // دغدغههای Hibernate فقط اینجا زندگی میکنند
@Table(name = "orders")
public class OrderJpaEntity {
@Id private UUID id;
private String customerId;
private String status;
// ... ستونها، سازنده بدون آرگومان، getter/setter که Hibernate میخواهد
protected OrderJpaEntity() {}
// ...
}
خودِ OrderJpaRepository یک interface OrderJpaRepository extends JpaRepository<OrderJpaEntity, UUID> معمولی است. کل Spring Data درون آداپتور زندگی میکند؛ اپلیکیشن هرگز آن را نمیبیند. mapper هم فقط بین شکل دامنه و شکل entity ترجمه میکند.
آداپتور ورودی: REST پورت را فراخوانی میکند
و در سمت مقابل، controller که پورت ورودی را فراخوانی میکند:
package com.acme.orders.adapter.in.web;
import com.acme.orders.application.port.in.PlaceOrderUseCase;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.net.URI;
@RestController
@RequestMapping("/orders")
public class OrderController {
private final PlaceOrderUseCase placeOrder; // به PORT ورودی وابسته است
public OrderController(PlaceOrderUseCase placeOrder) {
this.placeOrder = placeOrder;
}
@PostMapping
public ResponseEntity<Void> place(@RequestBody PlaceOrderRequest req) {
var cmd = OrderWebMapper.toCommand(req); // DTO -> command
var id = placeOrder.placeOrder(cmd);
return ResponseEntity.created(URI.create("/orders/" + id.value())).build();
}
}
کل جریان را حالا با یک نگاه ببین:
HTTP → controller → پورت ورودی → سرویس یوزکیس → دامنه + پورتهای خروجی → آداپتورها → DB/پرداخت.
وابستگی در هر درز (seam)، رو به داخل اشاره میکند. هر پیکانِ import به سمت هسته است. همین.
قابلیت تست: سودِ ملموس
تصور کن بخواهی موتور یک قطعه را تست کنی. اگر موتور جوشخورده به کل بدنه باشد، باید کل ماشین را روشن کنی. ولی اگر موتور با چند پیچِ استاندارد وصل شده باشد، میتوانی روی میز تستش کنی و بهجای چرخها یک شبیهساز ببندی. پورتها همان «پیچهای استاندارد»اند؛ mock همان شبیهساز.
// یونیتتست خالص — بدون Spring، بدون DB، اجرا در چند میلیثانیه.
class PlaceOrderServiceTest {
@Test
void chargesThenSaves() {
var repo = mock(OrderRepository.class);
var pay = mock(PaymentGateway.class);
when(repo.save(any())).thenAnswer(inv -> inv.getArgument(0));
var service = new PlaceOrderService(repo, pay);
var cmd = new PlaceOrderUseCase.Command("cust-1",
List.of(new PlaceOrderUseCase.Command.Item("SKU", 2, 500)));
service.placeOrder(cmd);
verify(pay).charge(any(), eq(1000L)); // 2 * 500
verify(repo).save(argThat(o -> o.status() == OrderStatus.PAID));
}
}
نه @SpringBootTest، نه Testcontainers، نه HTTP. یک واژه: mock یعنی یک بدلِ ساختگی از یک رابط که تو رفتارش را دیکته میکنی و میتوانی بعداً بپرسی «فلان متد صدا زده شد؟». چون سرویس فقط به رابطهایی وابسته است که خودت مالکشان هستی، بهراحتی بدل جایگزین میکنی.
آداپتورها تستهای باریکِ خودشان را میگیرند: @DataJpaTest (با Testcontainers، یعنی یک پایگاهدادهی واقعی در کانتینر) برای آداپتور ماندگاری، و @WebMvcTest برای controller. موجودیتهای دامنه JUnit ساده میگیرند. هر تست دقیقاً یک دغدغه را هدف میگیرد و سریع اجرا میشود — این نتیجهی مستقیم معماری است، نه یک افزودنی.
اجبار قانون با ArchUnit
ArchUnit یک کتابخانه است که با آن میتوانی دربارهی ساختار کدت تست بنویسی — مثلاً «هیچ کلاسی در پکیج دامنه نباید به Spring وابسته باشد». این تستها در همان CI اجرا میشوند و اگر کسی قانون را بشکند، بیلد قرمز میشود:
@AnalyzeClasses(packages = "com.acme.orders")
class ArchitectureTest {
@ArchTest
static final ArchRule domain_depends_on_nothing_external =
classes().that().resideInAPackage("..domain..")
.should().onlyDependOnClassesThat()
.resideInAnyPackage("..domain..", "java..", "javax.validation..");
@ArchTest
static final ArchRule no_framework_in_domain =
noClasses().that().resideInAPackage("..domain..")
.should().dependOnClassesThat()
.resideInAnyPackage("org.springframework..", "jakarta.persistence..");
}
این معماری را از یک صفحهی ویکی (که کسی نمیخواندش) به یک دروازهی بیلد (build gate) تبدیل میکند. بدون آن، اولین مهندس خستهای که زیر فشار ددلاین است، یک repository را با @Autowired به دامنه تزریق میکند و شش ماه هیچکس متوجه نمیشود — تا وقتی که نوشتن یک تست ساده دفعتاً به راهانداختن کل Spring نیاز پیدا میکند.
دامها و نکات ظریف
- استفادهی مجدد از دامنه بهعنوان entityِ JPA. رایجترین میانبر، و آرامآرام کل سود را نابود میکند — حالا نیازهای Hibernate مدلت را دیکته میکنند. اگر برای یک اپ کوچک مجبوری ادغامشان کنی، آگاهانه انجام بده نه بهطور پیشفرض.
- نشت DTO به دامنه. اگر
PlaceOrderServiceیک DTOِ وبِ دارای@RequestBodyبپذیرد، فریمورک وب به هسته رسیده. در مرز به یکCommandمَپ کن. - پورتهای کمخونی که ORM را آینه میکنند. یک پورت خروجی با
findByStatusAndCustomerIdOrderByCreatedAt(...)فقط Spring Data در لباس مبدل است. پورتها باید قصد دامنه را بیان کنند (ordersAwaitingPayment())، نه مکانیک کوئری را. - انتزاعهای نشتکننده (leaky abstractions). پورتی که
Page<Entity>برمیگرداند یاSQLExceptionپرتاب میکند، آداپتور را نشت میدهد. پورتها به زبان دامنه حرف میزنند، از جمله استثناهای مخصوص دامنه. - خستگی از مَپینگ. دامنه↔entity↔DTO سه شکل و دو مَپر است. از MapStruct (کتابخانهای که کد مَپر را خودکار میسازد) برای کاهش boilerplate استفاده کن، اما هرگز نگذار مَپر در دامنه زندگی کند.
- جایگذاری تراکنش.
@Transactionalمتعلق به سرویس یوزکیس است (مرز واحد کار)، نه دامنه (که از تراکنش خبر ندارد) و نه پخششده در repositoryها. - پورتهای چرخهای. پورتهای ورودی و خروجی نباید به DTOهای یکدیگر ارجاع دهند. مدلهای command/response را در سمت ورودی و آبجکتهای دامنه را در سمت خروجی نگه دار.
- و در نهایت: ماژولاریزهکردن بیش از حدِ یک اپ CRUD. پورتها و مَپرها دورِ سرویسی که فقط به یک repository فوروارد میکند، هزینه میافزایند بدون سود.
کِی مهندسیِ بیش از حد است
دربارهی این صادق باش — این دقیقاً همان چیزی است که یک مهندس ارشد را از بقیه جدا میکند. Hexagonalِ کامل رایگان نیست: کلاسهای بیشتر، مَپرهای بیشتر، غیرمستقیمسازی بیشتر، آنبوردینگِ کندتر برای عضو جدید تیم.
زمانی میارزد که: دامنه واقعاً پیچیده باشد (ثابتهای غنی، قوانین زیاد)؛ اپ عمر طولانی داشته باشد؛ چند آداپتور راننده/راندهشده داشته باشی (REST + Kafka + batch، یا Postgres امروز + چیز دیگری بعداً)؛ یا قابلیت تستِ قوانین کسبوکار یک الزام سخت باشد.
زمانی مهندسیِ بیش از حد است که: سرویس اساساً CRUD یا یک لولهی دادهی نازک باشد؛ تیم کوچک و اپ کوتاهعمر باشد؛ منطق دامنهی واقعی وجود نداشته باشد (اعتبارسنجی + ماندگاری یک دامنه نیست). برای اینها، یک اپ لایهایِ منضبط — حتی با اشتراک entityها — سریعتر تحویل میشود و کاملاً قابل نگهداری است.
وقتی سراغ پورتها برو که یک وابستگیِ فنی واقعاً تهدید کند که یک دامنهی غیربدیهی را تحریف یا به آن نشت کند. معماری هزینهای است که میپردازی تا اختیار (optionality) بخری؛ اختیاری که هرگز اعمال نخواهی کرد را نخر.
سؤالات مصاحبه
وابستگیهای کد منبع فقط رو به داخل و به سمت دامنه اشاره میکنند؛ دامنه به هیچ چیز بیرونی وابسته نیست. مهم است چون تصمیمات فنیِ بیثبات (DB، فریمورک، transport) را از قوانین کسبوکارِ پایدار جدا میکند، پس قوانین را میتوان جدا تست کرد و فناوری میتواند بدون موجانداختن در هسته عوض شود.
پورتهای راننده/ورودی رابطهاییاند که اپلیکیشن در معرض میگذارد تا کنشگران بیرونی آن را برانند (یوزکیسی که controller فراخوانی میکند). پورتهای راندهشده/خروجی رابطهاییاند که اپلیکیشن نیاز دارد تا به سیستمهای بیرونی برسد (repository یا gateway که آداپتور پیاده میکند). آداپتور ورودی پورت را فراخوانی میکند؛ آداپتور خروجی پورت را پیاده میکند.
همان ایدهی هستهاند — وابستگیهای رو به داخل حول دامنهای بدون فریمورک — در سطوح بزرگنماییِ متفاوت. Clean یک حلقهی یوزکیس صریح و مدلهای request/response نامگذاریشدهی عبور از مرز اضافه میکند؛ Hexagonal بر تقارن راننده/راندهشده تأکید دارد. در عمل ترکیبشان میکنی: واژگان پورت/آداپتورِ Hexagonal + لایهی یوزکیسِ Clean.
در پورت خروجی: رابط متعلق به دامنه است اما در زیرساخت پیاده میشود. پیکانِ زمانکامپایل از پایگاهداده به دامنه (از طریق implements OrderRepository) همان وابستگیِ وارونه است — زیرساخت به دامنه وابسته است، نه برعکس.
تکرارِ رفتار نیست، فقط تکرارِ شکل است. ادغامشان محدودیتهای Hibernate (سازندهی بدون آرگومان، حالت mutable، طراحیِ سازگار با proxy، فیلدهای مبتنی بر schema) را بر مدل کسبوکار تحمیل میکند و یک دامنهی کمخون و شکلگرفتهبراساسماندگاری تولید میکند. جداسازی یک مَپر هزینه دارد اما ثابتها و قوانین را خالص و قابلتست نگه میدارد. برای یک اپ CRUDِ بدیهی، ادغام یک میانبر مشروع و عمدی است.
لایهی کسبوکار/سرویس رو به پایین به لایهی ماندگاری وابسته است، پس دامنه بهطور گذرا پایگاهداده و ORM را میشناسد. Hexagonal آن یال را وارونه میکند: ماندگاری به یک پورتِ متعلق به دامنه وابسته میشود و دامنه از هر دانشِ زیرساختی آزاد میشود.
روی سرویس اپلیکیشن/یوزکیس که واحد کار را تعریف میکند. دامنه نباید بداند تراکنش اصلاً وجود دارد — این یک دغدغهی زیرساختی است. گذاشتنش در دامنه Spring را به هسته نشت میدهد و قوانین کسبوکار را به چرخهعمرِ ماندگاری میچسباند.
این API کوئریِ ORM است که از پورت نشت میکند — انتزاع بهجای بیان قصد دامنه، Spring Data را آینه میکند. پورتها باید مثل زبان فراگیر (ubiquitous language) خوانده شوند (ordersAwaitingPayment())؛ اینکه چطور به کوئری مَپ میشود کارِ خصوصیِ آداپتور است.
controller (یک آداپتور ورودی) حالا به یک پورت خروجی وابسته است و لایهی اپلیکیشن را دور میزند، پس هماهنگسازیِ کسبوکار، ثابتها و مرز تراکنش همه رد میشوند. هم درستی (بدون پرداخت، بدون گذار حالت) و هم معماری (آداپتور ورودی که به پورت خروجی میرسد) نقض میشوند. controllerها فقط مجازند به پورت ورودی وابسته باشند.
منطق کسبوکار فقط به رابطهایی وابسته است که خودت مالکشان هستی، پس یوزکیسها با mock/fake تست میشوند — بدون Spring context، بدون DB — در چند میلیثانیه. آداپتورها تستهای برشیِ باریک میگیرند (@DataJpaTest، @WebMvcTest)؛ دامنه JUnit ساده میگیرد. هر تست یک دغدغه را هدف میگیرد، نتیجهای ساختاری و مستقیم.
var order = new Order(new OrderId(UUID.randomUUID()), "c1",
List.of(new OrderLine("SKU", 1, 100)));
order.markPaid();
order.markPaid();
در فراخوانیِ دوم IllegalStateException("only NEW orders can be paid, was PAID") پرتاب میکند. ثابت در متدِ دامنه زندگی میکند، پس گذارهای غیرمجازِ حالت بدون توجه به فراخواننده سریع شکست میخورند — دقیقاً همان تضمینی که از یک مدل غنی میخواهی، و چیزی که یک entityِ کمخونِ مبتنی بر setter بیصدا اجازهاش میداد.
اجبار زمانکامپایل را ترجیح بده: domain/application/adapter را به ماژولهای جدای Gradle/Maven تقسیم کن تا دامنه فیزیکاً نتواند Spring را ببیند. جایی که ماژول عملی نیست، یک تست ArchUnit اضافه کن که ادعا کند ..domain.. به هیچ چیز مرتبط با فریمورک وابسته نیست و آن را در CI اجرا کن. code review بهتنهایی همیشه زیر فشار ددلاین فرسوده میشود.
وقتی منطق دامنهی واقعی وجود ندارد — یک سرویس CRUD یا لولهی دادهی نازک — یا اپ کوچک و کوتاهعمر است با یک آداپتور در هر سمت. آنگاه پورتها، مَپرها و غیرمستقیمسازی اختیاری میخرند که هرگز استفاده نخواهی کرد. وقتی سراغ پورتها و آداپتورهای کامل برو که یک دامنهی غیربدیهی واقعاً با کوپلینگ فنی تهدید شود، یا چند آداپتورِ محتملالتغییر داشته باشی.
بله. دامنه آبجکتهای رویدادِ دامنهی ساده (OrderPlaced) تولید میکند و یا آنها را برمیگرداند یا روی aggregate ثبت میکند. یک پورت خروجی (DomainEventPublisher) در دامنه تعریف و توسط یک آداپتور (Kafka، ApplicationEventPublisher اسپرینگ) پیاده میشود. دامنه از فناوریِ پیامرسانی آزاد میماند.
لایهها بهتنهایی hexagonal نیستند — جهت است که اهمیت دارد. لایههای کلاسیک رو به پایین به ماندگاری اشاره میکنند، پس دامنه به DB وابسته است. Hexagonal یالِ ماندگاری را از طریق یک پورتِ متعلق به دامنه وارونه میکند. اگر سرویس تو مستقیماً یک repository از Spring Data را import میکند، لایه داری نه پورت و آداپتور، و سودِ جداسازی یا قابلیت تست را نمیگیری.
- سه معماریِ لایهای، Hexagonal و Clean سه بیان از یک قانوناند: وابستگیها رو به داخل، به سمت دامنه؛ دامنه به هیچکس وابسته نیست.
- لایهای کلاسیک همین قانون را نقض میکند چون کسبوکار به ماندگاری وابسته است؛ Hexagonal آن یال را با یک پورتِ متعلق به دامنه وارونه میکند (اصل وارونگی وابستگی، فیزیکیشده).
- پورت ورودی را بیرون فرا میخواند؛ پورت خروجی را دامنه تعریف و زیرساخت پیاده میکند. آداپتورها این دو را به دنیای واقعی وصل میکنند.
- entityِ JPA و موجودیتِ دامنه را جدا نگه دار؛ یک مَپر بینشان بگذار تا نیازهای Hibernate به کسبوکار نشت نکند.
@Transactionalروی یوزکیس مینشیند، نه دامنه. پورتها به زبان دامنه حرف میزنند، نه به زبان کوئری.- قانون را با ماژولبندی و ArchUnit اجبار کن؛ code review فرسوده میشود.
- و مهمترین قضاوت: این معماری اختیار میخرد؛ برای یک اپ CRUDِ کوچک، اختیاری که هرگز اعمال نمیکنی را نخر.
Most software-architecture talk arrives buried under hexagons, nested rings, and impressive-sounding names. Here's the good news: underneath all of it there is only one idea. Get that one idea deep in your bones and you can re-derive everything else yourself. Let's build it slowly, from scratch.
Here's what you'll walk away knowing:
- The one rule every one of these architectures shares (the Dependency Rule) — and why it exists at all.
- The three styles — Layered, Hexagonal (Ports & Adapters), and Onion/Clean — and what actually distinguishes them.
- A precise reference table of the layers.
- A complete Spring Boot app, end to end, with real domain, port, adapter, and test code.
- How to enforce the rule with ArchUnit and module boundaries so it doesn't erode.
- Common pitfalls, and the biggest senior judgment call: when this is over-engineering.
- Fifteen interview questions with full answers.
Part 0 — words you must know
Before anything else, let's unpack a few terms from the ground up so none of them ever gets dropped on you cold.
- Dependency: when class A
imports or uses class B, we say A depends on B. If B changes, A may break. The direction of that arrow — who depends on whom — is the heart of this entire chapter. - Domain: the heart of your business — the actual rules of your work. Things like "an order must have at least one line" or "only a NEW order can be paid." These have nothing to do with databases or HTTP; they'd still be true if your company ran on pen and paper.
- Infrastructure: all the replaceable technical stuff — database, web framework, message queue, payment service. These are tools, not the business.
- Port: a Java interface that the app itself owns. Think of a wall socket: the app decides its shape, and anything wanting to connect must match that shape.
- Adapter: code that wires a port to the real world — either implementing it or calling it. Like the plug that goes into the socket.
- Coupling: how tangled two pieces of code are. High coupling means you can't change one without touching the other.
Don't picture the dependency arrow as "who calls whom at runtime." Picture it as "who knows whose name." At runtime the domain may cause database code to run, but at compile time the domain doesn't even know the name of the database. That separation of "who knows whose name" is what makes everything else possible.
Mental model: it's all one rule
Picture an embassy. Inside, your country's law rules — not the host country's. Embassy staff talk to the outside world only through a specific counter, on their own terms. Everything outside — the local language, local currency, local bureaucracy — must adapt itself to the counter's rules to do business with the inside. The embassy never reshapes itself to fit the host city's post office; it's the other way round. Your domain is the inside of that embassy. Business rules are your home country's law. The counter is the port. The outside world (DB, web, Kafka) must adapt itself to the counter via an adapter.
Strip away the diagrams and the marketing, and Layered, Hexagonal (Ports & Adapters), and Onion/Clean are three answers to a single question: which direction do source-code dependencies point?
The winning answer, shared by Hexagonal and Clean, is the Dependency Rule:
Source dependencies point inward, toward the domain, and the domain depends on nothing external.
That means your business logic must not import Spring, JPA, Jackson, Kafka, or java.sql. Everything else — ports, adapters, use cases, mappers — is just machinery that enforces this one rule. If you understand why the domain must not know about the database, you can derive the rest yourself.
Why is this worth it? The payoff is concrete:
- You can unit-test business logic with no Spring context and no database — tests that run in milliseconds.
- You can swap Postgres for MongoDB, or REST for gRPC, without touching a single business rule.
- Code that changes for business reasons is physically separated from code that changes for technical reasons.
The cost? Indirection and ceremony — more classes and layers standing in the middle of the path. Senior judgment is knowing exactly when that trade is worth paying.
Dependencies point inward, toward the domain. The domain depends on no one; everyone depends on the domain. If you keep just this one sentence from the whole chapter, you can reinvent ports, adapters, use cases, and mappers on your own.
The three styles compared
Classic Layered / n-tier
Picture an office building: sales upstairs, accounting in the middle, the warehouse below. Each floor only deals with the floor below it. Sales leans on accounting; accounting leans on the warehouse. Now the problem: if the warehouse (the database) changes its layout, that change travels up like a crack in the wall, from the bottom to the top, shaking every floor.
The default most teams reach for:
┌─────────────────────────┐
│ Controller / Web │ presentation
├─────────────────────────┤
│ Service │ business logic
├─────────────────────────┤
│ Repository / DAO │ persistence
├─────────────────────────┤
│ Database │
└─────────────────────────┘
dependencies point DOWN
Each layer depends on the one below. The fatal flaw is right here: the business layer depends on the persistence layer. Your Service imports Repository, and that Repository is bound to JPA and the DB schema. So your domain now transitively (that is, through one middleman) knows about the database. The chain is: Service → Repository → JPA → schema.
In practice it gets worse: JPA @Entity classes leak upward and become your "domain model." Then your business rules get shaped by Hibernate's needs — public no-arg constructor, mutable setters, lazy-loading proxies, @ManyToOne graphs — rather than by the business itself.
When JPA entities become your domain model, you get an anemic domain model: classes full of getters/setters and empty of rules. "Anemic" means the real behavior and logic aren't in it; it's just data. Business rules then scatter across services, and no one can guarantee an invalid order never gets built. Layered isn't wrong — it's just insufficiently strict about the dependency direction.
Hexagonal (Ports & Adapters), Alistair Cockburn, 2005
Cockburn's key insight: there is no top or bottom, only inside — the application — and outside — everything else: UI, DB, message brokers, other services. The application talks to the outside world exclusively through ports (interfaces it owns), and the outside world connects via adapters that implement or invoke those ports.
DRIVING side (primary) DRIVEN side (secondary)
actors that USE the app things the app USES
REST ─┐ ┌─ JPA / Postgres
CLI ─┤→ [driving port] → APPLICATION → [driven port] ├─ Kafka
gRPC ─┘ (inbound) (domain + (outbound) └─ Payment API
use cases)
Let me carefully separate the two kinds of ports, because this is exactly where people stumble in interviews:
- Driving / primary / inbound ports: interfaces the application exposes so the outside can "drive" it — e.g.
PlaceOrderUseCase. An adapter here (say a REST controller) calls the port. The outside commands the inside. - Driven / secondary / outbound ports: interfaces the application requires so it can reach the outside itself — e.g.
OrderRepository,PaymentGateway. An adapter here (say a JPA repository) implements the port. The inside uses the outside.
The port is the wall socket. Its shape and voltage are decided by the house (the application). An inbound adapter is like a light switch that commands the socket. An outbound adapter is like a fridge that plugs into the socket to draw power from the house. In both cases the house dictates the shape of the connection, not the fridge. Swap the fridge and the socket stays untouched — exactly what you want for your database.
Now the critical inversion: the outbound port is defined by the domain but implemented in infrastructure. This is the Dependency Inversion Principle made physical — the arrow between domain and database is flipped so that now the database depends on the domain, not the other way round.
Normal: domain → database (the domain knows the DB's name). Bad.
Inverted: the domain defines an interface (OrderRepository), and the JPA class writes implements OrderRepository. Now the compile-time arrow runs from JPA to the domain. The domain no longer knows JPA's name. The arrow got flipped.
Onion / Clean (Jeffrey Palermo 2008 / Robert Martin 2012)
The same rule, this time drawn as concentric rings:
┌───────────────────────────────────────┐
│ Frameworks & Drivers (web, DB, UI) │ ← adapters
│ ┌─────────────────────────────────┐ │
│ │ Interface Adapters (controllers,│ │
│ │ presenters, gateways) │ │
│ │ ┌───────────────────────────┐ │ │
│ │ │ Use Cases (application) │ │ │
│ │ │ ┌─────────────────────┐ │ │ │
│ │ │ │ Entities (domain) │ │ │ │
│ │ │ └─────────────────────┘ │ │ │
│ │ └───────────────────────────┘ │ │
│ └─────────────────────────────────┘ │
└───────────────────────────────────────┘
dependencies point INWARD only
Hexagonal and Clean are largely the same architecture at different zoom levels. Clean adds an explicit use-case ring between entities and adapters, and names the "cross-the-boundary" data structures (request/response models). Hexagonal emphasizes the symmetry of driving vs driven sides. In a real Spring Boot project you typically merge them: hexagonal port/adapter vocabulary, Clean's use-case layer, and domain entities at the core.
The layers, precisely — a reference table
Keep this table in front of you like a map. For each layer, three things matter: what it contains, what it's allowed to depend on, and what it must not know about at all.
| Layer | Contains | May depend on | Must NOT know about |
|---|---|---|---|
| Domain | Entities, value objects, domain services, domain events, business invariants, outbound port interfaces | Only the JDK (and maybe tiny domain libs) | Spring, JPA, HTTP, JSON, SQL |
| Application | Use-case services, inbound port interfaces, orchestration, transaction boundaries | Domain | Web/DB frameworks (except via ports) |
| Adapters (in) | REST controllers, GraphQL, CLI, message listeners, DTOs, DTO↔domain mappers | Application (inbound ports), Domain | DB internals |
| Adapters (out) | JPA repositories, HTTP clients, Kafka producers, JPA entities, entity↔domain mappers | Domain (outbound ports) | Web layer |
| Config / bootstrap | Spring @Configuration, wiring, main |
Everything | — |
Two new words from the table:
- Value object: an object whose identity is purely its value, not an id. E.g.
OrderId— twoOrderIds with the same UUID are "the same." Like banknotes: two 1000-unit notes are interchangeable to you. - Invariant: a rule that must always stay true, like "an order always has at least one line." The domain's job is to make sure it's never violated.
The JPA @Entity and the domain entity are two different classes. The domain Order is a pure Java object enforcing invariants in its constructor and methods. The OrderJpaEntity is a persistence shape with a no-arg constructor, @Id, and getters/setters. A mapper converts between them at the adapter boundary.
Yes, it's more code. That code is exactly the price of keeping Hibernate's requirements out of your business rules. It's not duplication of behavior, only of shape.
A concrete Spring Boot package layout
There are two common conventions. Package-by-feature with hexagonal sub-packages scales best — instead of every controller in one place and every repository in another, each feature (here orders) gets its own package and is layered inside it:
com.acme.orders
├── domain # ZERO framework imports
│ ├── model
│ │ ├── Order.java # rich aggregate, enforces invariants
│ │ ├── OrderId.java # value object
│ │ ├── OrderLine.java
│ │ └── OrderStatus.java
│ ├── event
│ │ └── OrderPlaced.java
│ └── port
│ └── out
│ ├── OrderRepository.java # outbound port (interface)
│ └── PaymentGateway.java # outbound port (interface)
│
├── application
│ ├── port
│ │ └── in
│ │ └── PlaceOrderUseCase.java # inbound port (interface)
│ └── service
│ └── PlaceOrderService.java # implements the use case
│
└── adapter
├── in
│ └── web
│ ├── OrderController.java
│ ├── PlaceOrderRequest.java # DTO
│ └── OrderWebMapper.java
└── out
└── persistence
├── OrderJpaEntity.java
├── OrderJpaRepository.java # Spring Data interface
├── OrderPersistenceAdapter.java # implements OrderRepository
└── OrderPersistenceMapper.java
One new word: an aggregate is a cluster of domain objects that behaves as one unit and has a "root" (here Order) that is the only door into changing that cluster. To touch the OrderLines you must go through Order.
To enforce the dependency rule instead of merely hoping for it, split the project into Maven/Gradle modules (domain, application, bootstrap) so that the domain module literally doesn't have Spring on its own classpath and cannot compile against it. Or add an ArchUnit test (shown below). Compilation errors and CI failures are the only durable guardrails; code review is not — because tired humans overlook things.
Port and adapter example, end to end
Now we go from theory to real code. Watch which direction the import arrow points in each file.
Domain: a rich entity and its outbound ports
First, Order itself. Notice it has no annotations — no @Entity, no @Component. The rules live in the constructor and the methods:
package com.acme.orders.domain.model;
import java.util.List;
import java.util.Objects;
// Pure domain. No annotations. Invariants live in the constructor and methods.
public final class Order {
private final OrderId id;
private final String customerId;
private final List<OrderLine> lines;
private OrderStatus status;
public Order(OrderId id, String customerId, List<OrderLine> lines) {
if (lines == null || lines.isEmpty())
throw new IllegalArgumentException("order must have at least one line");
this.id = Objects.requireNonNull(id);
this.customerId = Objects.requireNonNull(customerId);
this.lines = List.copyOf(lines); // defensive copy -> immutable
this.status = OrderStatus.NEW;
}
public long totalCents() {
return lines.stream().mapToLong(OrderLine::subtotalCents).sum();
}
// A real business rule, unit-testable with zero infrastructure.
public void markPaid() {
if (status != OrderStatus.NEW)
throw new IllegalStateException("only NEW orders can be paid, was " + status);
this.status = OrderStatus.PAID;
}
public OrderId id() { return id; }
public String customerId() { return customerId; }
public List<OrderLine> lines(){ return lines; }
public OrderStatus status() { return status; }
}
See three things together: the constructor won't let a line-less order be built (an invariant); List.copyOf makes a defensive copy so no one outside can mutate the list (immutable); and markPaid checks the condition before changing state. That's what makes an entity "rich" — it has behavior, not just data.
Now the outbound ports. These are only interfaces, and they're defined in the domain itself:
package com.acme.orders.domain.port.out;
import com.acme.orders.domain.model.Order;
import com.acme.orders.domain.model.OrderId;
import java.util.Optional;
// Outbound port: DEFINED by the domain, IMPLEMENTED by infrastructure.
public interface OrderRepository {
Order save(Order order);
Optional<Order> findById(OrderId id);
}
package com.acme.orders.domain.port.out;
import com.acme.orders.domain.model.OrderId;
public interface PaymentGateway {
// Returns a provider reference; throws PaymentDeclinedException on failure.
String charge(OrderId orderId, long amountCents);
}
Notice: OrderRepository says "I want to save an Order" — in the domain's language. It doesn't say how, with what SQL, in which table. Those details are the adapter's job.
Application: inbound port + use-case service
The inbound port is the only door through which the outside is allowed to place an order:
package com.acme.orders.application.port.in;
import com.acme.orders.domain.model.OrderId;
import java.util.List;
// Inbound port: the ONLY way the outside is allowed to place an order.
public interface PlaceOrderUseCase {
OrderId placeOrder(Command command);
// A dedicated command object -> the web DTO never reaches the domain.
record Command(String customerId, List<Item> items) {
public record Item(String sku, int qty, long unitPriceCents) {}
}
}
That Command is a dedicated input object. Why not just the web DTO? Because if the web DTO came this far, the web framework would have wormed into the core of the app. Command is a boundary between web and business.
And now the service itself, where the orchestration happens:
package com.acme.orders.application.service;
import com.acme.orders.application.port.in.PlaceOrderUseCase;
import com.acme.orders.domain.model.*;
import com.acme.orders.domain.port.out.OrderRepository;
import com.acme.orders.domain.port.out.PaymentGateway;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.UUID;
@Service // The one place Spring is allowed to touch application code.
public class PlaceOrderService implements PlaceOrderUseCase {
private final OrderRepository orders; // depends on the PORT, not JPA
private final PaymentGateway payments;
public PlaceOrderService(OrderRepository orders, PaymentGateway payments) {
this.orders = orders;
this.payments = payments;
}
@Override
@Transactional // transaction boundary belongs to the use case, not the domain
public OrderId placeOrder(Command cmd) {
var lines = cmd.items().stream()
.map(i -> new OrderLine(i.sku(), i.qty(), i.unitPriceCents()))
.toList();
var order = new Order(new OrderId(UUID.randomUUID()), cmd.customerId(), lines);
payments.charge(order.id(), order.totalCents()); // driven port
order.markPaid(); // domain rule
return orders.save(order).id(); // driven port
}
}
Notice what PlaceOrderService does not import: no @Entity, no JpaRepository, no RestTemplate, no HttpServletRequest. It just orchestrates domain objects through interfaces. That is the whole point. The only trace of Spring here is two annotations: @Service (so Spring builds and injects this class) and @Transactional (the transaction boundary).
A transaction means "either all of these steps happen together, or none of them do." That's a unit of work. The boundary of that unit of work is exactly the use case — "place an order" is one atomic operation. The domain must not even know transactions exist; that's an infrastructure concern. So @Transactional sits on the service, not on Order.
Outbound adapter: JPA implements the port
Here's where you see the inversion arrow with your own eyes — this class writes implements OrderRepository, meaning it depends on the domain:
package com.acme.orders.adapter.out.persistence;
import com.acme.orders.domain.model.Order;
import com.acme.orders.domain.model.OrderId;
import com.acme.orders.domain.port.out.OrderRepository;
import org.springframework.stereotype.Component;
import java.util.Optional;
@Component
public class OrderPersistenceAdapter implements OrderRepository {
private final OrderJpaRepository jpa;
private final OrderPersistenceMapper mapper;
public OrderPersistenceAdapter(OrderJpaRepository jpa, OrderPersistenceMapper mapper) {
this.jpa = jpa;
this.mapper = mapper;
}
@Override
public Order save(Order order) {
OrderJpaEntity saved = jpa.save(mapper.toJpa(order)); // domain -> entity
return mapper.toDomain(saved); // entity -> domain
}
@Override
public Optional<Order> findById(OrderId id) {
return jpa.findById(id.value()).map(mapper::toDomain);
}
}
And the JPA entity, where all of Hibernate's concerns are imprisoned:
package com.acme.orders.adapter.out.persistence;
import jakarta.persistence.*;
import java.util.UUID;
@Entity // Hibernate concerns live ONLY here
@Table(name = "orders")
public class OrderJpaEntity {
@Id private UUID id;
private String customerId;
private String status;
// ... columns, no-arg ctor, getters/setters that Hibernate demands
protected OrderJpaEntity() {}
// ...
}
The OrderJpaRepository itself is a normal interface OrderJpaRepository extends JpaRepository<OrderJpaEntity, UUID>. All of Spring Data lives inside the adapter; the application never sees it. The mapper just translates between the domain shape and the entity shape.
Inbound adapter: REST calls the port
And on the opposite side, the controller, which calls the inbound port:
package com.acme.orders.adapter.in.web;
import com.acme.orders.application.port.in.PlaceOrderUseCase;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.net.URI;
@RestController
@RequestMapping("/orders")
public class OrderController {
private final PlaceOrderUseCase placeOrder; // depends on inbound PORT
public OrderController(PlaceOrderUseCase placeOrder) {
this.placeOrder = placeOrder;
}
@PostMapping
public ResponseEntity<Void> place(@RequestBody PlaceOrderRequest req) {
var cmd = OrderWebMapper.toCommand(req); // DTO -> command
var id = placeOrder.placeOrder(cmd);
return ResponseEntity.created(URI.create("/orders/" + id.value())).build();
}
}
Now see the whole flow at a glance:
HTTP → controller → inbound port → use-case service → domain + outbound ports → adapters → DB/payment.
Dependencies at every seam point inward. Every import arrow points toward the core. That's it.
Testability: the concrete payoff
Imagine you want to test a component's engine. If the engine is welded to the whole chassis, you have to start the entire car. But if it's bolted on with a few standard bolts, you can test it on the bench and attach a simulator in place of the wheels. Ports are those "standard bolts"; mocks are the simulator.
// Pure unit test — no Spring, no DB, milliseconds to run.
class PlaceOrderServiceTest {
@Test
void chargesThenSaves() {
var repo = mock(OrderRepository.class);
var pay = mock(PaymentGateway.class);
when(repo.save(any())).thenAnswer(inv -> inv.getArgument(0));
var service = new PlaceOrderService(repo, pay);
var cmd = new PlaceOrderUseCase.Command("cust-1",
List.of(new PlaceOrderUseCase.Command.Item("SKU", 2, 500)));
service.placeOrder(cmd);
verify(pay).charge(any(), eq(1000L)); // 2 * 500
verify(repo).save(argThat(o -> o.status() == OrderStatus.PAID));
}
}
No @SpringBootTest, no Testcontainers, no HTTP. One word: a mock is a fake stand-in for an interface whose behavior you dictate and which you can later ask "was this method called?". Because the service depends only on interfaces you own, you substitute fakes trivially.
Adapters get their own narrow tests: @DataJpaTest (with Testcontainers — a real database in a container) for the persistence adapter, and @WebMvcTest for the controller. Domain entities get plain JUnit. Each test targets exactly one concern and runs fast — a direct consequence of the architecture, not an add-on.
Enforcing the rule with ArchUnit
ArchUnit is a library that lets you write tests about the structure of your code — for example, "no class in the domain package may depend on Spring." These tests run in the same CI, and if someone breaks the rule, the build goes red:
@AnalyzeClasses(packages = "com.acme.orders")
class ArchitectureTest {
@ArchTest
static final ArchRule domain_depends_on_nothing_external =
classes().that().resideInAPackage("..domain..")
.should().onlyDependOnClassesThat()
.resideInAnyPackage("..domain..", "java..", "javax.validation..");
@ArchTest
static final ArchRule no_framework_in_domain =
noClasses().that().resideInAPackage("..domain..")
.should().dependOnClassesThat()
.resideInAnyPackage("org.springframework..", "jakarta.persistence..");
}
This turns the architecture from a wiki page (that nobody reads) into a build gate. Without it, the first tired engineer under deadline pressure will @Autowired a repository into the domain and nobody will notice for six months — until writing one simple test suddenly requires spinning up all of Spring.
Common pitfalls & gotchas
- Re-using the domain as the JPA entity. The most common shortcut, and it quietly destroys the whole benefit — Hibernate's needs now dictate your model. If you must merge them for a small app, do it knowingly, not by default.
- DTOs leaking into the domain. If
PlaceOrderServiceaccepts a@RequestBody-annotated web DTO, the web framework has reached the core. Map to aCommandat the boundary. - Anemic ports that mirror the ORM. An outbound port with
findByStatusAndCustomerIdOrderByCreatedAt(...)is just Spring Data in disguise. Ports should express domain intent (ordersAwaitingPayment()), not query mechanics. - Leaky abstractions. A port returning
Page<Entity>or throwingSQLExceptionleaks the adapter. Ports speak the domain's language, including domain-specific exceptions. - Mapping fatigue. Domain↔entity↔DTO is three shapes and two mappers. Use MapStruct (a library that auto-generates mapper code) to cut boilerplate, but never let the mapper live in the domain.
- Transaction placement.
@Transactionalbelongs on the use-case service (the boundary of a unit of work), not on the domain (which knows nothing of transactions) nor scattered across repositories. - Circular ports. Inbound and outbound ports must not reference each other's DTOs. Keep command/response models on the inbound side and domain objects on the outbound side.
- And finally: over-modularizing a CRUD app. Ports and mappers around a service that just forwards to a repository add cost with no benefit.
When it's over-engineering
Be honest about this — it's precisely the thing that separates a senior engineer from everyone else. Full hexagonal is not free: more classes, more mappers, more indirection, slower onboarding for a new team member.
It pays off when: the domain is genuinely complex (rich invariants, many rules); the app is long-lived; you have multiple driving/driven adapters (REST + Kafka + batch, or Postgres today + something else later); or testability of business rules is a hard requirement.
It's over-engineering when: the service is essentially CRUD or a thin data pipe; the team is small and the app short-lived; there is no real domain logic (validation + persistence is not a domain). For those, a disciplined layered app — even sharing entities — ships faster and is perfectly maintainable.
Reach for the ports when a technical dependency actually threatens to distort or leak into a non-trivial domain. Architecture is a cost you pay to buy optionality; don't buy optionality you'll never exercise.
Interview Questions
Source-code dependencies point only inward, toward the domain; the domain depends on nothing external. It matters because it isolates volatile technical decisions (DB, framework, transport) from stable business rules, so the rules can be tested in isolation and technology can change without rippling through the core.
Driving/inbound ports are interfaces the application exposes so external actors can drive it (a use case called by a controller). Driven/outbound ports are interfaces the application requires to reach external systems (a repository or gateway implemented by an adapter). Inbound adapters call ports; outbound adapters implement ports.
They're the same core idea — inward-pointing dependencies around a framework-free domain — at different zoom levels. Clean adds an explicit use-case ring and named cross-boundary request/response models; Hexagonal stresses the driving/driven symmetry. In practice you blend them: hexagonal port/adapter vocabulary + Clean's use-case layer.
At the outbound port: the interface is owned by the domain, but implemented in infrastructure. The compile-time arrow from database to domain (via implements OrderRepository) is the inverted dependency — infrastructure depends on domain, not the reverse.
It's not duplication of behavior, only of shape. Merging them forces Hibernate's constraints (no-arg constructor, mutable state, proxy-friendly design, schema-driven fields) onto your business model, producing an anemic, persistence-shaped domain. Separation costs a mapper but keeps invariants and rules pure and testable. For a trivial CRUD app the merge is a legitimate, deliberate shortcut.
The business/service layer depends downward on the persistence layer, so the domain transitively knows the database and ORM. Hexagonal inverts that edge: persistence depends on a domain-owned port, freeing the domain of all infrastructure knowledge.
On the application/use-case service, which defines the unit of work. The domain must not know transactions exist — that's an infrastructure concern. Putting it in the domain leaks Spring into the core and couples business rules to a persistence lifecycle.
It's the ORM query API leaking through the port — the abstraction mirrors Spring Data instead of expressing domain intent. Ports should read like the ubiquitous language (ordersAwaitingPayment()); how that maps to a query is the adapter's private business.
The controller (an inbound adapter) now depends on an outbound port and bypasses the application layer, so business orchestration, invariants, and the transaction boundary are all skipped. Correctness (no payment, no state transition) and the architecture (inbound adapter reaching an outbound port) are both violated. Controllers may only depend on inbound ports.
Business logic depends only on interfaces you own, so use cases are tested with mocks/fakes — no Spring context, no DB — in milliseconds. Adapters get narrow slice tests (@DataJpaTest, @WebMvcTest); the domain gets plain JUnit. Each test targets one concern, a direct structural consequence.
var order = new Order(new OrderId(UUID.randomUUID()), "c1",
List.of(new OrderLine("SKU", 1, 100)));
order.markPaid();
order.markPaid();
It throws IllegalStateException("only NEW orders can be paid, was PAID") on the second call. The invariant lives in the domain method, so illegal state transitions fail fast regardless of caller — exactly the guarantee you want from a rich model, and something an anemic setter-based entity would silently allow.
Prefer compile-time enforcement: split domain/application/adapter into separate Gradle/Maven modules so the domain physically cannot see Spring. Where modules aren't practical, add an ArchUnit test asserting ..domain.. depends on nothing framework-related, and run it in CI. Code review alone always erodes under deadline pressure.
When there's no real domain logic — a CRUD service or thin data pipe — or the app is small and short-lived with a single adapter on each side. The ports, mappers, and indirection then buy optionality you'll never use. Reach for full ports-and-adapters when a non-trivial domain is genuinely threatened by technical coupling, or you have multiple/likely-changing adapters.
Yes. The domain produces plain domain-event objects (OrderPlaced) and either returns them or records them on the aggregate. An outbound port (DomainEventPublisher) is defined in the domain and implemented by an adapter (Kafka, Spring ApplicationEventPublisher). The domain stays free of the messaging technology.
Layers alone aren't hexagonal — direction is what matters. Classic layers point down into persistence, so the domain depends on the DB. Hexagonal inverts the persistence edge via a domain-owned port. If your service imports a Spring Data repository directly, you have layers, not ports and adapters, and you don't get the isolation or testability benefits.
- Layered, Hexagonal, and Clean are three expressions of one rule: dependencies point inward, toward the domain; the domain depends on no one.
- Classic layered breaks that rule because business depends on persistence; Hexagonal inverts that edge with a domain-owned port (Dependency Inversion made physical).
- The inbound port is called by the outside; the outbound port is defined by the domain and implemented by infrastructure. Adapters wire both to the real world.
- Keep the JPA entity and the domain entity separate; put a mapper between them so Hibernate's needs never leak into the business.
@Transactionalsits on the use case, not the domain. Ports speak the domain's language, not the query language.- Enforce the rule with module boundaries and ArchUnit; code review erodes.
- And the biggest judgment call: this architecture buys optionality — for a small CRUD app, don't buy optionality you'll never exercise.