0

During my limited professional experience, I have been involved in microservices projects with a common structure:

  1. The Controller takes a request and validates it using the jakarta.validation.Valid annotation. After validation, it passes the request to the service.
  2. 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.
  3. 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:

  1. Validate the REQUEST with the Validator.
  2. Map the REQUEST to DOMAIN_INPUT with MapperInput.
  3. Execute domain logic with the Service.
  4. Map DOMAIN_OUTPUT to RESPONSE with MapperOutput.
  5. 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&lt;REQUEST&gt; validator;

private final MapperInput&lt;REQUEST, DOMAIN_INPUT&gt; mapperInput;

private final ServiceInputOutputAware&lt;DOMAIN_INPUT, DOMAIN_OUTPUT&gt; service;

private final MapperOutput&lt;DOMAIN_OUTPUT, RESPONSE&gt; 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:

  1. if the parameterized type is for an input, the method will have a parameter of Void which will never be used.
  2. 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

  • 2
    What you have described is basically the Mediator Pattern. Unfortunately we don't do design reviews, however if you have a specific problem with the approach you've outlined here, consider an [edit] to your question - even if you might be implementing a well-known design pattern. Such questions are perfectly on-topic for this community. – Greg Burghardt Dec 05 '23 at 00:18
  • 1
    I'd lean on the side of many classes with narrow, well defined responsibilities, with equally narrow interfaces (by which I just mean the set of public members). This doesn't mean that a class cannot facilitate "multiple things" to happen by delegating to contained classes (or even to virtual methods of child classes), it's just that the responsibility of that class is to "orchestrate" a process - the pitfall there is if you try and make the orchestrator class have a "pass through" interface, exposing "for convenience" the underlying stuff (doing that defeats its purpose). – Filip Milovanović Dec 06 '23 at 08:18
  • No, there are no problems, maybe I just have to found a better name for each type of coordinator : https://github.com/paulmarcelinbejan/Coordinator-Architecture/tree/release/src/main/java/io/github/paulmarcelinbejan/coordinator/architecture/coordinator/base – Paul Marcelin Bejan Dec 06 '23 at 19:30

0 Answers0