Clean Architecture

Reviewed & published by Brayan K

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.

Part of the free Java course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.

What You'll Learn in This Lesson

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.

LayerContainsMay Depend OnExample
DomainEntities, Value Objects, EventsNothing!Order, Money, OrderId
ApplicationUse cases, Ports (interfaces)Domain onlyPlaceOrderUseCase
InfrastructureDB repos, API clients, messagingApplication + DomainJpaOrderRepository
PresentationControllers, DTOs, view modelsApplication + DomainOrderController

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()); }
    }
}

3️⃣ 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.

// 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; }
}

🎯 Your Turn #1 — Point the Arrows Inward

// 🎯 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.

🎯 Your Turn #2 — Add a Port & an Adapter

// 🎯 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 adapters

Common Errors (and the Fix)

Pro Tips

📋 Quick Reference

ConceptIn JavaPurpose
Dependency ruleimports point inward onlyDomain stays framework-free
Entityclass Order (has OrderId)Identity + behaviour
Value objectrecord Money(amount, currency)Immutable, equal by value
Input portinterface PlaceOrderUseCaseWhat the outside may call
Output portinterface OrderRepositoryWhat the app needs (WHAT)
AdapterJpaOrderRepository implementsConcrete implementation (HOW)
Use case / interactorclass PlaceOrderServiceOrchestrates one workflow
Dependency inversionconstructor takes the portBoth depend on the interface
Domain eventrecord OrderPlaced(OrderId)Decoupled cross-boundary signal

Mini-Challenge — A Cancel-Order Use Case

// 🎯 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.

Practice quiz

The dependency rule states that source-code dependencies may only point:

  • Outward, toward infrastructure
  • In any direction
  • Inward, toward higher-level policy (the domain)
  • From domain to presentation

Answer: Inward, toward higher-level policy (the domain). Outer layers may import inner ones, never the reverse. The domain depends on nothing.

Which layer is allowed to depend on NOTHING (no framework, no other layer)?

  • Domain
  • Presentation
  • Infrastructure
  • Application

Answer: Domain. The domain is the pure core — it knows nothing about Spring, JPA, HTTP, or any outer layer.

A Money value object compares as equal when:

  • Its memory address matches
  • It has the same id
  • Never — value objects are always distinct
  • Its amount and currency are equal (value equality)

Answer: Its amount and currency are equal (value equality). A value object has no identity; two Money of USD 109.98 are equal by value. Records give this for free.

How does an Entity differ from a Value Object?

  • An entity is always immutable
  • An entity has a stable identity that outlives its data
  • A value object has an id field
  • They are the same thing

Answer: An entity has a stable identity that outlives its data. Entities (like Order with an OrderId) are compared by identity; value objects (like Money) by their values.

In Ports & Adapters, a 'port' is:

  • An interface the application owns (e.g. OrderRepository, PaymentPort)
  • A concrete database class
  • A network socket
  • A Spring annotation

Answer: An interface the application owns (e.g. OrderRepository, PaymentPort). A port is an interface the app defines. An adapter (JpaOrderRepository, StripePaymentAdapter) implements it.

Where does the OrderRepository interface live?

  • In the infrastructure layer
  • In the presentation layer
  • In the application layer, next to the use case that needs it
  • Outside the project

Answer: In the application layer, next to the use case that needs it. The output port sits in the application layer; the outer adapter reaches in to implement it — that inversion keeps arrows inward.

A use case (interactor) should contain:

  • All the business rules
  • Orchestration of one workflow, delegating rules to the domain
  • JPA mappings
  • HTTP controllers

Answer: Orchestration of one workflow, delegating rules to the domain. A use case coordinates steps and calls domain objects; the actual business rules live in the domain entities.

Dependency inversion in a use case means the service:

  • Calls new JpaOrderRepository() directly
  • Extends the adapter class
  • Stores a static reference to the database
  • Receives a port implementation through its constructor

Answer: Receives a port implementation through its constructor. The service depends on the abstraction and gets the concrete adapter via constructor injection, wired at startup.

An 'anaemic domain model' is one where:

  • Entities own their rules and behaviour
  • Entities are just getters/setters and all logic lives in fat services
  • There are too many value objects
  • The domain depends on no framework

Answer: Entities are just getters/setters and all logic lives in fat services. Clean Architecture avoids anaemic models: put behaviour (order.complete(), money.add()) on the object that owns the data.

What does an ArchUnit test let you do?

  • Generate adapters automatically
  • Speed up the build
  • Turn the dependency rule into a failing test if a layer imports the wrong package
  • Replace unit tests

Answer: Turn the dependency rule into a failing test if a layer imports the wrong package. ArchUnit asserts architecture in JUnit, so 'mvn test' goes red the moment someone breaks the dependency rule.

Continue this course

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.

Related lessons