The problem: business rules welded to the framework
A discount rule lives inside a @RestController method that also calls JpaRepository, reads HttpServletRequest and throws ResponseStatusException. To test the rule you need a Spring context and a database. To move the same rule behind a message listener, you copy it. To upgrade the JPA provider, you touch business code. The framework has become the architecture.
Clean architecture, the hexagonal style and ports and adapters are close relatives with one shared idea: business rules must not depend on delivery or infrastructure details. Used with judgement, this gives you tests that run in milliseconds and a core that survives framework changes. Used by rote, it produces folders full of one-line interfaces. This article aims at the first outcome.
The dependency rule
Source-code dependencies point inwards only.
+--------------------------------------------------+
| Frameworks / drivers: web, JPA, Kafka, HTTP |
| +--------------------------------------------+ |
| | Adapters: controllers, repositories impl | |
| | +--------------------------------------+ | |
| | | Application: use cases, ports | | |
| | | +--------------------------------+ | | |
| | | | Domain: entities, rules | | | |
| | | +--------------------------------+ | | |
| | +--------------------------------------+ | |
| +--------------------------------------------+ |
+--------------------------------------------------+
dependencies point toward the centreThe domain knows nothing about Spring, JPA or HTTP. The application layer (use cases) knows the domain and declares what it needs from the outside as interfaces, the ports. Adapters in the outer ring implement those ports and call the use cases. The direction of control may go outwards (a use case calls the database), but the source dependency points inwards, because the interface belongs to the inner layer. That reversal is dependency inversion.
A concrete layout
Package by feature first, layers inside each feature:
ordering/
domain/ Order, Money, OrderLine, OrderPolicy
application/ PlaceOrderUseCase, port/out/OrderRepository,
port/out/PaymentGateway
adapter/in/web/ OrderController, PlaceOrderRequest
adapter/out/ JpaOrderRepository, PaymentHttpClientThe port is declared where it is used, in the application layer:
// application/port/outpublic interface OrderRepository { Optional<Order> findById(OrderId id); void save(Order order);}The use case depends on the port, not on JPA:
@Service@Transactionalpublic class PlaceOrderUseCase { private final OrderRepository orders; private final PaymentGateway payments; public PlaceOrderUseCase(OrderRepository o, PaymentGateway p) { this.orders = o; this.payments = p; } public OrderId place(PlaceOrderCommand cmd) { Order order = Order.create(cmd.customerId(), cmd.lines()); payments.authorize(order.id(), order.total()); orders.save(order); return order.id(); }}The adapter does the translation, and the persistence model can differ from the domain model:
@Repositoryclass JpaOrderRepository implements OrderRepository { private final OrderJpaRepo jpa; // Spring Data interface public void save(Order o) { jpa.save(OrderEntity.from(o)); } public Optional<Order> findById(OrderId id) { return jpa.findById(id.value()).map(OrderEntity::toDomain); }}Keeping a separate JPA entity costs mapping code. Many teams accept annotating the domain class with a few JPA annotations when the model is simple; this bends the purity rule on purpose. State that choice in an ADR so it is a decision, not an accident.
What you gain in testing
The use case can be tested with in-memory fakes, no Spring context and no container:
class PlaceOrderUseCaseTest { @Test void savesOrderAfterAuthorization() { var repo = new InMemoryOrderRepository(); var payments = new FakePaymentGateway(); var useCase = new PlaceOrderUseCase(repo, payments); OrderId id = useCase.place(sampleCommand()); assertThat(repo.findById(id)).isPresent(); assertThat(payments.authorized()).containsKey(id); }}The domain rules get plain unit tests. Adapters get focused slice or integration tests (@WebMvcTest for the controller, @DataJpaTest or a real database for the repository). The result is a fast majority of tests and a few slow ones that target exactly the integration risk. See the testing strategy article.
Where it pays off
- Real business rules that change and need fast tests: pricing, eligibility, state machines.
- Several entry points for the same behaviour (HTTP, message listener, scheduled job).
- Infrastructure with real chance of change or substitution: a payment provider, a message broker, an external API.
- Long-lived systems where the domain outlives frameworks.
Where it is ceremony
- A CRUD service with little logic: a controller, a service and a repository interface already say everything. Adding ports, commands, mappers and use-case classes for "save this row" multiplies files without protecting anything.
- Interfaces with exactly one implementation and no test double need. Spring Data repositories already are an abstraction.
- Mapping every object between four layers by default.
- Prototypes and short-lived tools.
A useful stance: start simple, and introduce a port at the moment you feel pain, such as hard-to-test logic, a second entry point, or an infrastructure you want to isolate. Apply the structure to the core domain and keep supporting parts lightweight. Different modules of one application can legitimately use different levels of ceremony.
Common mistakes
- Putting the domain behind a layer cake but keeping logic in services that merely pass data (an anemic model), so the inner circle contains nothing.
- Leaking framework types, for example
Pageableor JPA entities, into the domain or use-case signatures. - A single huge
Serviceper entity rather than focused use cases. - Ports shaped like the technology (
KafkaPublisher) rather than the need (OrderEventPublisher). - Letting web DTOs reach the domain.
- Enforcing the rule by convention only. An architecture test (for example with ArchUnit) can fail the build when
domainimportsorg.springframework.
Trade-offs
You trade more types and mapping for testability, replaceable infrastructure and a visible core. The price is real: onboarding time, indirection when navigating code, and the temptation to over-abstract. The benefit grows with domain complexity and system lifetime and shrinks for thin data-in, data-out services.
Enforcing the dependency rule with a test
A rule that is only written on a wiki erodes. An architecture test makes it executable:
@AnalyzeClasses(packages = "com.example.ordering")class ArchitectureTest { @ArchTest static final ArchRule domain_is_framework_free = noClasses().that().resideInAPackage("..domain..") .should().dependOnClassesThat() .resideInAnyPackage("org.springframework..", "jakarta.persistence..");}Start with the one or two rules that protect the most value, such as the domain staying framework-free and adapters not calling each other. Too many rules make the test suite a bureaucratic obstacle.
Introducing it into an existing codebase
Do not rewrite. Pick one feature with real business logic and growing pain. Extract the rules into plain domain classes with unit tests, put a port around the one infrastructure dependency that hurts, and leave the rest alone. Repeat only where the same pain appears. The structure then spreads along the lines of actual need rather than as a big-bang migration.
Wiring in Spring and keeping the core clean
Spring stays at the edges. The domain classes are plain Java and are not annotated as beans. The use case may carry @Service and @Transactional as a pragmatic compromise, or you can register it from a configuration class in the adapter layer so the application package stays annotation-free. Either choice is acceptable if it is consistent. What matters is that the container is a detail: you can construct the use case with new in a test and it behaves the same. Error handling follows the same direction: the domain throws its own exceptions, and the web adapter translates them into HTTP responses, so the core never knows a status code exists.
A quick decision rule
Ask three questions before adding a port. Does the logic behind it change for business reasons? Do I need to test the caller without the real dependency? Could a second implementation or entry point realistically appear? If all three answers are no, call the dependency directly and move on. You can add the port later in minutes, because the dependency rule is easy to apply on top of working code and hard to remove once it has spread everywhere.
Checklist
- The domain package has no framework imports; this is checked by a test.
- Ports are named by business need and live next to their users.
- Use cases are focused, one intent each.
- Adapters contain translation, not business rules.
- Core logic is tested without Spring or a database.
- The decision about entity mapping is written down.
- Ceremony matches the complexity of each module.
Where to go next
- DDD aggregates: shaping the domain at the centre.
- Spring Boot layering: the simpler starting point.
- Senior backend developer roadmap: design skills at this level.