During my limited professional experience, I have been involved in microservices projects with a common structure:
- The Controller takes a request and validates it using the jakarta.validation.Valid annotation. After validation, it passes the request to the service.
- Inside the service layer, a Mapper component maps the request DTO to an entity, executes some logic, and then uses the Mapper again to map the entity to a response DTO. Finally, it returns the response.
- The controller returns the response to the user.
This conventional approach, widely used in tutorials, often leads to service layers performing too many tasks, violating the principle of single responsibility. The service layer can become a large class, just by dealing with CRUD operations.
Prompted by the question "Is this the only way to do it?" and the realization that it's not, I've been exploring alternative architectures. I've developed the Coordinator-Architecture, which involves the following components:
- Controller
- Coordinator
- Validator
- MapperInput
- Service
- MapperOutput
The Controller passes the request to the Coordinator, which processes the logic in 5 steps:
- Validate the REQUEST with the Validator.
- Map the REQUEST to DOMAIN_INPUT with MapperInput.
- Execute domain logic with the Service.
- Map DOMAIN_OUTPUT to RESPONSE with MapperOutput.
- Return the RESPONSE.
The Coordinator is based on the following interface:
public interface Coordinator<REQUEST, RESPONSE> {
RESPONSE process(REQUEST request) throws FunctionalException, TechnicalException;
}
and an AbstractCoordinator implements the process as follows:
@RequiredArgsConstructor
public abstract class AbstractCoordinator<REQUEST, DOMAIN_INPUT, DOMAIN_OUTPUT, RESPONSE> implements Coordinator<REQUEST, RESPONSE> {
private final Validator<REQUEST> validator;
private final MapperInput<REQUEST, DOMAIN_INPUT> mapperInput;
private final ServiceInputOutputAware<DOMAIN_INPUT, DOMAIN_OUTPUT> service;
private final MapperOutput<DOMAIN_OUTPUT, RESPONSE> mapperOutput;
@Override
public RESPONSE process(REQUEST request) throws FunctionalException, TechnicalException {
// STEP 1: validate the request
validator.validate(request);
// STEP 2: map request to domain object
DOMAIN_INPUT domainInput = mapperInput.toDomain(request);
// STEP 3: execute domain logic
DOMAIN_OUTPUT domainOutput = service.execute(domainInput);
// STEP 4: map domain object to response
RESPONSE response = mapperOutput.toResponse(domainOutput);
// STEP 5: return the response
return response;
}
}
This method is able to handle various scenarios:
- Passing a request and returning a response.
- No request but returning a response.
- Having a request but not returning any response.
However, in scenarios where I don't have the parameterized type, I have to pass Void
, which is not elegant:
- if the parameterized type is for an input, the method will have a parameter of Void which will never be used.
- if the parameterized type is used as output, then the method implementation will requires writing `return null` as the last line.
For that reason I have created some variants of Coordinator for each scenario:
1 - CoordinatorRequestResponseAware
public interface CoordinatorRequestResponseAware<REQUEST, RESPONSE> {
RESPONSE process(REQUEST request) throws FunctionalException, TechnicalException;
}
2 - CoordinatorResponseAware
public interface CoordinatorResponseAware<RESPONSE> {
RESPONSE process() throws FunctionalException, TechnicalException;
}
3 - CoordinatorRequestAware
public interface CoordinatorRequestAware<REQUEST> {
void process(REQUEST request) throws FunctionalException, TechnicalException;
}
My question is, from an architectural point of view, is it better to be flexible and provide different interfaces for all scenarios, or is it better to be more strict so all the implementations behave to one single interface?
I will truly appreciate any review! I'm looking to improve myself as a developer.
All the code can be found on the GitHub repository: Coordinator-Architecture