Hexagonal Architecture: Escaping the Framework Trap with Ports and Adapters
Your Spring Boot application has become a hostage to its own framework. Business logic is scattered across controllers, services are riddled with JPA annotations, and swapping databases would require rewriting half the codebase. This isn’t a design flaw—it’s the inevitable result of letting infrastructure concerns bleed into your domain.
You’ve seen it happen. A clean service class starts with a simple @Transactional annotation. Then someone adds @Cacheable because performance matters. A few sprints later, the method signature includes Pageable parameters, and suddenly your “business logic” knows intimate details about Spring Data’s pagination strategy. The class that was supposed to encapsulate your domain rules now imports from fifteen different Spring packages.
The real trap isn’t Spring itself—it’s the seductive convenience that erodes boundaries one annotation at a time. Every shortcut that saved you twenty minutes during initial development now costs you hours during testing, debugging, and that inevitable moment when requirements change and you realize your order processing logic is welded to PostgreSQL-specific queries.
Hexagonal Architecture offers a way out. By treating your framework as just another replaceable adapter—no more privileged than a database driver or messaging queue—you reclaim ownership of your domain. Your business rules become testable in milliseconds without spinning up containers. Your core logic becomes portable, indifferent to whether it’s invoked by a REST controller, a CLI command, or an event consumer.
But getting there from an existing codebase requires more than understanding the theory. It demands a practical migration strategy that acknowledges you can’t stop delivering features while you refactor.
The Real Cost of Framework Coupling
Every codebase tells a story. In the beginning, the framework was a productivity multiplier—Rails conventions, Spring annotations, or Express middleware let you ship features at remarkable speed. But somewhere between launch and year three, the story changed. Your business logic now speaks fluent ORM. Your domain models inherit from framework base classes. Your service layer imports HTTP request objects. The framework didn’t just support your application—it colonized it.

This isn’t a failure of discipline. It’s the natural entropy of software under pressure.
The Gradual Entanglement
Framework coupling rarely happens through a single bad decision. It accumulates through hundreds of reasonable shortcuts. A developer needs the current user ID deep in a calculation, so they pass the request context down. A complex query requires raw SQL, so the repository starts leaking database dialects into business rules. A third-party API integration deadline looms, so the HTTP client gets instantiated directly in the domain service.
Each decision makes sense in isolation. The cumulative effect is a codebase where changing your ORM requires rewriting business logic, where upgrading framework versions demands months of testing, and where the boundary between “what the system does” and “how it does it” has completely dissolved.
The Testing Tax
The most insidious cost is what happens to your test suite. When business logic depends on framework infrastructure, every unit test becomes an integration test in disguise. Testing a pricing calculation requires spinning up a database connection. Validating an order workflow needs mock HTTP servers. Running the full suite takes twenty minutes instead of twenty seconds.
Teams adapt. They write fewer tests. They skip edge cases. They defer refactoring because the safety net has too many holes. The codebase becomes increasingly brittle, and the team becomes increasingly afraid to touch it.
Symptoms You Already Recognize
You know you have this problem if:
- Adding a new persistence layer (caching, a different database) requires touching business logic files
- Your domain entities import anything from the framework namespace
- Test setup files are longer than the tests themselves
- “Mocking the world” is a phrase your team uses without irony
- Framework upgrades are multi-sprint projects requiring extensive regression testing
- New developers need to understand the framework before understanding your business domain
These symptoms compound over time. What starts as a minor inconvenience becomes architectural debt with interest rates that would make a loan shark blush.
💡 Pro Tip: Track how long your test suite takes and how much setup code each test requires. These metrics reveal coupling that code reviews miss.
The good news: there’s a systematic approach to reclaiming your domain logic from framework dependencies. It starts with understanding hexagonal architecture’s core mental model.
Hexagonal Architecture: The Mental Model
When Alistair Cockburn introduced hexagonal architecture in 2005, he wasn’t proposing a new folder structure. He was solving a fundamental design problem: applications that couldn’t be tested without their infrastructure, and infrastructure changes that rippled through business logic. His insight was deceptively simple—treat your application as a hexagon with symmetric ports, where the shape itself is irrelevant but the symmetry is everything.

