Structuring Spring Boot Applications

Clean Architecture with Controller, Mapper, Service, and Repository

Designing a Clean Architecture with Controller, Mapper, Service, and Repository

When building modern Spring Boot applications, organizing code in layers brings clarity, separation of concerns, and easier testing. One of the most common and effective approaches is using the Controller → Service → Mapper → Repository flow.

In this post, I’ll walk you through how we typically handle create, update, and delete operations using this layered strategy—with code samples.


1. Create Operation (POST)

When a client sends a request to create a new resource, here’s how the flow works:

  • Controller: The endpoint is mapped using @PostMapping. We receive the request body as a DTO (e.g., MyEntityDto) and forward it to the service layer.
  • Service: The service receives the DTO and delegates the conversion to a mapper, which transforms the DTO into an entity.
  • Repository: The service calls the repository to persist the new entity.
  • Back to Service: Once saved, the service maps the entity back to a DTO.
  • Controller Response: The controller returns a 201 Created response with the DTO of the newly created resource.

Controller

@PostMapping
public ResponseEntity<MyEntityDto> create(@RequestBody MyEntityDto dto) {
    MyEntityDto created = myService.create(dto);
    return ResponseEntity.status(HttpStatus.CREATED).body(created);
}

Service

public MyEntityDto create(MyEntityDto dto) {
    MyEntity entity = mapper.toEntity(dto);
    MyEntity saved = repository.save(entity);
    return mapper.toDto(saved);
}

Mapper (MapStruct)

@Mapper(componentModel = "spring")
public interface MyEntityMapper {
    MyEntity toEntity(MyEntityDto dto);
    MyEntityDto toDto(MyEntity entity);
}

2. Update Operation (PATCH)

When a partial update is requested:

  • Controller: We map a @PatchMapping, usually with the resource ID as a path variable and the incoming updates as a DTO in the request body.
  • Service: The service retrieves the existing entity from the database.
  • Mapper: Instead of replacing the entity, we patch it — the mapper applies non-null values from the DTO onto the existing entity.
  • Repository: The patched entity is saved.
  • Back to Service: The saved entity is then mapped to a DTO.
  • Controller Response: The updated DTO is returned with a 200 OK.

Controller

@PatchMapping("/{id}")
public ResponseEntity<MyEntityDto> update(@PathVariable Long id, @RequestBody MyEntityDto dto) {
    MyEntityDto updated = myService.patch(id, dto);
    return ResponseEntity.ok(updated);
}

Service

public MyEntityDto patch(Long id, MyEntityDto dto) {
    MyEntity entity = repository.findById(id)
        .orElseThrow(() -> new EntityNotFoundException("Entity not found"));
    
    mapper.patch(dto, entity); // modifies the existing entity
    MyEntity saved = repository.save(entity);
    return mapper.toDto(saved);
}

Mapper (MapStruct with @MappingTarget)

@Mapper(componentModel = "spring")
public interface MyEntityMapper {
    MyEntity toEntity(MyEntityDto dto);
    MyEntityDto toDto(MyEntity entity);

    void patch(MyEntityDto dto, @MappingTarget MyEntity entity);
}

3. Delete Operation (DELETE)

Deletion is straightforward:

  • Controller: We use @DeleteMapping with the resource ID.
  • Service: The service can either delete directly using the ID or check if the entity exists before deleting.
  • Repository: The deletion is executed.
  • Controller Response: A 204 No Content response indicates successful deletion.

Controller

@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id) {
    myService.delete(id);
    return ResponseEntity.noContent().build();
}

Service

public void delete(Long id) {
    if (!repository.existsById(id)) {
        throw new EntityNotFoundException("Entity not found");
    }
    repository.deleteById(id);
}

Summary

This architecture keeps responsibilities well-separated:

  • Controller: Handles HTTP-specific concerns.
  • Service: Handles business logic and flow coordination.
  • Mapper: Handles conversion between DTOs and entities.
  • Repository: Talks directly to the database.

