Clean Architecture
Hey Engineers! In this blog, we’re diving deep into clean architecture, the software design philosophy to build robust, maintainable systems. We’ll explore clean architecture using a lemonade stand application (LemonadeStandPro) as an example to examine its inner workings.
What Is Clean Architecture?
Clean architecture, introduced by Robert C. Martin, prioritizes separation of concerns by organizing a system into concentric layers with a strict dependency rule: inner layers know nothing of the outer ones. This isolates business logic from external concerns like frameworks, databases, or UIs. The layers are:
- Entities: The innermost layer, representing the core domain objects and business rules.
- Use Cases: The application-specific logic that orchestrates the entities to perform meaningful tasks.
- Interface Adapters: The bridge between use cases and external systems, translating data and commands.
- Frameworks & Drivers: The outermost layer, encompassing tools and technologies that interact with the system.
The dependency flow is inward:
This ensures the core remains independent, making the system adaptable and resilient.
Under the Hood:
Let’s implement LemonadeStandPro, an app for managing lemonade production and sales, to see clean architecture in practice.
Entities: The Core Domain
Entities form the heart of clean architecture—the pure, unadulterated representation of the business domain. They encapsulate the most fundamental rules and data structures, free from any external dependencies. Think of entities as the timeless truths of your system, unaffected by how they’re stored or displayed.
For LemonadeStandPro, the Lemonade entity defines what lemonade is
public class Lemonade {
private final String flavor;
private int lemonsUsed;
private int sugarGrams;
private int waterMl;
public Lemonade(String flavor, int lemonsUsed, int sugarGrams, int waterMl) {
this.flavor = flavor;
this.lemonsUsed = lemonsUsed;
this.sugarGrams = sugarGrams;
this.waterMl = waterMl;
}
public boolean isSweetEnough() {
return sugarGrams >= lemonsUsed * 10; // Business rule: 10g sugar per lemon
}
public String getFlavor() { return flavor; }
public int getLemonsUsed() { return lemonsUsed; }
}
This is framework-agnostic—pure Java representing the lemonade’s intrinsic properties.
Key Characteristics:
- Independence: No references to frameworks, databases, or UI—pure Java.
- Business Focus: Contains rules like “10g of sugar per lemon” that define the domain.
- Reusability: Can be reused across applications (e.g., a lemonade kiosk or a factory).
Dos and Dont’s:
Do: Define entities as plain Java objects (POJOs) with no dependencies. Example: a PetRock class with name and mood. Don’t: Let them sneakily import javax.persistence.Entity or other framework nonsense. That’s like giving a sage a smartphone—they’ll just get distracted by cat videos.
Entities are the foundation—stable and unchanging, even as outer layers evolve.
Use Cases: The Business Logic
The use case layer defines what the application does. It’s the application-specific logic that orchestrates entities to fulfill user intentions or system requirements. Use cases are still part of the business domain but are more dynamic, representing workflows or operations rather than static truths.
Here’s MakeLemonadeUseCase for creating lemonade:
public interface LemonadeRepository {
void save(Lemonade lemonade);
}
public class MakeLemonadeUseCase {
private final LemonadeRepository repo;
public MakeLemonadeUseCase(LemonadeRepository repo) {
this.repo = repo;
}
public String execute(String flavor, int lemons, int sugar, int water) {
Lemonade lemonade = new Lemonade(flavor, lemons, sugar, water);
if (!lemonade.isSweetEnough()) {
return "Too tart! Adjust sugar for " + flavor + " lemonade.";
}
repo.save(lemonade);
return flavor + " lemonade created successfully.";
}
}
Key Characteristics:
- Application-Specific: Focuses on actions like “make lemonade” or “sell lemonade.”
- Dependency Inversion: Relies on interfaces (e.g.,
LemonadeRepository), not concrete implementations. - Encapsulation: Contains the flow of operations, keeping it isolated from external details.
Dos and Dont’s:
Do: Write use cases as single-responsibility classes. Example: MakeLemonadeUseCase handles only making lemonade logic and not selling lemonade. Use interfaces to define what the inner layers need (e.g., LemonadeRepository). Implement them in the outer layers. Don’t: Let them touch the database or UI directly.
Use cases act as the conductor, directing entities without knowing how they’re persisted or presented.
Interface Adapters: Bridging the Gap
The interface adapters layer is the translator, mediating between the abstract world of use cases and the concrete realities of external systems. It’s responsible for converting data formats, handling protocols, and implementing the interfaces that use cases depend on. This layer ensures that the core remains oblivious to the outside world’s complexities.
Adapters come in three main forms:
- Controllers: Process incoming requests (e.g., from a UI or API).
- Presenters: Format use case outputs for external consumption.
- Gateways: Implement repository or service interfaces for persistence or external APIs.
Controller Example
A Spring REST controller for MakeLemonadeUseCase:
@RestController
@RequestMapping("/lemonade")
public class LemonadeController {
private final MakeLemonadeUseCase makeLemonadeUseCase;
public LemonadeController(MakeLemonadeUseCase makeLemonadeUseCase) {
this.makeLemonadeUseCase = makeLemonadeUseCase;
}
@PostMapping("/make")
public ResponseEntity<String> makeLemonade(@RequestBody LemonadeRequest request) {
String result = makeLemonadeUseCase.execute(
request.getFlavor(), request.getLemons(), request.getSugar(), request.getWater()
);
return ResponseEntity.ok(result);
}
}
record LemonadeRequest(String flavor, int lemons, int sugar, int water) {}
Presenter Example
A presenter to structure the output:
public class LemonadePresenter {
public LemonadeResponse present(String useCaseResult) {
return new LemonadeResponse(useCaseResult, useCaseResult.contains("successfully"));
}
}
record LemonadeResponse(String message, boolean isSuccess) {}
Updated controller with presenter:
@PostMapping("/make")
public ResponseEntity<LemonadeResponse> makeLemonade(@RequestBody LemonadeRequest request) {
String result = makeLemonadeUseCase.execute(
request.getFlavor(), request.getLemons(), request.getSugar(), request.getWater()
);
LemonadeResponse response = new LemonadePresenter().present(result);
return ResponseEntity.ok(response);
}
Key Characteristics:
- Translation: Maps external data (e.g., JSON) to internal models and vice versa.
- Flexibility: Enables swapping frameworks or storage systems without core changes.
- Isolation: Shields use cases from framework-specific concerns.
Dos and Dont’s :
Do: Write controllers or presenters that call use cases and format the results. Don’t: Let them sneak business logic in. They’re translators, not decision-makers.
Adapters are the glue, keeping the system modular and adaptable.
Frameworks & Drivers: The Outer Shell
The outermost layer contains the tools and technologies that interact with the system—databases, web frameworks, APIs, and UIs. This is the most volatile layer, subject to trends, upgrades, or obsolescence. In LemonadeStandPro, this includes:
- Spring Boot: For the REST API.
- Hibernate/JPA: For persistence.
- React: For a potential frontend.
Key Characteristics:
- Disposable: Can be replaced (e.g., swap Spring for Micronaut) without affecting inner layers.
- Detail-Oriented: Handles low-level concerns like HTTP routing or SQL queries.
- Dependent: Relies entirely on interface adapters for communication.
Dos and Dont’s :
Do: Implement SaveFavorRepository for saving lemonade recipe with Spring Data or JDBC in a separate package. Don’t: Let framework annotations (like @Entity or @Autowired) creep into your core logic.
This layer is the system’s skin—necessary but not intrinsic to its identity.
System Design: Scaling the Application
Expanding LemonadeStandPro to handle sales, payments, and supplier integration showcases clean architecture’s strengths:
- Modularity: Add a
SellLemonadeUseCasewithout alteringMakeLemonadeUseCase. - Testability: Mock repositories or adapters to test use cases in isolation.
- Scalability: Extract inventory into a microservice, reusing entities and use cases with new adapters.
Inward dependencies ensure external changes don’t ripple inward.
Why It’s Critical
Clean architecture is essential for complex systems:
- Framework Independence: Decouples business logic from tools, easing technology transitions.
- Resilience to Change: Adapts to new requirements via modular use cases and adapters.
- Maintainability: Isolates concerns, simplifying debugging and refactoring.
Without it, systems become brittle—business logic entangled with frameworks or databases.
Conclusion
Clean architecture offers a structured, scalable approach to software design. By implementing LemonadeStandPro with Java, we’ve explored how its expanded layers—entities, use cases, interface adapters, and frameworks—enforce discipline while enabling flexibility. Whether you’re building a small app or a distributed system, this methodology ensures your codebase remains robust, testable, and adaptable—ready for whatever challenges come next. For those leading teams or architecting solutions, mastering clean architecture is a cornerstone of delivering high-quality software.