Skip to content

Clean Architecture

Clean Architecture was proposed by Robert C. Martin (Uncle Bob) as a synthesis of several earlier layered-architecture ideas — Hexagonal, Onion, and BCE. Its central rule is the Dependency Rule: source-code dependencies must always point inward, toward higher-level policy1.

Clean Architecture

Source: The Clean Code Blog — Robert C. Martin

Layers

Layer Also called Responsibility
Entities Domain Enterprise-wide business rules and data structures. Independent of any application.
Use Cases Application Application-specific business rules. Orchestrates Entities; defines what the app does.
Interface Adapters Adapters Converts data between Use Cases and external formats: Controllers, Presenters, Gateways.
Frameworks & Drivers Infrastructure Web frameworks, databases, UI, external APIs. The outermost ring — all detail lives here.

The Dependency Rule

Nothing in an inner layer may reference anything in an outer layer. Data that crosses boundaries must be in simple structures — plain objects or records — never a framework type. An @Entity annotation or HttpServletRequest must never appear inside a Use Case or Entity class.

What belongs where

A practical heuristic for deciding which layer a class belongs to:

  • If it changes when the business changes → Entity or Use Case
  • If it changes when the interface changes (REST → gRPC, JSON → XML) → Interface Adapter
  • If it changes when a library is upgraded (Spring Boot version, JPA dialect) → Frameworks & Drivers

In our architecture

The diagram below shows how Clean Architecture layers map to the classes and packages used in the course's Spring Boot microservices:

flowchart LR
  subgraph Adapters ["Interface Adapters (green)"]
    direction TB
    Interface:::adapter
    RecordIn:::adapter
    RecordOut:::adapter
  end
  subgraph UseCases ["Use Cases (red)"]
    direction TB
    Service:::usecase
    DTO:::usecase
  end
  subgraph Entities ["Entities (yellow)"]
    direction TB
    Repository:::entity
    Table:::entity
  end

  Interface --> RecordIn
  Interface --> RecordOut

  Adapters <-->|Parser| UseCases
  Service --> DTO
  UseCases <-->|Mapper| Entities
  Repository --> Table

  classDef adapter fill:#6f6
  classDef usecase fill:#f99
  classDef entity fill:#ff9

Role of each component

RecordIn / RecordOut : Input/output DTOs that live at the adapter boundary. They decouple the HTTP contract from the internal model, so changing a JSON field name or adding a validation annotation never touches the Use Case.

Parser : Converts between Record* types and the Use Case DTOs. It is the only place that knows both worlds — keeping both sides of the boundary clean.

Service : Implements the use-case logic. It depends only on repository interfaces declared in the Entities layer, never on JPA, Spring Data, or any other persistence detail.

Mapper : Converts between Use Case DTOs and @Entity / @Table objects at the persistence boundary. The @Entity class — which carries ORM annotations — is confined to the Entities/Infrastructure boundary and never leaks into Service logic.

Repository : A plain Java interface declared in the Entities layer. The JPA implementation (AccountJpaAdapter) lives in the outermost layer and is injected at runtime by Spring's dependency injection container.

Suggested package layout

com.example.product/
├── domain/                  ← Entities layer
│   ├── Product.java         # Domain object (no framework annotations)
│   └── ProductRepository.java  # Repository interface (port)
├── application/             ← Use Cases layer
│   ├── ProductService.java  # Business logic
│   └── ProductDTO.java      # Data passed across the use-case boundary
├── adapter/                 ← Interface Adapters layer
│   ├── in/
│   │   ├── ProductController.java
│   │   ├── ProductRecordIn.java
│   │   └── ProductRecordOut.java
│   └── out/
│       ├── ProductJpaAdapter.java   # Implements ProductRepository
│       ├── ProductTable.java        # @Entity class
│       └── ProductMapper.java

Practical benefits

Testability without infrastructure : Business rules in the Use Case layer can be unit-tested with plain JUnit — no Spring context, no database, no web server. Mock the Repository interface and test the Service in milliseconds.

Swappable delivery mechanism : The HTTP layer (REST, gRPC, GraphQL, CLI) can be swapped or run in parallel without touching the Service class. The adapter is the only thing that changes.

