cyberangles blog

Clean Architecture Design Pattern: How Does the Outer Database Layer Work in Microservices? (Person CRUD Example Without Violating the Dependency Rule)

In the world of software architecture, the quest for maintainable, scalable, and testable systems is never-ending. Enter Clean Architecture—a paradigm popularized by Robert C. Martin (Uncle Bob) that emphasizes separation of concerns, independence from frameworks, and a strict focus on business logic. At its core lies the Dependency Rule, which states: "Source code dependencies must point only inward, toward higher-level policies. Nothing in an inner circle can know anything about an outer circle."

Now, pair Clean Architecture with microservices—an architectural style where applications are decomposed into loosely coupled, independently deployable services—and you unlock even greater flexibility. But here’s the catch: microservices often rely heavily on databases, and databases are external "details" (in Clean Architecture terms). How do you design the database layer in a microservice without violating the Dependency Rule? How do you ensure your service remains decoupled from its database, even as requirements evolve?

In this blog, we’ll demystify the role of the outer database layer in a Clean Architecture-based microservice. Using a practical Person CRUD (Create, Read, Update, Delete) example, we’ll walk through the design, implementation, and testing of the database layer—all while strictly adhering to the Dependency Rule. By the end, you’ll understand how to treat databases as replaceable details, not core dependencies.

2026-02

Table of Contents#

  1. Understanding Clean Architecture Basics
  2. The Dependency Rule in Action
  3. Microservices and the Database Layer: Why Separation Matters
  4. Designing the Person CRUD Microservice
  5. Step-by-Step Implementation: The Outer Database Layer
  6. Testing the Database Layer Without Violating Dependencies
  7. Common Pitfalls and How to Avoid Them
  8. Conclusion
  9. 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 Person with 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: Person with fields id (UUID), name (string), email (string), and createdAt (timestamp).
  • Use Cases: Create, retrieve, update, and delete Person records.
  • Database: Persist Person data (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:

  1. Define domain entities and repository interfaces in inner layers.
  2. Implement the repository in the outer layer, adapting database operations to the domain model.
  3. 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#