Skip to content

Clean Architecture

SOLID tells us how to design one class. But a service has dozens of classes, and the question becomes where each one goes and which may import which. In Hands-on 1 the account-service already splits AccountResource, AccountService, AccountRepository and AccountModel β€” Clean Architecture is the set of rules that explains that split and keeps it from eroding as the service grows.

Clean Architecture was proposed by Robert C. Martin (Uncle Bob) in 2012 as a synthesis of several earlier architecture ideas β€” Hexagonal (Ports & Adapters), Onion, Screaming Architecture, DCI and BCE (Boundary–Control–Entity). Its central rule is the Dependency Rule: source-code dependencies must always point inward, toward higher-level policy1. "Higher-level" means closer to the business and further from input/output devices.

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.

How can a Use Case save an account if it may not reference the database? Through Dependency Inversion: the Use Case declares the interface it needs (AccountRepository), and the outer layer implements it. At runtime the call goes outward (service β†’ JPA β†’ Postgres), but in the source code the dependency points inward (the JPA adapter imports the interface). This crossing of the boundary is the heart of the architecture.

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

The diagram is the course's simplified mapping. In Hands-on 1 the names are slightly different, and one shortcut is taken:

Hands-on 1 class Role in the diagram Layer
AccountController (interface, in the account module) + AccountResource Interface Interface Adapters
AccountIn, AccountOut RecordIn / RecordOut Interface Adapters
AccountParser Parser boundary Adapters ↔ Use Cases
AccountService Service Use Cases
Account DTO / domain object Use Cases / Entities
AccountRepository (Spring Data CrudRepository) Repository persistence boundary
AccountModel (@Entity) Table persistence boundary

A pragmatic shortcut

In Hands-on 1, AccountRepository extends a Spring Data interface, so AccountService depends on a framework type β€” strictly, a violation of the Dependency Rule (see the Leaking framework types inward warning below). For a small CRUD service this is a reasonable trade: less code, and Spring Data is unlikely to be replaced. The full example below shows the strict version, where the service depends on a plain interface and only the adapter knows JPA.

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 files below show a complete Account microservice slice across all four layers, in the strict form. 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

This is what happens, layer by layer, when a client calls POST /accounts:

sequenceDiagram
    autonumber
    actor C as Client
    participant Ctl as AccountController
    participant Svc as AccountService
    participant Acc as Account
    participant Port as AccountRepository
    participant Ad as AccountJpaAdapter
    participant DB as Postgres
    C->>Ctl: POST /accounts (JSON)
    Ctl->>Svc: create(AccountIn)
    Svc->>Port: findByEmail(email)
    Port->>Ad: (implemented by)
    Ad->>DB: SELECT ... WHERE email = ?
    DB-->>Svc: empty: email is free
    Svc->>Acc: Account.of(in): validate, hash password
    Svc->>Port: save(account)
    Port->>Ad: (implemented by)
    Ad->>DB: AccountMapper.toTable, INSERT
    Svc-->>Ctl: AccountOut
    Ctl-->>C: 201 Created

The rule "email must be unique" lives in AccountService (an application rule); the rules "email must contain @" and "password has at least 8 characters" live in Account (domain invariants). Neither knows that Postgres exists. In this compact example, AccountIn and AccountOut are plain records used directly as the use case's input and output models, so no Parser is needed; add one when the HTTP contract and the use-case model start to differ.

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

    // Rebuilds an Account that already exists (e.g. loaded from the database):
    // no validation and no new id β€” the data was validated when it was created.
    public static Account reconstruct(String id, String name, String email, String passwordHash) {
        return new Account(id, name, email, passwordHash);
    }

    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
9
// 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);
    List<Account> findAll();
    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.
// Spring Web annotations (@RestController, @PostMapping, ...) appear only in this layer.
// The controller translates and delegates; 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 List<Account> findAll() {
        return jpa.findAll().stream().map(AccountMapper::toDomain).toList();
    }

    @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 β€” as Account.of(...) does with the email and password rules.

Mapping everything everywhere

Strict Clean Architecture means one model per layer and a mapper between each pair. In a CRUD service with no real business rules this is pure overhead. Keep the strict separation where the rules are, and accept shortcuts (like Hands-on 1's Spring Data repository) where they are not.


Key takeaways

  • The Dependency Rule: source code in an inner layer never references an outer layer; at runtime calls go outward, but compile-time dependencies point inward.
  • Four rings: Entities (domain invariants), Use Cases (application rules), Interface Adapters (controllers, mappers), Frameworks & Drivers (Spring, JPA, the database).
  • Crossing a boundary outward is done with Dependency Inversion: the inner layer declares the interface, the outer layer implements it.
  • Framework types (@Entity, HttpServletRequest, Spring Data interfaces) stay in the outer rings; Parsers and Mappers translate at the boundaries.
  • The payoff: use cases tested with plain JUnit, and delivery or persistence swapped without touching business rules. See Hexagonal Architecture for the same idea seen from the boundary.

A step-by-step Spring Boot walkthrough of this architecture (in Portuguese) is available on video2.



  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 ↩