Lesson 49 • Expert
Clean Architecture
Structure a Java application so its business rules stay pure and testable — by splitting it into layers, pointing every dependency inward, and hiding databases and frameworks behind ports and adapters.
What You'll Learn in This Lesson
- ✓Split an app into domain, application, infrastructure & presentation layers
- ✓Apply the dependency rule so source dependencies point only inward
- ✓Model entities (identity + behaviour) and value objects (immutable)
- ✓Define ports (interfaces) and adapters (implementations) — Hexagonal style
- ✓Write use cases / interactors that orchestrate one workflow each
- ✓Use dependency inversion to keep frameworks out of your domain
Before You Start
This lesson builds on Interfaces & Abstraction (ports are interfaces), OOP Design Patterns (dependency injection), and Unit Testing (the payoff is testable code). Comfort with Generics helps too.
A Real-World Analogy
Think of your application as a house with standard wall sockets. The wiring inside the wall is your domain — the real logic, hidden and stable. A socket is a port: a fixed, agreed-upon shape that says "plug something in here." A lamp, a kettle, or a phone charger is an adapter: it conforms to the socket and provides the actual behaviour.
You can swap the kettle for a toaster without rewiring the house, because both speak "socket." In the same way you can swap a JpaOrderRepository for an in-memory fake, or Stripe for PayPal, without touching a single business rule — as long as they plug into the same port. The wiring (domain) never reaches out to grab a specific appliance; the appliances plug into it. That is the dependency rule made physical.
1️⃣ The Four Layers & the Dependency Rule
Clean Architecture sorts your code into concentric layers — picture an onion. At the centre is the domain: pure business logic that knows nothing about databases, the web, or any framework. Around it sits the application layer (your use cases). On the outside live infrastructure (databases, message queues, third-party APIs) and presentation (controllers, DTOs).
One rule holds the whole thing together — the dependency rule: source-code dependencies may only point inward. An outer layer may import an inner one, but an inner layer must never import an outer one. Your domain therefore never mentions Spring or JPA. That is what lets you test business logic with no framework, swap your database without rewriting rules, and read the domain to understand the system.
| Layer | Contains | May Depend On | Example |
|---|---|---|---|
| Domain | Entities, Value Objects, Events | Nothing! | Order, Money, OrderId |
| Application | Use cases, Ports (interfaces) | Domain only | PlaceOrderUseCase |
| Infrastructure | DB repos, API clients, messaging | Application + Domain | JpaOrderRepository |
| Presentation | Controllers, DTOs, view models | Application + Domain | OrderController |
infra folder and the domain + application still compile, your dependencies point the right way. If the domain stops compiling, an arrow is pointing outward — that is the bug.2️⃣ The Domain: Entities & Value Objects
The domain layer holds two kinds of object. An entity has a stable identity that outlives its data: two Orders with the same OrderId are the same order, even if their contents differ — so you compare them by id. A value object has no identity; it is defined entirely by its values, so two Money of USD 10.00 are interchangeable and equal.
Crucially, these objects carry their own behaviour and rules. Order.complete() refuses to run twice; Money.add() rejects mismatched currencies. An object that is just fields with getters and setters — with all the logic living in some service — is called an anaemic domain model, and it is exactly what Clean Architecture avoids. Put the rules in the object that owns the data.
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.Currency;
import java.util.List;
import java.util.Objects;
import java.util.UUID;
public class Main {
// --- VALUE OBJECT ----------------------------------------------------
// No identity of its own; two Moneys with the same amount+currency ARE equal.
// Java records give you immutability and value-equality for free.
public record Money(BigDecimal amount, Currency currency) {
public Money { // compact constructor = validation
Objects.requireNonNull(amount, "amount");
Objects.requireNonNull(currency, "currency");
if (amount.signum() < 0) throw new IllegalArgumentException("amount < 0");
}
public static Money of(String amount, String code) {
return new Money(new BigDecimal(amount), Currency.getInstance(code));
}
public Money add(Money other) {
if (!currency.equals(other.currency)) // a business rule, IN the domain
throw new IllegalArgumentException("Cannot add " + currency + " to " + other.currency);
return new Money(amount.add(other.amount), currency); // returns a NEW Money
}
@Override public String toString() { return currency.getCurrencyCode() + " " + amount; }
}
// --- ENTITY ----------------------------------------------------------
// Has a stable IDENTITY (OrderId). Two orders with the same id are the same order,
// even if their contents differ.
public record OrderId(String value) {
public static OrderId generate() { return new OrderId("ORD-" + UUID.randomUUID()); }
}
public enum OrderStatus { PENDING, COMPLETED, CANCELLED }
public record OrderLine(String sku, Money subtotal) {}
// A RICH entity: identity + DATA + the BEHAVIOUR that protects its own rules.
// This is the opposite of an "anaemic" model (data with no behaviour).
public static final class Order {
private final OrderId id;
private final List<OrderLine> lines;
private OrderStatus status = OrderStatus.PENDING;
public Order(OrderId id, List<OrderLine> lines) {
if (lines == null || lines.isEmpty()) // invariant enforced at the boundary
throw new IllegalArgumentException("Order must have at least one item");
this.id = id;
this.lines = List.copyOf(lines); // defensive copy -> immutable inside
}
public Money total() { // logic lives WITH the data
return lines.stream().map(OrderLine::subtotal).reduce(Money::add).orElseThrow();
}
public void complete() { // a state transition, validated
if (status != OrderStatus.PENDING)
throw new IllegalStateException("Can only complete PENDING orders, was " + status);
status = OrderStatus.COMPLETED;
}
public void cancel() {
if (status == OrderStatus.COMPLETED)
throw new IllegalStateException("Cannot cancel a completed order");
status = OrderStatus.CANCELLED;
}
public OrderId id() { return id; }
public OrderStatus status() { return status; }
}
public static void main(String[] args) {
// Money is immutable and validates itself.
Money total = Money.of("79.99", "USD").add(Money.of("29.99", "USD"));
System.out.println("Two items total: " + total); // USD 109.98
System.out.println("Equal by value? " + total.equals(Money.of("109.98", "USD"))); // true
// The Order ENFORCES its own rules — no service needed to keep it valid.
Order order = new Order(new OrderId("ORD-001"), List.of(
new OrderLine("KBD-1", Money.of("79.99", "USD")),
new OrderLine("MSE-1", Money.of("29.99", "USD"))));
System.out.println("Order " + order.id().value() + " = " + order.total() + " (" + order.status() + ")");
order.complete();
System.out.println("After complete -> " + order.status()); // COMPLETED
// Try to break a rule: the domain refuses.
try { order.cancel(); }
catch (IllegalStateException e) { System.out.println("Refused: " + e.getMessage()); }
try { new Order(OrderId.generate(), List.of()); }
catch (IllegalArgumentException e) { System.out.println("Refused: " + e.getMessage()); }
}
}Two items total: USD 109.98
Equal by value? true
Order ORD-001 = USD 109.98 (PENDING)
After complete -> COMPLETED
Refused: Cannot cancel a completed order
Refused: Order must have at least one item3️⃣ Ports, Use Cases & Dependency Inversion
The application layer answers "what can this system do?" Each capability is a use case (also called an interactor) — one class that orchestrates a single workflow, like "place an order." It builds domain objects, calls their methods, and coordinates the steps. It contains orchestration, not business rules — those live in the domain.
A use case needs a database and a payment gateway, but it must not know about them. So it declares ports: plain interfaces it owns. An input port is the use case interface the outside world may call (PlaceOrderUseCase). An output port is a capability the use case needs from the world (OrderRepository, PaymentPort). The use case depends only on these interfaces.
This is the Dependency Inversion Principle: instead of the high-level use case depending on a low-level JpaOrderRepository, both depend on the OrderRepository abstraction. The use case receives a concrete implementation through its constructor (constructor injection) — it never writes new JpaOrderRepository(). The actual wiring happens once, at startup, in a configuration class.
OrderRepository sits in the application layer, next to the use case that needs it — not in infrastructure. The outer adapter reaches in to implement it. That inversion is the whole trick that keeps the arrows pointing inward.// APPLICATION layer. It depends ONLY on the domain (never on Spring, JDBC, HTTP).
// It defines INPUT ports (what callers may do) and OUTPUT ports (what it needs
// from the outside world), then wires them together in a use case.
//
// === Output ports — interfaces the use case CALLS ===================
package com.myapp.application.port.out;
import com.myapp.domain.model.Order;
import com.myapp.domain.model.OrderId;
import java.util.Optional;
// "I need somewhere to load and store orders" — but NOT a database. An interface.
public interface OrderRepository {
Optional<Order> findById(OrderId id);
void save(Order order);
}
// ---------------------------------------------------------------------
package com.myapp.application.port.out;
import com.myapp.domain.model.Money;
// "I need to take a payment" — not Stripe, not PayPal. Just the capability.
public interface PaymentPort {
void charge(Money amount, String paymentMethod);
}
// === Input port — the use case the OUTSIDE world is allowed to call ==
package com.myapp.application.port.in;
import com.myapp.domain.model.OrderId;
import java.util.List;
public interface PlaceOrderUseCase {
OrderId place(List<String> skus);
}
// === Use case implementation — orchestration only ====================
package com.myapp.application.usecase;
import com.myapp.application.port.in.PlaceOrderUseCase;
import com.myapp.application.port.out.OrderRepository;
import com.myapp.application.port.out.PaymentPort;
import com.myapp.domain.model.Order;
import com.myapp.domain.model.OrderId;
import java.util.List;
public class PlaceOrderService implements PlaceOrderUseCase {
private final OrderRepository orders; // depends on the PORT (interface)...
private final PaymentPort payment; // ...never on a concrete adapter.
// DEPENDENCY INVERSION: the high-level use case and the low-level adapter
// BOTH depend on these interfaces. The arrow points inward, toward the app.
public PlaceOrderService(OrderRepository orders, PaymentPort payment) {
this.orders = orders;
this.payment = payment;
}
@Override
public OrderId place(List<String> skus) {
// 1) Build the domain object (it validates its own invariants).
Order order = buildOrder(skus);
// 2) Charge through the payment PORT — real impl decided at startup.
payment.charge(order.total(), "card");
// 3) Persist through the repository PORT.
orders.save(order);
// 4) Hand back just the identity. No JPA entity, no HTTP type leaks out.
return order.id();
}
private Order buildOrder(List<String> skus) { /* map skus -> OrderLines */ return null; }
}package declaration) from the application layer — not a single runnable main. Drop them into a Maven/Gradle project with the matching folders; the use case compiles against any class that implements its ports.🎯 Your Turn #1 — Point the Arrows Inward
___ blanks so the domain only imports the domain, and the use case depends on the port interface — never the concrete adapter.// 🎯 YOUR TURN — make the dependency arrows point the right way.
//
// The DEPENDENCY RULE: source code dependencies point INWARD only.
// presentation -> application -> domain (allowed)
// domain -> infra (FORBIDDEN)
//
// Fill in the blanks marked with ___ so the imports obey the rule.
// File: domain/model/Order.java (the innermost layer)
package com.myapp.domain.model;
// 1) The domain may ONLY import other domain code. Which package is safe here?
import com.myapp.___.model.Money; // 👉 domain (NEVER infra or application)
public final class Order { /* ... pure business logic ... */ }
// File: application/usecase/PlaceOrderService.java
package com.myapp.application.usecase;
// 2) The use case depends on the PORT, not the concrete adapter.
// Import the interface, not JpaOrderRepository.
import com.myapp.application.port.out.___; // 👉 OrderRepository (the interface)
public class PlaceOrderService { /* depends on the interface only */ }
// ✅ Expected: domain imports only "domain"; the service imports the PORT
// interface "OrderRepository", never the concrete "JpaOrderRepository".4️⃣ Adapters & Enforcing the Rule
An adapter is an outer-layer class that implements a port. JpaOrderRepository implements OrderRepository; StripePaymentAdapter implements PaymentPort. This is the only place framework details — @Repository, JPA mapping, the Stripe SDK — are allowed to appear. At its edges, an adapter maps between the outside shape (a database row, a JSON body) and the domain object, so neither side leaks into the other.
The dependency rule is easy to state and easy to break by accident. The fix is to make it a test. ArchUnit lets you assert architecture in plain JUnit: "no class in ..domain.. may depend on ..infra..." Add it to your suite and mvn test goes red the moment someone imports the wrong package — a guardrail, not a guideline on a wiki.
// INFRASTRUCTURE layer. Adapters live on the OUTSIDE. They IMPLEMENT the ports
// the use case declared, so you can swap them without touching domain or app.
// This is the only layer allowed to mention Spring, JPA, Stripe, Kafka...
package com.myapp.infra.persistence;
import com.myapp.application.port.out.OrderRepository; // implements the inner port
import com.myapp.domain.model.Order;
import com.myapp.domain.model.OrderId;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository // framework annotation: fine HERE
public class JpaOrderRepository implements OrderRepository {
private final SpringDataOrderRepo db;
public JpaOrderRepository(SpringDataOrderRepo db) { this.db = db; }
@Override public Optional<Order> findById(OrderId id) {
// Map the PERSISTENCE row -> the DOMAIN entity at the boundary.
return db.findById(id.value()).map(OrderMapper::toDomain);
}
@Override public void save(Order order) {
db.save(OrderMapper.toEntity(order)); // domain -> row, also at the boundary
}
}
// --- a second adapter for the payment port ---------------------------
package com.myapp.infra.payment;
import com.myapp.application.port.out.PaymentPort;
import com.myapp.domain.model.Money;
import org.springframework.stereotype.Component;
@Component
public class StripePaymentAdapter implements PaymentPort {
@Override public void charge(Money amount, String method) {
// The real Stripe SDK call lives here. The domain neither knows nor cares.
}
}
// === ArchUnit — turn the dependency rule into a FAILING test =========
package com.myapp;
import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.lang.ArchRule;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
@AnalyzeClasses(packages = "com.myapp")
class ArchitectureTest {
// The domain must not reach outward to infra or presentation.
@ArchTest static final ArchRule domain_is_pure =
noClasses().that().resideInAPackage("..domain..")
.should().dependOnClassesThat()
.resideInAnyPackage("..infra..", "..presentation..");
// The application layer must not depend on Spring.
@ArchTest static final ArchRule application_is_framework_free =
noClasses().that().resideInAPackage("..application..")
.should().dependOnClassesThat()
.resideInAPackage("org.springframework..");
}
// If anyone imports infra from the domain, "mvn test" goes red. Guard, not guideline.com.tngtech.archunit:archunit-junit5), so mvn test fails the build if the dependency rule is ever broken.🎯 Your Turn #2 — Add a Port & an Adapter
___ blanks — what makes a port, and how an adapter satisfies it.// 🎯 YOUR TURN — add an OUTPUT port and an ADAPTER for sending email.
// The use case must not know about SMTP — only the capability "notify".
// File: application/port/out/NotificationPort.java
package com.myapp.application.port.out;
// 1) Declare the CAPABILITY the use case needs, as an interface.
public ___ NotificationPort { // 👉 a port is an interface
void orderShipped(String email, String orderId);
}
// File: infra/email/SmtpNotificationAdapter.java
package com.myapp.infra.email;
import com.myapp.application.port.out.NotificationPort;
// 2) The adapter IMPLEMENTS the port. This is where SMTP details live.
public class SmtpNotificationAdapter ___ NotificationPort { // 👉 implements the port
@Override
public void orderShipped(String email, String orderId) {
// real javax.mail / SMTP call goes here — invisible to the application
System.out.println("Email to " + email + ": order " + orderId + " shipped");
}
}
// ✅ Expected: NotificationPort is an "interface"; the adapter uses
// "implements NotificationPort". Swap SMTP for a fake in tests with no
// change to the use case.5️⃣ A Package Structure That Makes the Rule Obvious
Pick a package layout where the layers are visible at a glance, so a wrong dependency stands out in a code review (and ArchUnit catches the rest). Group by layer first, then by feature inside. Read it inside-out: domain has no dependencies, application depends only on the domain, and the outer folders hold the adapters.
# Recommended package layout. Read it INSIDE-OUT: domain has zero
# dependencies, then application, then the outer adapter layers.
src/main/java/com/myapp/
├── domain/ # The core. Knows nothing about the outside world.
│ ├── model/ # Entities + Value Objects (Order, Money, OrderId)
│ ├── event/ # Domain events (OrderPlaced, OrderCancelled)
│ └── exception/ # Domain exceptions (OrderNotFoundException)
├── application/ # Use cases. Depends on domain ONLY.
│ ├── port/
│ │ ├── in/ # Input ports — use-case interfaces (driving side)
│ │ └── out/ # Output ports — repo/gateway interfaces (driven side)
│ └── usecase/ # Use-case implementations (orchestration only)
├── infra/ # Adapters that IMPLEMENT the out ports.
│ ├── persistence/ # JPA repositories + mappers
│ ├── payment/ # Stripe / PayPal adapters
│ └── messaging/ # Kafka producers / consumers
└── presentation/ # Adapters that CALL the in ports.
├── rest/ # @RestController, request/response DTOs
└── config/ # Spring @Configuration — wires ports to adapterssrc/main/java in your Maven or Gradle project.Common Errors (and the Fix)
- ❌ Domain depending on a framework — your
Orderimportsorg.springframeworkor a JPA@Entity, so the domain can no longer be tested or reused without that framework. Fix: keep the domain pure Java; let an outer adapter map to/from persistence types at the boundary. - ❌ Anaemic domain model — entities are bags of getters/setters and every rule lives in a fat service. Fix: move the behaviour onto the object that owns the data —
order.complete(),money.add()— so invariants are enforced where the state lives. - ❌ Leaking infrastructure into the domain — a JPA
Orderentity (with@Column, lazy proxies, a no-arg constructor) is passed around as if it were the domain object. Fix: use a separate persistence entity and a mapper; the domain object stays clean. - ❌ Circular dependencies between layers — the use case calls the adapter and the adapter calls back into the use case, so neither compiles in isolation. Fix: depend on a port interface in one direction only; introduce a domain event if the outer layer needs to react.
- ❌ Newing dependencies inside a use case —
new JpaOrderRepository()inside the service hard-wires it to one database and breaks dependency inversion. Fix: take theOrderRepositoryport through the constructor and wire the concrete adapter once, in configuration.
Pro Tips
- 💡 Records for value objects —
record Money(BigDecimal amount, Currency currency) {}gives you immutability and value-equality for free, exactly what a value object needs. - 💡 Enforce architecture with ArchUnit — a single test that says "domain must not depend on infra" turns the dependency rule into a build failure, not a hope.
- 💡 Prefer domain events over direct coupling — instead of the use case calling an
EmailServicedirectly, publish anOrderPlacedevent and let a handler react. The outer layer plugs in without the inner layer knowing. - 💡 Start simple, grow deliberately — begin with a rich domain plus repository ports; add use cases, more value objects, and events only as the business rules actually multiply.
📋 Quick Reference
| Concept | In Java | Purpose |
|---|---|---|
| Dependency rule | imports point inward only | Domain stays framework-free |
| Entity | class Order (has OrderId) | Identity + behaviour |
| Value object | record Money(amount, currency) | Immutable, equal by value |
| Input port | interface PlaceOrderUseCase | What the outside may call |
| Output port | interface OrderRepository | What the app needs (WHAT) |
| Adapter | JpaOrderRepository implements | Concrete implementation (HOW) |
| Use case / interactor | class PlaceOrderService | Orchestrates one workflow |
| Dependency inversion | constructor takes the port | Both depend on the interface |
| Domain event | record OrderPlaced(OrderId) | Decoupled cross-boundary signal |
Frequently Asked Questions
What exactly is the dependency rule in Clean Architecture?
Source-code dependencies may only point inward, toward higher-level policy. Presentation depends on application, application depends on domain, and the domain depends on nothing. An inner layer must never import an outer one, so your domain never references Spring, JPA, or HTTP. Tools like ArchUnit can turn this rule into a unit test that fails the build the moment someone breaks it.
How is Hexagonal Architecture (Ports & Adapters) related to Clean Architecture?
They are the same idea drawn differently. Clean Architecture pictures concentric circles; Hexagonal pictures a core with ports on its edges. In both, a 'port' is an interface your application owns (OrderRepository, PaymentPort) and an 'adapter' is an outer implementation of it (JpaOrderRepository, StripePaymentAdapter). Input ports are the use cases the world may call; output ports are the capabilities the application needs from the world. The point is identical: keep business logic free of framework details.
What is the difference between an Entity and a Value Object?
An Entity has a stable identity that outlives its data — two Orders with the same OrderId are the same order even if their lines differ, so you compare them by id. A Value Object has no identity; it is defined entirely by its values, so two Money instances of USD 10.00 are interchangeable and equal. In Java, model entities as classes with an id field and value objects as immutable records that get value-equality for free.
How do use cases and the dependency inversion principle fit together?
A use case (interactor) is a class that orchestrates one application workflow — place an order, cancel an order — by calling domain objects and output ports. It needs a database and a payment gateway, but instead of newing them up it declares interfaces (ports) and receives implementations through its constructor. That is dependency inversion: the high-level use case and the low-level adapter both depend on the abstraction, and the concrete wiring happens once, at startup, in a configuration class.
Isn't all this layering overkill for a small CRUD app?
Often, yes. Clean Architecture pays off when a domain has real, changing business rules and a long life. For a three-table CRUD app it adds ceremony without much benefit, so a thinner approach is fine. A good middle path is to start with a rich domain plus repository ports, then add use cases, value objects, and domain events only as the rules grow. The cost you must avoid is leaking framework details into the domain — that is hard to undo later regardless of app size.
Mini-Challenge — A Cancel-Order Use Case
Order entity — not the service. Check your design against the expected behaviour.// 🎯 MINI-CHALLENGE: design a "cancel order" use case, clean-architecture style.
// Write it from the outline — no filled-in logic given this time.
//
// Layers to touch (point every dependency INWARD):
// 1. domain/model/Order — add a cancel() method that throws if already shipped.
// 2. application/port/out/ — reuse OrderRepository (findById + save).
// 3. application/port/in/ — define CancelOrderUseCase { void cancel(OrderId id); }
// 4. application/usecase/ — CancelOrderService implements CancelOrderUseCase:
// a) load the Order via the repository PORT (throw if not found),
// b) call order.cancel() (the DOMAIN enforces the rule),
// c) save the Order via the repository PORT.
// 5. infra/ — keep the existing JpaOrderRepository adapter; no change.
//
// Rules to respect:
// - The service depends on INTERFACES only (no JpaOrderRepository import).
// - No Spring / JPA annotations in domain or application.
// - The cancellation RULE lives in Order, not in the service.
//
// ✅ Expected behaviour:
// cancel(existingPendingId) -> order.status() becomes CANCELLED, saved once.
// cancel(shippedId) -> IllegalStateException from the domain.
// cancel(unknownId) -> OrderNotFoundException from the service.
// your code here🎉 Lesson Complete!
You can now structure a Java application the clean way: a pure domain of entities and value objects that own their rules, an application layer of use cases that orchestrate workflows through ports, and outer adapters that implement those ports behind frameworks. The dependency rule keeps every arrow pointing inward, dependency inversion keeps Spring and JPA out of your business logic, and an ArchUnit test keeps it that way for good.
Next up: the Final Project — build a complete Java application that applies everything you've learned across the course.
Sign up for free to track which lessons you've completed and get learning reminders.