Definition
- Domain – entities, value objects, and domain services holding business rules; depends on nothing external.
- Inbound ports – interfaces defining use cases (e.g.,
ApproveLoanUseCase); driven by adapters like REST controllers. - Outbound ports – interfaces defining what the domain needs (e.g.,
LoanRepository,CreditBureauPort); implemented by outbound adapters. - Adapters – concrete implementations: REST controller (inbound), JPA repository (outbound), HTTP client (outbound).
- Dependency rule – all dependencies point inward; the domain owns the interfaces; adapters depend on and implement them.
- Not a deployment choice – hexagonal is a code-organization style applied inside a monolith or inside each microservice.
Picture the loan-approval department at National Bank of India as the heart of the business. Around it are standardized slots (ports): one slot accepts applications whether they come by web, mobile, or phone. Another slot sends records out whether to Oracle or PostgreSQL. The web form, the mobile app, and the Oracle connector are all adapters that plug into these slots. If the bank switches databases, you unplug one adapter and plug in another -- the loan rules in the heart are not touched.
Understanding the Concepts
What is the Hexagonal architecture?
Hexagonal (Ports and Adapters) places domain logic at the center, defines ports (interfaces) for everything it needs or offers, and attaches adapters for specific technologies. The dependency rule is that everything points inward.
How do ports and adapters work?
// OUTBOUND PORT: domain declares what it needs
package com.bank.domain.port;
import java.math.BigDecimal;
public interface LoanRepository {
void save(Loan loan);
Loan findById(String loanId);
}
public interface CreditBureauPort {
int fetchScore(String customerId);
}
// INBOUND PORT: domain declares what it offers
package com.bank.domain.port;
import java.math.BigDecimal;
public interface ApproveLoanUseCase {
LoanDecision approve(String customerId, BigDecimal amount);
}
// DOMAIN SERVICE: depends only on ports (interfaces)
package com.bank.domain.service;
import com.bank.domain.port.*;
import java.math.BigDecimal;
public class LoanApprovalService implements ApproveLoanUseCase {
private final CreditBureauPort creditBureau;
private final LoanRepository loanRepo;
public LoanApprovalService(CreditBureauPort creditBureau, LoanRepository loanRepo) {
this.creditBureau = creditBureau;
this.loanRepo = loanRepo;
}
@Override
public LoanDecision approve(String customerId, BigDecimal amount) {
int score = creditBureau.fetchScore(customerId);
if (score < 700) return LoanDecision.rejected("Score below 700");
Loan loan = new Loan(customerId, amount);
loanRepo.save(loan);
return LoanDecision.approved(loan);
}
}
// OUTBOUND ADAPTER: implements the port with real technology
package com.bank.adapter.out;
import com.bank.domain.port.LoanRepository;
import org.springframework.data.jpa.repository.JpaRepository;
public interface JpaLoanRepository extends JpaRepository<Loan, String>, LoanRepository {
// Spring Data implements save() and findById() automatically
}
// INBOUND ADAPTER: REST controller drives the domain through the inbound port
package com.bank.adapter.in;
import com.bank.domain.port.ApproveLoanUseCase;
import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal;
@RestController
@RequestMapping("/api/loans")
public class LoanController {
private final ApproveLoanUseCase approveUseCase;
public LoanController(ApproveLoanUseCase approveUseCase) {
this.approveUseCase = approveUseCase;
}
@PostMapping
public LoanDecision apply(@RequestParam String customerId,
@RequestParam BigDecimal amount) {
return approveUseCase.approve(customerId, amount);
}
}
How do you test the domain in isolation?
// Unit test with in-memory fakes: zero database, zero Spring
class LoanApprovalServiceTest {
@Test
void rejectsLowScore() {
CreditBureauPort fakeBureau = id -> 600; // always returns 600
LoanRepository fakeRepo = new InMemoryLoanRepository();
var service = new LoanApprovalService(fakeBureau, fakeRepo);
LoanDecision result = service.approve("CUST-1001", new BigDecimal("5000000"));
assertEquals("REJECTED", result.getStatus());
}
}
Expected output:
Test passed: rejectsLowScore
Why is hexagonal a style layered ON TOP of microservices, not an alternative?
Microservices and monolith are deployment choices (how many units, how they communicate). Hexagonal is about code organization inside one unit. You do not choose between them; you apply hexagonal inside each service.
What are common mistakes?
// WRONG: domain imports Spring JPA
package com.bank.domain.service;
import org.springframework.data.jpa.repository.JpaRepository; // BREAKS the architecture
// CORRECT: domain imports only its own port interface
package com.bank.domain.service;
import com.bank.domain.port.LoanRepository; // domain owns this interface
When should you NOT use hexagonal?
For trivial CRUD services where the domain logic is just pass-through, hexagonal adds ceremony without benefit. Apply it where the domain is rich and valuable -- loan approval, settlement, fraud scoring.
What should a senior engineer know?
- The ports belong to the domain package, which enforces the inward dependency direction at compile time.
- If your domain imports JPA, Spring, or HTTP classes, you have broken the architecture.
- You can enforce this with ArchUnit tests in CI.
- The benefits are testability (test domain with fakes), technology independence (swap DB by writing a new adapter), and clarity (business rules untangled from framework annotations).
How do you enforce the dependency rule with ArchUnit?
ArchUnit tests in CI prevent accidental imports of framework classes into the domain package. If a developer adds a Spring annotation to a domain entity, the build fails.
package com.bank.architecture;
import com.tngtech.archunit.core.importer.ClassFileImporter;
import com.tngtech.archunit.lang.ArchRule;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
public class HexagonalArchTest {
// @Test
public void domainMustNotDependOnSpringOrJpa() {
var classes = new ClassFileImporter()
.importPackages("com.bank");
ArchRule rule = noClasses()
.that().resideInAPackage("..domain..")
.should().dependOnClassesThat()
.resideInAnyPackage(
"org.springframework..",
"javax.persistence..",
"jakarta.persistence..");
rule.check(classes);
}
}
// Build output when a developer adds @Entity to a domain class:
// ArchUnit test FAILED:
// com.bank.domain.model.Loan imports jakarta.persistence.Entity
// Domain must not depend on JPA!
// Fix: move @Entity to the adapter or use a separate JPA entity in adapter.out
Visual Overview
Hexagonal Architecture
Inbound Adapters Outbound Adapters
┌─────────────┐ ┌─────────────────┐
│ REST │──┐ ┌───►│ JPA Repository │
│ Controller │ │ ┌───────────┐ │ └─────────────────┘
└─────────────┘ ├──►│ │───┤
┌─────────────┐ │ │ DOMAIN │ │ ┌─────────────────┐
│ Kafka │──┘ │ │ └───►│ HTTP Client │
│ Listener │ │ ports = │ │ (Credit Bureau) │
└─────────────┘ │ interfaces│ └─────────────────┘
└───────────┘
Dependencies point INWARD
Domain depends on NOTHING external
Package Structure
com.bank/
├── domain/
│ ├── model/ (entities, value objects)
│ ├── port/ (interfaces: inbound + outbound)
│ └── service/ (domain services)
├── adapter/
│ ├── in/ (REST controller, Kafka listener)
│ └── out/ (JPA repo, HTTP client)
Key Takeaway
Hexagonal architecture puts the business rules at the center and surrounds them with ports (interfaces) and adapters (concrete technology implementations). The dependency rule is that everything points inward: the domain depends on nothing external and never imports Spring, JPA, or HTTP classes. Inbound adapters like REST controllers drive the domain through inbound ports, and the domain reaches outward through outbound ports implemented by outbound adapters like JPA repositories.
The payoff is testability and technology independence. At National Bank of India, Pankaj could unit-test the loan approval rule with in-memory fakes, no database and no web server, and when the bank migrated its database the team only wrote a new adapter while the approval rule stayed untouched.
The key point is that hexagonal is not an alternative to microservices or a monolith. Microservices and monolith are deployment choices; hexagonal is code organization inside one unit. You apply hexagonal inside each service where the domain logic is rich, and keep trivial CRUD services simpler.
Remember one sentence: put the business rules in the center with technology-agnostic ports, plug concrete adapters onto those ports with dependencies pointing inward, and you can swap any technology by writing a new adapter without touching the domain.
---