Hexagonal architecture in a real project
Let's trace a real request through the project, you can find it on my GitHub at PlazoletaUsers and I used the template from Pragma. If you're a beginner, I previously wrote a post explaining the basics of hexagonal architecture.

The three blocks
The project has three top-level packages: domain, application, and infrastructure.
domain/
├── model/ business classes, no framework
├── api/ input ports (use cases)
├── spi/ output ports for domain
├── usecase/ business logic implementation
└── exception/ business errors
application/
├── dto/ objects sent over http
├── handler/ orchestrates the call
└── mapper/ dto to domain mapping
infrastructure/
├── input.rest/ http entry point
├── out.jpa/ database adapter (jpa)
├── exceptionhandler/ exception to http response
└── configuration/ wires everything together
One request, from HTTP to the database
Say an admin creates a new propietario (owner). The request hits POST /api/v1/users.
@PreAuthorize("hasRole('ADMINISTRADOR')")
@PostMapping("/")
public ResponseEntity<Void> saveUser(@Valid @RequestBody UserRequestDto userRequestDto) {
userHandler.saveUser(userRequestDto);
return new ResponseEntity<>(HttpStatus.CREATED);
}The controller does no logic. It just calls the handler.
@Override
public void saveUser(UserRequestDto userRequestDto) {
userRequestDto.setRol(String.valueOf(RolType.PROPIETARIO));
UserModel userModel = userRequestMapper.toUser(userRequestDto);
userServicePort.saveUser(userModel);
}The handler sets the role, then hands the conversion to the mapper. This mapper has a small trick worth showing:
@Mapping(target = "rol", source = "rol", qualifiedByName = "stringToRolModel")
UserModel toUser(UserRequestDto userRequestDto);
@Named("stringToRolModel")
default RolModel stringToRolModel(String rol) {
if (rol == null) return null;
return RolModel.builder().nombre(RolType.valueOf(rol.toUpperCase())).build();
}The DTO carries the role as a plain string. The mapper turns it into a RolModel before it enters the domain. The domain never touches a raw string for a role.
Now the domain does its actual job:
@Override
public UserModel saveUser(UserModel user) {
if (!isOfLegalAge(user.getFechaNacimiento())) {
throw new UserNotAdultException();
}
RolModel rol = rolPersistencePort.getByName(user.getRol().getNombre());
String hashedPassword = passwordEncoderPort.encode(user.getClave());
user.setClave(hashedPassword);
user.setRol(rol);
return userPersistencePort.saveUser(user);
}This is the interesting part. UserUseCase checks the age, hashes the password, and looks up the full role. It doesn't know if the password encoder is BCrypt or something else. It doesn't know if the data ends up in Postgres or in a mock. It just calls ports.
The real save happens in the adapter:
@Override
public UserModel saveUser(UserModel user) {
UserEntity userEntity = userRepository.save(userEntityMapper.toEntity(user));
return userEntityMapper.toUserModel(userEntity);
}UserJpaAdapter converts the domain model into a JPA entity, saves it, and converts the result back. The response climbs back up: adapter → use case → handler → response mapper → controller → HTTP response.
The exception that skips two layers
If the user is under 18, UserUseCase throws UserNotAdultException. This class lives in the domain. It knows nothing about HTTP.
public class UserNotAdultException extends DomainException {
public UserNotAdultException() {
super("El usuario debe ser mayor de edad");
}
}ControllerAdvisor, in infrastructure, catches any DomainException and turns it into a 409:
@ExceptionHandler(DomainException.class)
public ResponseEntity<CustomResponse<Void>> handleDomainException(DomainException ex) {
CustomResponse<Void> response = CustomResponse.<Void>builder()
.status(HttpStatus.CONFLICT.value())
.error(ex.getMessage())
.message(ex.getMessage())
.build();
return ResponseEntity.status(HttpStatus.CONFLICT.value()).body(response);
}The domain decides what went wrong. Infrastructure decides how to tell the client. That split is the whole point of hexagonal architecture, and here it actually holds.
Frequently asked questions
What is hexagonal architecture in Spring Boot?
It's a way to split a project into three layers: domain (business logic, no framework code), application (translates between HTTP and the domain), and infrastructure (everything technical — controllers, JPA, config). The domain never depends on the other two.
What are ports and adapters?
A port is an interface. Input ports (domain.api, like IUserServicePort) declare what the system can do. Output ports (domain.spi, like IUserPersistencePort) declare what the domain needs from the outside world. Adapters, in infrastructure, implement those ports with real technology: a REST controller, a JPA repository.
Why doesn't the domain layer have framework annotations?
So the business logic stays testable and portable. UserUseCase doesn't know if it's running inside Spring, being called from a test, or wired into something else entirely. It just needs its ports.
Comments