Table of Contents#
- Understanding Clean Architecture Basics
- The Dependency Rule in Action
- Microservices and the Database Layer: Why Separation Matters
- Designing the Person CRUD Microservice
- Step-by-Step Implementation: The Outer Database Layer
- Testing the Database Layer Without Violating Dependencies
- Common Pitfalls and How to Avoid Them
- Conclusion
- References
1. Understanding Clean Architecture Basics#
Clean Architecture organizes code into concentric layers, each with a specific responsibility. The layers, from innermost to outermost, are:
- Entities: Core business objects representing domain rules (e.g., a
Personwith business logic). - Use Cases: Application-specific business rules (e.g., "create a person" or "validate a person’s email").
- Interface Adapters: Convert data between formats usable by inner layers and external agencies (e.g., controllers, presenters, repositories).
- Frameworks & Drivers: External tools like databases, web frameworks, or UI libraries—treated as "details."
Key Principle: Inner layers contain business rules; outer layers contain implementation details. Dependencies must flow inward: outer layers depend on inner layers, but inner layers know nothing about outer layers.
2. The Dependency Rule in Action#
The Dependency Rule is the backbone of Clean Architecture. To visualize:
Imagine a series of circles. The innermost circle is "Entities," surrounded by "Use Cases," then "Interface Adapters," and finally "Frameworks & Drivers." All source code dependencies must point toward the center.
- An Entity (inner) never imports code from Use Cases, Interface Adapters, or Frameworks.
- A Use Case may depend on Entities but not on Interface Adapters or Frameworks.
- An Interface Adapter may depend on Use Cases or Entities but not on Frameworks.
- Frameworks & Drivers depend on Interface Adapters, Use Cases, or Entities (but never the reverse).
This ensures inner layers (business logic) remain isolated from external changes (e.g., switching databases or web frameworks).
3. Microservices and the Database Layer: Why Separation Matters#
Microservices are designed for autonomy: each service owns its data and can evolve independently. However, even within a single microservice, treating the database as a core dependency is risky. Here’s why separation matters:
- Autonomy: If a service is tightly coupled to a specific database (e.g., PostgreSQL), migrating to MongoDB later requires rewriting core logic.
- Testability: Testing business logic shouldn’t require a live database. With separation, you can mock the database layer.
- Decoupling: Database schema changes (e.g., adding a column) won’t break inner layers if the adapter layer absorbs the change.
In Clean Architecture, the database is an external detail—part of "Frameworks & Drivers." The microservice’s core (Entities, Use Cases) must never depend on the database. Instead, the database layer adapts to the core.
4. Designing the Person CRUD Microservice#
Let’s design a microservice for CRUD operations on a Person entity. We’ll map this to Clean Architecture layers to ensure the database remains an outer detail.
Requirements#
- Entity:
Personwith fieldsid(UUID),name(string),email(string), andcreatedAt(timestamp). - Use Cases: Create, retrieve, update, and delete
Personrecords. - Database: Persist
Persondata (we’ll use PostgreSQL for this example, but it should be replaceable).
Mapping to Clean Architecture Layers#
Layer 1: Entities (Innermost)#
The Person entity is a plain old Java object (POJO) with no external dependencies. It contains only domain logic (e.g., validating an email format).
// Entity Layer (No framework imports!)
public class Person {
private final UUID id;
private final String name;
private final String email;
private final LocalDateTime createdAt;
public Person(UUID id, String name, String email, LocalDateTime createdAt) {
validateEmail(email); // Business rule: email must be valid
this.id = id;
this.name = name;
this.email = email;
this.createdAt = createdAt;
}
private void validateEmail(String email) {
if (!email.contains("@")) {
throw new IllegalArgumentException("Invalid email");
}
}
// Getters (no setters—entities are immutable by design)
public UUID getId() { return id; }
public String getName() { return name; }
public String getEmail() { return email; }
public LocalDateTime getCreatedAt() { return createdAt; }
}Layer 2: Use Cases#
Use Cases define application-specific logic. For CRUD, we’ll create a PersonUseCase class that depends on a PersonRepository interface (defined here, in the Use Cases layer).
// Use Cases Layer (Depends on Entity and Repository Interface)
public class PersonUseCase {
private final PersonRepository personRepository;
// Dependency injection: Use Case receives a Repository (interface, not implementation)
public PersonUseCase(PersonRepository personRepository) {
this.personRepository = personRepository;
}
// Create a person
public Person createPerson(String name, String email) {
Person person = new Person(UUID.randomUUID(), name, email, LocalDateTime.now());
return personRepository.save(person);
}
// Get a person by ID
public Person getPerson(UUID id) {
return personRepository.findById(id)
.orElseThrow(() -> new RuntimeException("Person not found"));
}
// Update a person's email
public Person updatePersonEmail(UUID id, String newEmail) {
Person existingPerson = getPerson(id);
// Create a new immutable Person with updated email
Person updatedPerson = new Person(
existingPerson.getId(),
existingPerson.getName(),
newEmail,
existingPerson.getCreatedAt()
);
return personRepository.save(updatedPerson);
}
// Delete a person
public void deletePerson(UUID id) {
personRepository.deleteById(id);
}
}
// Repository Interface (Defined in Use Cases Layer—inner layer owns the contract)
public interface PersonRepository {
Person save(Person person);
Optional<Person> findById(UUID id);
void deleteById(UUID id);
}Key Point: The PersonRepository interface is defined in the Use Cases layer. This ensures the Use Case depends on an abstraction, not a concrete database implementation.
5. Step-by-Step Implementation: The Outer Database Layer#
Now, we’ll implement the outer database layer. This layer lives in "Frameworks & Drivers" and adapts database operations to the PersonRepository interface defined in the Use Cases layer.
Step 1: Define the Database Entity (ORM Model)#
Most databases use ORMs (e.g., Hibernate, JPA) to map objects to tables. We’ll create a PersonJpaEntity—an ORM-specific model that mirrors the database schema. This lives in the outer layer and is never referenced by inner layers.
// Frameworks & Drivers Layer (Database Detail)
import jakarta.persistence.*; // JPA annotations (outer detail)
@Entity
@Table(name = "persons")
public class PersonJpaEntity {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
private String name;
private String email;
private LocalDateTime createdAt;
// JPA requires a no-arg constructor (implementation detail)
public PersonJpaEntity() {}
// Convert JPA Entity to Domain Entity (inner layer)
public Person toDomainEntity() {
return new Person(id, name, email, createdAt);
}
// Convert Domain Entity to JPA Entity (inner → outer)
public static PersonJpaEntity fromDomainEntity(Person person) {
PersonJpaEntity jpaEntity = new PersonJpaEntity();
jpaEntity.id = person.getId();
jpaEntity.name = person.getName();
jpaEntity.email = person.getEmail();
jpaEntity.createdAt = person.getCreatedAt();
return jpaEntity;
}
// Getters and setters (required by JPA)
}Step 2: Implement the Repository Interface#
Next, we’ll create PersonRepositoryImpl—the concrete implementation of PersonRepository (from the Use Cases layer). This class uses JPA to interact with PostgreSQL but adapts the database operations to the domain model.
// Frameworks & Drivers Layer (Database Detail)
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.springframework.stereotype.Repository; // Spring-specific (outer detail)
@Repository // Framework annotation (outer detail)
public class PersonRepositoryImpl implements PersonRepository {
@PersistenceContext // JPA's EntityManager (injected by framework)
private EntityManager entityManager;
@Override
public Person save(Person person) {
// Convert domain entity to JPA entity (outer layer adapts to inner)
PersonJpaEntity jpaEntity = PersonJpaEntity.fromDomainEntity(person);
entityManager.persist(jpaEntity); // Save to database
return jpaEntity.toDomainEntity(); // Return domain entity to inner layer
}
@Override
public Optional<Person> findById(UUID id) {
PersonJpaEntity jpaEntity = entityManager.find(PersonJpaEntity.class, id);
return jpaEntity != null ? Optional.of(jpaEntity.toDomainEntity()) : Optional.empty();
}
@Override
public void deleteById(UUID id) {
PersonJpaEntity jpaEntity = entityManager.find(PersonJpaEntity.class, id);
if (jpaEntity != null) {
entityManager.remove(jpaEntity);
}
}
}Step 3: Wire It All Together with Dependency Injection#
Finally, we use a framework like Spring to inject the PersonRepositoryImpl into PersonUseCase. The Use Case remains unaware of the concrete repository—it only depends on the PersonRepository interface.
// Framework Configuration (Frameworks & Drivers Layer)
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class AppConfig {
@Bean
public PersonUseCase personUseCase(PersonRepository personRepository) {
return new PersonUseCase(personRepository); // Inject repository impl
}
}6. Testing the Database Layer Without Violating Dependencies#
Testing is where Clean Architecture shines. We can test inner layers (business logic) in isolation and outer layers (database) separately.
Unit Testing the Use Case (Inner Layer)#
To test PersonUseCase, we mock PersonRepository (since the Use Case depends on the interface, not the concrete implementation). No live database needed!
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.Optional;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
public class PersonUseCaseTest {
@Mock // Mock the repository interface
private PersonRepository personRepository;
@InjectMocks // Inject mock into Use Case
private PersonUseCase personUseCase;
@Test
void createPerson_ShouldSaveAndReturnPerson() {
// Arrange
Person mockPerson = new Person(UUID.randomUUID(), "Alice", "[email protected]", LocalDateTime.now());
when(personRepository.save(any(Person.class))).thenReturn(mockPerson);
// Act
Person result = personUseCase.createPerson("Alice", "[email protected]");
// Assert
assertEquals("Alice", result.getName());
assertEquals("[email protected]", result.getEmail());
}
}Integration Testing the Database Layer (Outer Layer)#
To test PersonRepositoryImpl, we use an in-memory database (e.g., H2) to validate database interactions. This tests the adapter layer without affecting inner layers.
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
@DataJpaTest // Spring test annotation for JPA repositories
public class PersonRepositoryImplTest {
@Autowired
private PersonRepositoryImpl personRepository;
@Test
void save_ShouldPersistPerson() {
// Arrange
Person person = new Person(UUID.randomUUID(), "Bob", "[email protected]", LocalDateTime.now());
// Act
Person savedPerson = personRepository.save(person);
// Assert
assertNotNull(savedPerson.getId());
assertEquals("Bob", savedPerson.getName());
}
}7. Common Pitfalls and How to Avoid Them#
Pitfall 1: Leaking ORM Entities into Inner Layers#
Problem: Using PersonJpaEntity (ORM model) in PersonUseCase (Use Cases layer) creates a dependency on the database.
Solution: Always map ORM entities to domain entities in the adapter layer (as shown in PersonJpaEntity.toDomainEntity()).
Pitfall 2: Business Logic in the Repository#
Problem: Adding logic like "validate email format" in PersonRepositoryImpl.
Solution: Business logic belongs in Entities or Use Cases. The repository’s job is only to persist data.
Pitfall 3: Hardcoding Database Details in Use Cases#
Problem: Referencing table names or SQL queries in Use Cases.
Solution: Database details (e.g., table names) belong in the ORM model (PersonJpaEntity). Use configuration files (e.g., application.properties) for connection strings.
8. Conclusion#
In Clean Architecture, the database layer is an outer detail—a tool to persist data, not a core part of the system. By following the Dependency Rule, we ensure inner layers (business logic) remain isolated from database changes.
For microservices, this separation is critical: it enables autonomy, simplifies testing, and future-proofs the service against database migrations. The key steps are:
- Define domain entities and repository interfaces in inner layers.
- Implement the repository in the outer layer, adapting database operations to the domain model.
- Test inner layers with mocks and outer layers with integration tests.
By treating the database as a replaceable detail, you build microservices that are resilient, maintainable, and ready to evolve.
9. References#
- Martin, R. C. (2018). Clean Architecture: A Craftsman’s Guide to Software Structure and Design. Prentice Hall.
- Fowler, M. (2014). Microservices Guide. martinfowler.com/articles/microservices.html
- Freeman, S., & Pryce, N. (2009). Growing Object-Oriented Software, Guided by Tests. Addison-Wesley.
- Repository Pattern. martinfowler.com/eaaCatalog/repository.html