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.