This layered approach not only leads to cleaner code but also makes it easier to maintain, test, and scale.

Testing Tips for Each Layer

A clean architecture not only makes development easier—it also enables layered testing. By testing each layer in isolation, you can catch bugs early, simplify debugging, and improve confidence during refactoring.

Here’s how to approach testing for each part of the stack:


1. Testing the Controller (Web Layer Test)

Use @WebMvcTest to test only the controller layer, mocking the service.

@WebMvcTest(MyController.class)
class MyControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private MyService myService;

    @Test
    void testCreate_shouldReturn201() throws Exception {
        MyEntityDto inputDto = new MyEntityDto("Example");
        MyEntityDto outputDto = new MyEntityDto("Example", 1L);

        Mockito.when(myService.create(any())).thenReturn(outputDto);

        mockMvc.perform(post("/entities")
                .contentType(MediaType.APPLICATION_JSON)
                .content(asJsonString(inputDto)))
            .andExpect(status().isCreated())
            .andExpect(jsonPath("$.id").value(1L));
    }

    private static String asJsonString(Object obj) throws JsonProcessingException {
        return new ObjectMapper().writeValueAsString(obj);
    }
}

Tips:

  • Use MockMvc to simulate HTTP requests.
  • Mock the service layer to isolate controller logic.
  • Validate HTTP status and response content.

2. Testing the Service Layer

Use unit tests with mocked repository and mapper.

@ExtendWith(MockitoExtension.class)
class MyServiceTest {

    @InjectMocks
    private MyService myService;

    @Mock
    private MyRepository repository;

    @Mock
    private MyEntityMapper mapper;

    @Test
    void testCreate_shouldReturnDto() {
        MyEntityDto dto = new MyEntityDto("Test");
        MyEntity entity = new MyEntity("Test");
        MyEntity saved = new MyEntity("Test", 1L);
        MyEntityDto resultDto = new MyEntityDto("Test", 1L);

        Mockito.when(mapper.toEntity(dto)).thenReturn(entity);
        Mockito.when(repository.save(entity)).thenReturn(saved);
        Mockito.when(mapper.toDto(saved)).thenReturn(resultDto);

        MyEntityDto result = myService.create(dto);
        assertEquals(1L, result.getId());
    }
}

Tips:

  • Mock all dependencies (repository, mapper).
  • Focus on business logic and flow.
  • Assert results and interactions.

3. Testing the Mapper (MapStruct)

If using MapStruct, it’s usually safe to trust the code generation, but you can still write basic tests.

class MyEntityMapperTest {

    private final MyEntityMapper mapper = Mappers.getMapper(MyEntityMapper.class);

    @Test
    void testToDto_shouldMapCorrectly() {
        MyEntity entity = new MyEntity("Test", 1L);
        MyEntityDto dto = mapper.toDto(entity);
        assertEquals("Test", dto.getName());
        assertEquals(1L, dto.getId());
    }
}

Tips:

  • Use MapStruct’s Mappers.getMapper(...) for standalone tests.
  • Test edge cases (nulls, empty strings, etc.).

4. Testing the Repository

Use @DataJpaTest to run tests against an in-memory database (H2 by default).

@DataJpaTest
class MyRepositoryTest {

    @Autowired
    private MyRepository repository;

    @Test
    void testSaveAndFind() {
        MyEntity entity = new MyEntity("Test");
        MyEntity saved = repository.save(entity);

        Optional<MyEntity> found = repository.findById(saved.getId());
        assertTrue(found.isPresent());
        assertEquals("Test", found.get().getName());
    }
}

Tips:

  • Focus on persistence behavior (save, find, delete).
  • Use H2 for fast, isolated tests.
  • Rollback after each test automatically with @DataJpaTest.

Final Thoughts

Testing each layer separately brings peace of mind during development. Combined with the clean architecture of Controller → Service → Mapper → Repository, you can build robust, maintainable applications with confidence.