Swappable persistence : Replace JPA with MongoDB, or an in-memory map for tests, by providing a new implementation of the Repository interface. The domain and application layers are unaffected.

Parallel team work : Teams can develop the persistence adapter and the REST adapter independently, as long as they agree on the port interfaces. The Service can be developed — and fully tested — before any adapter exists.


Where does this class belong?

A quick decision guide for placing a new class:

flowchart TD
    start([New class]) --> q1{Does it contain\nbusiness rules?}
    q1 -->|Yes| q2{Does it depend on\na framework annotation?}
    q2 -->|Yes| fix1["Move the business rule\nto an inner class\nLeave the annotation\nin the adapter layer"]:::warn
    q2 -->|No| entity["Entity or Use Case ✓"]:::ok
    q1 -->|No| q3{Does it translate\nbetween layers?}
    q3 -->|Yes| adapter["Interface Adapter ✓"]:::ok
    q3 -->|No| infra["Frameworks & Drivers ✓"]:::ok
    classDef ok fill:#6c6,color:#fff
    classDef warn fill:#f90,color:#000

Full working example

The five files below show a complete Account microservice slice across all four layers. Each file is in a different layer — notice that dependencies always point inward.

flowchart RL
    AccountController -->|"Parser converts\nRecordIn → DTO"| AccountService
    AccountService -->|"depends on interface"| AccountRepository
    AccountJpaAdapter -->|"implements"| AccountRepository
    AccountTable -.-|"used by\nadapter only"| AccountJpaAdapter
    AccountMapper -.-|"used by\nadapter only"| AccountJpaAdapter

    style AccountController fill:#6f6
    style AccountService fill:#f99
    style AccountRepository fill:#ff9
    style AccountJpaAdapter fill:#fcc
    style AccountTable fill:#fcc
    style AccountMapper fill:#fcc
// Entities layer — pure domain object, zero framework annotations.
// Changes only when business rules about accounts change.
public class Account {

    private final String id;
    private String name;
    private final String email;
    private String passwordHash;

    private Account(String id, String name, String email, String passwordHash) {
        this.id           = id;
        this.name         = name;
        this.email        = email;
        this.passwordHash = passwordHash;
    }

    public static Account of(AccountIn in) {
        if (in.email() == null || !in.email().contains("@"))
            throw new DomainException("Invalid email: " + in.email());
        if (in.password() == null || in.password().length() < 8)
            throw new DomainException("Password must be at least 8 characters");
        return new Account(
            UUID.randomUUID().toString(),
            in.name(),
            in.email(),
            PasswordHash.bcrypt(in.password())
        );
    }

    public boolean matchesPassword(String rawPassword) {
        return PasswordHash.verify(rawPassword, this.passwordHash);
    }

    public String id()           { return id; }
    public String name()         { return name; }
    public String email()        { return email; }
    public String passwordHash() { return passwordHash; }
}
1
2
3
4
5
6
7
8
// Entities layer — repository interface (port) declared by the domain.
// The persistence technology is unknown here.
public interface AccountRepository {
    Optional<Account> findById(String id);
    Optional<Account> findByEmail(String email);
    Account save(Account account);
    void delete(String id);
}
// Use Cases layer — application-specific business logic.
// Depends on domain objects and the repository port interface — nothing from Spring.
public class AccountService {

    private final AccountRepository repository;

    public AccountService(AccountRepository repository) {
        this.repository = repository;
    }

    public AccountOut create(AccountIn in) {
        if (repository.findByEmail(in.email()).isPresent())
            throw new DomainException("Email already registered: " + in.email());
        Account account = Account.of(in);
        repository.save(account);
        return AccountOut.from(account);
    }

    public AccountOut findById(String id) {
        return repository.findById(id)
            .map(AccountOut::from)
            .orElseThrow(() -> new NotFoundException("Account not found: " + id));
    }

    public List<AccountOut> findAll() {
        return repository.findAll().stream()
            .map(AccountOut::from)
            .toList();
    }