The hexagon metaphor serves a specific purpose. Unlike layered diagrams that imply top-to-bottom hierarchy, a hexagon has no privileged direction. Every edge is equivalent. This visual reminder forces you to recognize that a database connection and an HTTP endpoint are architecturally identical—both are external concerns that your domain shouldn’t know about.
Ports: Contracts Owned by the Domain
A port is an interface that defines how the outside world interacts with your application, or how your application interacts with the outside world. The critical insight: ports belong to the domain, not to the infrastructure.
When your domain needs to persist an order, it doesn’t define “how to talk to PostgreSQL.” It defines “what it means to store an order”—a OrderRepository interface with methods like save() and findById(). This interface lives in your domain layer, expressed in domain language, with no knowledge of SQL, connection pools, or transaction managers.
This ownership model inverts the typical relationship. Instead of your domain depending on database abstractions designed by infrastructure teams, your infrastructure implements contracts designed by domain experts. The domain dictates what it needs; adapters figure out how to provide it.
Adapters: Implementations Owned by Infrastructure
Adapters are concrete implementations that satisfy port contracts. A PostgresOrderRepository implements OrderRepository. A StripePaymentGateway implements PaymentProcessor. Adapters live outside the hexagon, in the infrastructure layer, and they handle all the messy details of talking to real systems.
This separation creates a clean boundary. Your domain tests never touch a database. Your PostgreSQL adapter can be swapped for DynamoDB without changing a single line of business logic. Framework upgrades stay contained in their adapters rather than metastasizing through your codebase.
Primary vs. Secondary: Who Starts the Conversation
Hexagonal architecture distinguishes between two types of adapters based on who initiates communication.
Primary (driving) adapters call into your application. HTTP controllers, CLI handlers, message consumers, and GraphQL resolvers are primary adapters. They receive external stimuli and translate them into domain operations.
Secondary (driven) adapters are called by your application. Database repositories, payment gateways, email services, and external API clients are secondary adapters. Your domain initiates these conversations when it needs something from the outside world.
This distinction matters for dependency direction. Primary adapters depend on your domain—they call into it. Secondary adapters are depended upon by your domain through port interfaces—the domain defines what it needs, and adapters provide it.
The Dependency Rule: All Arrows Point Inward
The architectural constraint that makes hexagonal architecture work is absolute: dependencies flow from the outside toward the center. Adapters depend on ports. Ports live in the domain. The domain depends on nothing external.
This rule isn’t arbitrary. It guarantees that your most valuable code—the business logic that differentiates your product—remains isolated from the most volatile code: frameworks, databases, and external services that change frequently and unpredictably.
With this mental model established, the next question becomes practical: how do you actually define ports that represent clean contracts?
Defining Ports: Your Domain’s API Contract
Ports are interfaces that define how your domain communicates with the outside world. They form a boundary—a contract written entirely in your domain’s language, with no mention of HTTP, SQL, or message queues. This distinction separates hexagonal architecture from the typical “add an interface for everything” approach that clutters enterprise codebases.
The key insight is that ports belong to the domain. They’re not technical abstractions imposed from outside—they’re declarations of what your business logic needs and what it offers. When you design ports correctly, they become living documentation of your system’s capabilities and dependencies.
Input Ports: Exposing Business Capabilities
Input ports represent what your application can do. They’re the entry points for external actors—whether REST controllers, CLI commands, or message consumers. Each input port should map to a distinct business capability, not a CRUD operation.
public interface OrderManagementPort { OrderConfirmation placeOrder(PlaceOrderCommand command); void cancelOrder(OrderId orderId, CancellationReason reason); OrderStatus checkOrderStatus(OrderId orderId);}Notice the language here. There’s no createOrder() or updateOrderStatus()—those are database operations masquerading as business logic. Instead, placeOrder() captures intent. The PlaceOrderCommand contains everything needed to fulfill that intent: customer details, line items, shipping preferences. This command object serves as a data transfer mechanism that crosses the boundary cleanly, carrying only the information the domain needs.
The implementation of this port—your use case class—orchestrates the domain:
public class OrderManagementUseCase implements OrderManagementPort { private final OrderRepository orders; private final InventoryPort inventory; private final PaymentPort payments; private final NotificationPort notifications;
@Override public OrderConfirmation placeOrder(PlaceOrderCommand command) { Order order = Order.create(command.customerId(), command.lineItems());
inventory.reserve(order.getReservationRequest()); PaymentResult payment = payments.authorize(order.getPaymentRequest()); order.confirmPayment(payment.transactionId());
orders.save(order); notifications.sendOrderConfirmation(order.getCustomerId(), order.getId());
return new OrderConfirmation(order.getId(), order.getEstimatedDelivery()); }}This use case reads like a business process description. A developer unfamiliar with the codebase can understand the order placement flow without knowing anything about the underlying infrastructure.
Output Ports: Defining What You Need, Not How You Get It
Output ports represent what your domain requires from the outside world. They’re defined by your domain’s needs, implemented by adapters that handle the messy reality of databases, APIs, and message brokers. The domain dictates the contract; the infrastructure conforms to it.
public interface InventoryPort { ReservationResult reserve(ReservationRequest request); void release(ReservationId reservationId); AvailabilityStatus checkAvailability(ProductId productId, Quantity quantity);}This interface says nothing about whether inventory lives in a PostgreSQL database, a Redis cache, or an external warehouse management system. That’s the adapter’s concern. Your domain simply declares: “I need to reserve inventory, release it, and check availability.”
Output ports also handle cross-cutting concerns like persistence. A repository port defines what persistence operations the domain requires, expressed in terms the domain understands—not in terms of tables, documents, or cache keys.
💡 Pro Tip: Name your output ports after the capability they provide (
InventoryPort,PaymentPort), not the technology that implements them (InventoryRepositoryInterface,PaymentGatewayWrapper). When you read the use case code, you should understand the business flow without knowing the infrastructure.
Avoiding the Interface-Per-Class Anti-Pattern
A common mistake is creating a one-to-one mapping between interfaces and implementations. You end up with UserService and UserServiceImpl, OrderValidator and OrderValidatorImpl—interfaces that exist only to satisfy a dependency injection framework. These interfaces add indirection without adding value.
Ports are different. They exist because multiple implementations are genuinely possible:
PaymentPortmight have aStripePaymentAdapterin production and aFakePaymentAdapterin testsNotificationPortmight have anSesEmailAdapter, anInMemoryNotificationAdapterfor testing, and aLoggingNotificationAdapterfor local developmentInventoryPortmight start with aDatabaseInventoryAdapterand later migrate to anExternalWarehouseAdapterwhen integrating with a third-party system
If you can’t imagine a second implementation, you probably don’t need a port. Internal domain services that coordinate business logic rarely need abstraction—they’re already in the domain layer, speaking the domain’s language.
public class PricingService { public Money calculateTotal(List<LineItem> items, DiscountCode discount) { // Pure domain logic, no external dependencies Money subtotal = items.stream() .map(LineItem::getPrice) .reduce(Money.ZERO, Money::add); return discount.applyTo(subtotal); }}This PricingService has no external dependencies. It performs pure computation on domain objects. Adding an interface would only introduce unnecessary complexity without enabling any meaningful flexibility.
With ports defined, you’ve established a stable contract. Your domain logic can evolve independently of whether you’re using Hibernate or jOOQ, Kafka or RabbitMQ. The next step is building adapters that honor these contracts while handling the inevitable complexity of real-world infrastructure.
Building Adapters That Stay in Their Lane
Adapters are where hexagonal architecture meets reality. They translate between the outside world’s protocols and your domain’s language. A well-designed adapter does exactly two things: translate incoming requests into domain calls, and translate domain responses back out. Nothing more.
REST Controllers as Driving Adapters
Your REST controller shouldn’t contain business logic. It receives HTTP requests, converts them to domain objects, calls a port, and formats the response. Here’s what that looks like:
@RestController@RequestMapping("/api/orders")public class OrderController {
private final OrderService orderService;
public OrderController(OrderService orderService) { this.orderService = orderService; }
@PostMapping public ResponseEntity<OrderResponse> createOrder(@RequestBody CreateOrderRequest request) { CreateOrderCommand command = new CreateOrderCommand( request.getCustomerId(), request.getItems().stream() .map(item -> new OrderItem(item.getProductId(), item.getQuantity())) .toList() );
Order order = orderService.createOrder(command);
return ResponseEntity .status(HttpStatus.CREATED) .body(OrderResponse.from(order)); }}Notice what’s missing: no validation rules about minimum order quantities, no business logic about inventory checks, no decisions about pricing. The controller translates HTTP concepts (request bodies, status codes, headers) into domain concepts (commands, entities) and back again. Spring annotations stay at the boundary—your domain never sees @RequestBody or ResponseEntity.
This separation means your domain logic remains testable without spinning up a web server. It also means you could swap Spring MVC for another framework without touching your business rules.
Repository Adapters: Keeping JPA at the Edge
The persistence adapter implements your domain’s repository interface while hiding all ORM details. This separation prevents JPA annotations from contaminating your domain entities:
@Repositorypublic class JpaOrderRepository implements OrderRepository {
private final SpringDataOrderRepository springDataRepository; private final OrderMapper mapper;
public JpaOrderRepository(SpringDataOrderRepository springDataRepository, OrderMapper mapper) { this.springDataRepository = springDataRepository; this.mapper = mapper; }
@Override public Order save(Order order) { OrderEntity entity = mapper.toEntity(order); OrderEntity saved = springDataRepository.save(entity); return mapper.toDomain(saved); }
@Override public Optional<Order> findById(OrderId id) { return springDataRepository.findById(id.getValue()) .map(mapper::toDomain); }}The OrderEntity lives in the adapter layer with all its JPA annotations. The domain Order remains a clean object focused on business behavior. The mapper handles the translation, keeping both sides ignorant of each other’s implementation details.
This approach does require maintaining two object hierarchies, but the investment pays dividends. Your domain objects can use immutable value objects, rich behavior, and whatever structure makes business sense—without fighting JPA’s expectations around default constructors and mutable fields.
💡 Pro Tip: Create separate entity classes for persistence even when they mirror domain objects closely. The apparent duplication pays off when schema changes don’t require domain changes, and vice versa.
External Service Adapters: Taming Third-Party APIs
Third-party integrations are notoriously unstable. API versions change, rate limits shift, authentication mechanisms evolve. Wrapping external services behind your own interface protects your domain from this volatility:
@Componentpublic class StripePaymentGateway implements PaymentGateway {
private final StripeClient stripeClient;
public StripePaymentGateway(StripeClient stripeClient) { this.stripeClient = stripeClient; }
@Override public PaymentResult charge(PaymentRequest request) { try { PaymentIntent intent = stripeClient.paymentIntents().create( PaymentIntentCreateParams.builder() .setAmount(request.getAmount().toCents()) .setCurrency(request.getCurrency().getCode()) .setCustomer(request.getCustomerReference()) .build() );
return PaymentResult.success(intent.getId()); } catch (StripeException e) { return PaymentResult.failed(e.getMessage()); } }}When Stripe releases a new API version or you switch to a different payment provider, only this adapter changes. Your domain continues speaking in terms of PaymentRequest and PaymentResult—concepts you control.
External service adapters also give you a natural place to handle retry logic, circuit breakers, and fallback strategies. These infrastructure concerns belong in the adapter, not scattered throughout your domain code.
The Single Responsibility of Adapters
Every adapter follows the same pattern: translate and delegate. Incoming adapters translate external requests into domain calls. Outgoing adapters translate domain calls into infrastructure operations. When you find business logic creeping into an adapter, that’s a signal to push it back into the domain.
Watch for these warning signs that an adapter has overstepped:
- Conditional logic based on business rules rather than technical concerns
- Multiple domain service calls orchestrated together
- Data transformations that reflect business decisions rather than format conversions
This discipline creates a powerful testing advantage. You can verify your domain logic with fast, isolated unit tests while adapter tests focus purely on translation correctness and infrastructure integration. Mock the ports when testing domain logic; test adapters against real infrastructure or realistic fakes.
With adapters properly isolated, you unlock the architecture’s real payoff: a testing strategy that catches bugs faster and gives you confidence to refactor aggressively.
Testing Strategy: The Real Payoff
Everything we’ve built so far—ports, adapters, clean domain boundaries—culminates here. The testing story is where hexagonal architecture transforms from an interesting idea into a competitive advantage. When your domain logic runs in milliseconds without spinning up containers, databases, or HTTP servers, your entire development workflow accelerates. But this isn’t just about speed. It’s about confidence, maintainability, and the ability to refactor aggressively without fear.
Unit Testing Domain Logic: Pure and Fast
Your domain layer has no framework dependencies. None. This means testing it requires nothing but your test framework and assertions. No mocking frameworks wrestling with final classes. No test slices loading partial Spring contexts. Just plain objects exercising business rules.
class OrderServiceTest {
private OrderService orderService; private InMemoryOrderRepository orderRepository; private InMemoryInventoryPort inventoryPort;
@BeforeEach void setUp() { orderRepository = new InMemoryOrderRepository(); inventoryPort = new InMemoryInventoryPort(); orderService = new OrderService(orderRepository, inventoryPort); }
@Test void shouldRejectOrderWhenInsufficientInventory() { inventoryPort.setAvailableStock("SKU-12345", 5);
var result = orderService.placeOrder( new CustomerId("cust-001"), new OrderItem("SKU-12345", 10) );
assertThat(result.isFailure()).isTrue(); assertThat(result.getError()).isEqualTo(OrderError.INSUFFICIENT_INVENTORY); assertThat(orderRepository.findAll()).isEmpty(); }}This test executes in under 10 milliseconds. No Spring context. No database. No network calls. You’re testing business rules in isolation, exactly as they’ll behave in production. The implications compound: you can run hundreds of these tests in the time it takes to boot a single Spring context.
In-Memory Adapters: The Integration Test Sweet Spot
In-memory adapters aren’t just test utilities—they’re legitimate implementations of your ports that happen to store state in HashMaps instead of Postgres. They implement the same interface contract, maintain referential integrity, and can even simulate edge cases like concurrent access patterns.
public class InMemoryOrderRepository implements OrderRepository {
private final Map<OrderId, Order> orders = new ConcurrentHashMap<>();
@Override public void save(Order order) { orders.put(order.getId(), order); }
@Override public Optional<Order> findById(OrderId id) { return Optional.ofNullable(orders.get(id)); }
@Override public List<Order> findByCustomer(CustomerId customerId) { return orders.values().stream() .filter(order -> order.getCustomerId().equals(customerId)) .toList(); }
// Test helper methods public void clear() { orders.clear(); }
public List<Order> findAll() { return new ArrayList<>(orders.values()); }}These in-memory implementations run your complete use cases—multiple services coordinating through ports—without external dependencies. You catch integration bugs while maintaining sub-second test execution. The key insight: most bugs live in the coordination logic between components, not in the database queries themselves. In-memory adapters let you stress-test that coordination exhaustively.
When Real Databases Matter
Reserve database tests for verifying your adapter implementations, not your business logic. You’re testing that your SQL is correct, your transactions behave properly, and your ORM mappings work. This is infrastructure verification, not domain validation.
@DataJpaTest@Testcontainersclass PostgresOrderRepositoryTest {
@Container static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15");
@Autowired private PostgresOrderRepository repository;
@Test void shouldPersistAndRetrieveOrder() { var order = Order.create( new CustomerId("cust-001"), List.of(new OrderItem("SKU-12345", 2)) );
repository.save(order); var retrieved = repository.findById(order.getId());
assertThat(retrieved).isPresent(); assertThat(retrieved.get().getItems()).hasSize(1); }}These tests are slower—seconds instead of milliseconds—but you need far fewer of them. One adapter test per query pattern suffices. You’re not retesting business logic here; you’re verifying that the adapter faithfully implements the port contract against real infrastructure. Once that contract is proven, trust it.
💡 Pro Tip: Run domain unit tests on every file save. Run adapter integration tests before commits. Run full end-to-end tests in CI. This layered approach catches bugs early without destroying your feedback loop.
The Restructured Test Pyramid
Traditional test pyramids focus on unit vs. integration vs. end-to-end. Hexagonal architecture reframes this around architectural boundaries, giving each layer a distinct purpose:
- Domain tests (fastest, most numerous): Pure business logic with in-memory ports. These form your safety net for refactoring and cover every edge case in your business rules.
- Adapter tests (medium speed, moderate count): Each adapter against its real infrastructure. These prove your integration code works but don’t need exhaustive scenario coverage.
- System tests (slowest, fewest): Full application verifying adapters wire together correctly. Smoke tests that confirm deployment configuration, not business logic.
This structure means 80% of your tests run without any infrastructure. Refactoring becomes safe because you have fast, comprehensive feedback on business rules—the code that actually differentiates your product. When a domain test fails, you know exactly where to look. When an adapter test fails, you know it’s a mapping or query issue, not a business logic problem.
The confidence this testing strategy provides makes the migration effort worthwhile. Teams report running their full domain test suite in under 30 seconds, enabling genuine test-driven development workflows. Speaking of which, you don’t need to rewrite everything at once.
Incremental Migration: Strangling the Monolith
The worst way to adopt hexagonal architecture is a big-bang rewrite. The second-worst way is trying to migrate everything at once. Successful migrations happen one bounded context at a time, with feature development continuing uninterrupted. This approach, inspired by Martin Fowler’s strangler fig pattern, lets you gradually replace legacy code while maintaining production stability throughout the transition.
Pick Your Beachhead
Start with a bounded context that has clear boundaries, moderate complexity, and active development. Avoid the most tangled parts of your codebase—you want early wins, not a death march. A payment processing module or a notification subsystem often makes an ideal first candidate because these typically have well-defined responsibilities and limited coupling to other domains.
The goal is to prove the pattern works in your codebase before tackling the harder migrations. Success here builds organizational confidence and creates reusable patterns that accelerate subsequent migrations.
The Anti-Corruption Layer
Before extracting domain logic, establish a protective boundary between your new hexagonal code and the legacy system. This anti-corruption layer translates between the clean domain model you’re building and the framework-coupled code that still exists. Without this boundary, legacy concepts inevitably leak into your new design, undermining the very decoupling you’re trying to achieve.
public class OrderAntiCorruptionLayer {
private final LegacyOrderService legacyService; private final OrderRepository hexagonalRepository;
public Order findOrder(OrderId id) { // Check new system first Optional<Order> order = hexagonalRepository.findById(id); if (order.isPresent()) { return order.get(); }
// Fall back to legacy, translate to domain model LegacyOrderEntity legacy = legacyService.getOrder(id.value()); return translateToDomain(legacy); }
private Order translateToDomain(LegacyOrderEntity legacy) { return new Order( new OrderId(legacy.getId()), new CustomerId(legacy.getCustomerId()), legacy.getLineItems().stream() .map(this::translateLineItem) .toList(), OrderStatus.fromLegacyCode(legacy.getStatusCode()) ); }}This layer lets you migrate gradually. New features use the hexagonal code path while legacy functionality continues working through the translation layer. As migration progresses, the translation logic shrinks until the anti-corruption layer can be removed entirely.
The Refactoring Sequence
Follow this sequence for each piece of functionality you migrate:
Extract domain logic. Pull business rules out of framework-coupled services into pure domain objects. No annotations, no dependencies on infrastructure types. This often reveals hidden business logic buried in controller methods or database triggers.
Define the port. Create the interface that expresses what your domain needs from the outside world, using domain language exclusively. Resist the temptation to mirror existing API signatures—design the interface your domain actually needs.
Build the adapter. Implement the port with your current infrastructure. This adapter contains all the framework coupling and handles the translation between domain concepts and infrastructure concerns.
Swap in. Update the anti-corruption layer to route traffic through the new hexagonal path. Use feature flags to control the rollout and enable quick rollback if issues emerge.
public class MigrationToggle {
private final FeatureFlags flags;
public OrderService getOrderService(String customerId) { if (flags.isEnabled("hexagonal-orders", customerId)) { return hexagonalOrderService; } return legacyOrderService; }}Feature flags let you migrate customer segments incrementally. Start with internal users, expand to a percentage of production traffic, then complete the cutover. This gradual approach surfaces integration issues before they affect your entire user base.
Measuring Progress
Track coupling metrics to verify you’re moving in the right direction. Count the number of framework imports in your domain packages—this number should decrease over each sprint. Visibility into these metrics keeps the team focused and provides concrete evidence of progress to stakeholders.
// Track these metrics weekly:// - Framework imports in domain packages: target 0// - Lines of code in anti-corruption layer: should shrink over time// - Test execution time: should decrease as you add more unit tests// - Adapter count per port: ideally 2+ (prod + test fake)💡 Pro Tip: Add a CI check that fails if framework dependencies appear in domain packages. Automated enforcement prevents backsliding during the migration and catches violations before they reach main.
A six-month migration of a payment module typically shows framework coupling dropping by 15-20% per month, with test suite execution time decreasing as integration tests convert to unit tests. These improvements compound—faster tests encourage more frequent testing, which catches regressions earlier.
The strangler pattern works because it’s reversible. If a migration causes problems, route traffic back through the legacy path while you fix issues. You never have to choose between migration progress and production stability.
Of course, not every codebase benefits from this investment. Some applications are genuinely better served by simpler architectures—knowing when to skip hexagonal architecture entirely saves as much pain as knowing when to adopt it.
When Hexagonal Architecture Isn’t Worth It
Hexagonal architecture solves real problems, but it introduces real costs. Before embarking on a migration, you need an honest assessment of whether those costs are justified for your specific context.
Small Applications and MVPs
A startup validating product-market fit doesn’t need ports and adapters. When you’re uncertain whether the application will exist in six months, the flexibility to swap databases or frameworks is worthless. The overhead of defining ports, creating adapter implementations, and maintaining the boundary discipline slows you down precisely when speed matters most.
For applications under 10,000 lines of code with a single developer or small team, the cognitive load of hexagonal architecture often exceeds the coupling pain it prevents. A well-structured monolith with clear modules serves you better until the domain complexity genuinely demands it.
CRUD-Heavy Domains
Some applications are fundamentally data access layers with validation. Admin panels, content management systems, and configuration dashboards often have minimal business logic worth protecting. When your “domain” is essentially “validate input, persist to database, return result,” the ceremony of ports and adapters adds indirection without adding value.
💡 Pro Tip: If most of your “business logic” would fit in database constraints and triggers, hexagonal architecture is likely overkill.
Team Context Matters
Architecture exists to serve teams, not the reverse. A team unfamiliar with hexagonal patterns will write adapters that leak domain concepts, ports that mirror database schemas, and tests that miss the point entirely. The abstraction cost compounds when every code review becomes an architecture lesson.
Conversely, an experienced team maintaining a framework-coupled codebase might navigate that coupling effectively through convention and discipline. The question isn’t whether hexagonal architecture is theoretically superior—it’s whether the migration investment yields returns for this team maintaining this codebase.
Reading the Signals
Signs you’re over-engineering: Your ports mirror your adapters one-to-one. You have more interfaces than implementations. New features require changes in four layers instead of one.
Signs you’re under-engineering: Framework upgrades terrify you. You can’t explain the business rules without referencing database columns. Test setup takes longer than the tests themselves.
The previous sections gave you the tools for migration. Use them when the signals point toward genuine coupling pain—not as a prophylactic against problems you haven’t encountered.
Key Takeaways
- Start by extracting your domain logic into plain classes with no framework annotations—this single step reveals how coupled your current design really is
- Define ports using your domain’s vocabulary, not your infrastructure’s—if your interface mentions ‘JPA’ or ‘HTTP’, it’s not a true port
- Migrate incrementally by wrapping legacy code behind anti-corruption layers, letting you adopt hexagonal architecture one bounded context at a time
- Restructure your test pyramid around architectural boundaries: fast domain unit tests, adapter integration tests with real infrastructure, and minimal end-to-end tests