    public void delete(String id) {
        if (repository.findById(id).isEmpty())
            throw new NotFoundException("Account not found: " + id);
        repository.delete(id);
    }
}
// Interface Adapters layer — translates HTTP ↔ Use Case.
// The only Spring annotation in this file is @RestController.
// Business logic lives entirely in AccountService.
@RestController
@RequestMapping("/accounts")
public class AccountController implements AccountApi {

    private final AccountService service;

    public AccountController(AccountService service) {
        this.service = service;
    }

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public AccountOut create(@RequestBody @Valid AccountIn in) {
        return service.create(in);
    }

    @GetMapping("/{id}")
    public AccountOut findById(@PathVariable String id) {
        return service.findById(id);
    }

    @GetMapping
    public List<AccountOut> findAll() {
        return service.findAll();
    }

    @DeleteMapping("/{id}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    public void delete(@PathVariable String id) {
        service.delete(id);
    }
}
// Frameworks & Drivers layer — JPA entity class.
// Carries all ORM annotations so they never appear in domain or use-case classes.
@Entity
@Table(name = "accounts")
public class AccountTable {

    @Id
    @Column(nullable = false, updatable = false)
    private String id;

    @Column(nullable = false)
    private String name;

    @Column(nullable = false, unique = true)
    private String email;

    @Column(name = "password_hash", nullable = false)
    private String passwordHash;

    // JPA requires a no-arg constructor
    protected AccountTable() {}

    public AccountTable(String id, String name, String email, String passwordHash) {
        this.id           = id;
        this.name         = name;
        this.email        = email;
        this.passwordHash = passwordHash;
    }

    public String getId()           { return id; }
    public String getName()         { return name; }
    public String getEmail()        { return email; }
    public String getPasswordHash() { return passwordHash; }
}
// Frameworks & Drivers layer — implements the AccountRepository port using Spring Data JPA.
// This is the only class that knows about JPA, AccountTable, and AccountMapper.
@Repository
public class AccountJpaAdapter implements AccountRepository {

    private final AccountJpaRepository jpa;

    public AccountJpaAdapter(AccountJpaRepository jpa) {
        this.jpa = jpa;
    }

    @Override
    public Optional<Account> findById(String id) {
        return jpa.findById(id).map(AccountMapper::toDomain);
    }

    @Override
    public Optional<Account> findByEmail(String email) {
        return jpa.findByEmail(email).map(AccountMapper::toDomain);
    }

    @Override
    public Account save(Account account) {
        AccountTable saved = jpa.save(AccountMapper.toTable(account));
        return AccountMapper.toDomain(saved);
    }

    @Override
    public void delete(String id) {
        jpa.deleteById(id);
    }
}

// Spring Data JPA interface — lives at the outermost layer
interface AccountJpaRepository extends JpaRepository<AccountTable, String> {
    Optional<AccountTable> findByEmail(String email);
}
// Frameworks & Drivers layer — converts between domain objects and persistence objects.
// The boundary between Use Cases and Frameworks & Drivers layers.
public final class AccountMapper {

    private AccountMapper() {}

    public static Account toDomain(AccountTable table) {
        return Account.reconstruct(
            table.getId(),
            table.getName(),
            table.getEmail(),
            table.getPasswordHash()
        );
    }

    public static AccountTable toTable(Account account) {
        return new AccountTable(
            account.id(),
            account.name(),
            account.email(),
            account.passwordHash()
        );
    }
}

Common mistakes

Leaking framework types inward

Putting @Entity, @Column, @JsonProperty, or HttpServletRequest into a Use Case or Entity class violates the Dependency Rule immediately. The framework detail now dictates what the business rule looks like.

Fat controllers

Controllers that contain if statements, call multiple services, or orchestrate workflows have absorbed Use Case responsibilities. Controllers should translate and delegate — nothing more.

Anemic domain model

Entities that are pure data bags (only getters/setters) with all logic in the Service layer are a sign the layer boundary is not being used. Move invariant enforcement into the Entity.



  1. MARTIN, R. C. Clean Architecture: A Craftsman's Guide to Software Structure and Design. Prentice Hall, 2017.  

  2. Criando um projeto Spring Boot com Arquitetura Limpa by Giuliana Silva Bezerra