Recently a colleague of mine showed me how MediatR works - a .NET implementation of the mediator design pattern which, in short, "define an object that encapsulates how a set of objects interact"¹.
The idea sounded interesting considering that, unless a framework requires some specific way to set up the files and their responsibilities, my default approach for most projects is to use the 3-Tier Architecture or layered architecture.
Don't get me wrong: I still think that having a presentation, business and persistence layer (or some variant of this) still works for most cases and should be the way to go². However, I had a personal project (written in Java with SpringBoot and Spring Data JPA) that had grown in size and complexity and wanted to try something 'new' while refactoring.
The first thing was to find a Java equivalent of MediatR. While looking for alternatives I came across PipelinR. The source code was more or less what I expected for the proposed functionality so it was time to start playing with it.
Before we get into refactoring part, I'd like to show what the project originally looked like:
Package structure (inspired by the BCE pattern)
├── boundary
│ └── ColorsResource.java
├── control
│ ├── ColorRepository.java
│ └── ColorService.java
└── entity
└── Color.java
ColorResource.java
:
@RestController
@RequestMapping("/colors")
public class ColorsResource {
private final ColorService service;
public ColorsResource(final ColorService service) {
this.service = service;
}
@PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Color> insert(@Valid @RequestBody Color color) {
var data = service.insert(color);
var uri = ServletUriComponentsBuilder.fromCurrentRequest().path("/{id}")
.buildAndExpand(data.getId()).toUri();
return ResponseEntity.created(uri).body(data);
// (other endpoints omitted)
}
ColorService.java
@Service
public class ColorsService {
private final ColorRepository repository;
public ColorsService(final ColorRepository repository) {
this.repository = repository;
}
public Color insert(Color color) {
try {
return repository.save(color);
} catch (DataIntegrityViolationException e) {
throw new ResponseStatusException(
HttpStatus.CONFLICT,
MessageFormat.format("A Color with name [{0}] already exists.", color.getName()));
}
// (other methods omitted)
}
ColorRepository.java
@Repository
public interface ColorRepository extends JpaRepository<Color, Integer> {
}
Color.java
@Entity
@Table(name = "colors")
public class Color {
private static final long serialVersionUID = 4L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
@NotBlank(message = "Color.name cannot be blank.")
@Column(unique = true, nullable = false)
private String name;
@Column(name = "created_date", nullable = false, updatable = false)
private OffsetDateTime createdDate;
@Column(name = "updated_date")
private OffsetDateTime updatedDate;
// (constructor, getters and setters omitted)
}
Pipeline, Commands and Handlers
The first step to start using Pipelinr is to add the Maven dependency and the repository where it's hosted:
<dependency>
<groupId>an.awesome</groupId>
<artifactId>pipelinr</artifactId>
<version>0.5</version>
</dependency>
<repositories>
<repository>
<id>central</id>
<name>bintray</name>
<url>https://jcenter.bintray.com</url>
</repository>
</repositories>
The second step is to create a managed instance of a Pipeline to be injected in our classes:
@Configuration
public class PipelinrProvider {
@Bean
public Pipeline getPipeline(
ObjectProvider<Handler> commandHandlers,
ObjectProvider<Notification.Handler> notificationHandlers,
ObjectProvider<Command.Middleware> middlewares) {
return new Pipelinr()
.with(commandHandlers::stream) // Registers Handlers
.with(notificationHandlers::stream) // Registers Notifications (not covered here)
.with(middlewares::orderedStream); // Registers Middlewares (not covered here)
}
}
With this in place we can now start refactoring.
Commands
Roughly speaking, commands are objects that encapsulate the details of a request and capture its intent³. In our case, we expose a POST endpoint with the intent of creating a new color. So let's create two commands: one for capturing the color creation intent and one with the outcome of the intent (a created color).
ColorResponseCommand.java
public final class ColorResponseCommand {
private final int id;
private final String name;
private final OffsetDateTime createdDate;
private ColorResponseCommand(final int id,
final String name,
final OffsetDateTime createdDate) {
this.id = id;
this.name = name;
this.createdDate = createdDate;
}
public static ColorResponseCommand from(final Color color) {
return new ColorResponseCommand(color.getId(), color.getName(), color.getCreatedDate());
}
// getters omitted
}
CreateColorRequestCommand.java
public class CreateColorRequestCommand implements Command<ColorResponseCommand> {
// Only name is required when creating a Color
@NotBlank(message = "Name must not be blank.")
private final String name;
public CreateColorRequestCommand(final String name) {
this.name = name;
}
public String getName() {
return name;
}
}
Do note that the class above implements a Command interface from Pipelinr, defining the response as its parameterized type.
Handlers
Handlers in Pipelinr are classes that know how what to do with commands. It's very much similar to how Chain of Responsibility pattern works⁴. Since our use case is to create a color, let's add a handler for that:
@Component
public class CreateColorHandler implements
Command.Handler<CreateColorRequestCommand, ColorResponseCommand> {
private final ColorRepository repository;
public CreateColorHandler(final ColorRepository repository) {
this.repository = repository;
}
@Override
public ColorResponseCommand handle(final CreateColorRequestCommand command) {
var color = new Color();
color.setName(command.getName());
try {
var createdColor = repository.save(color);
return ColorResponseCommand.from(createdColor);
} catch (DataIntegrityViolationException e) {
throw new ResponseStatusException(
HttpStatus.CONFLICT,
MessageFormat
.format("A Color with name [{0}] already exists.", command.getName()));
}
}
}
There are a few things to notice:
- Our managed class implements Command.Handler, which defines that this handler will receive a
CreateColorRequestCommand
and respond with aColorResponseCommand
. - By implementing the interface mentioned above and making the instance managed (
@Component
annotation), this handler will be registered and available for use by the Pipeline instance (more on this on the next topic).
Pipeline
It's now time to put the handler and commands in action. As seen previously, our ColorsResource.java
delegated the creation of a Color
to the injected ColorService.java
. Instead, let's use the Pipeline instance we defined earlier. It works as a mediator between commands and handlers as it knows to which handler to dispatch each command in the application.
@RestController
@RequestMapping("/colors")
public class ColorsResource {
private final Pipeline pipeline;
public ColorsResource(final Pipeline pipeline) {
this.pipeline = pipeline;
}
@PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<ColorResponseCommand> insert(@Valid @RequestBody CreateColorRequestCommand command) {
var data = pipeline.send(command);
var uri = ServletUriComponentsBuilder.fromCurrentRequest().path("/{id}")
.buildAndExpand(data.getId()).toUri();
return ResponseEntity.created(uri).body(data);
}
}
With this new setup, we can now get rid of our ColorService.java
- which in a real world would have several methods - and instead create handlers and commands for each use case.
Let's see some of the pros and cons of adopting such solution.
Pros
- Decouples the invoker and receiver of a command by relying on a mediator to dispatch it to the right handler.
- Unit testing is simpler by having only the bare minimum to satisfy the dependencies of handlers and commands.
- The file names reflect the application use cases, making navigation clearer (debatable).
- Each command exposes only the attributes needed for a particular use case, avoiding sending and receiving fields that should not be applicable/exposed in all use cases.
- Reduces the possibility of merge conflicts in big teams, since use cases are decentralized from a single Service file.
- Simplifies adding new features/use cases to the application. As new handlers and commands are likely to be needed, existing code might not be affected.
Cons
- The IDE won't be your friend when trying to navigate to correct handler via the
pipeline.send()
method. - Each new use case requires a new handler and at least one new command to be created, resulting in a lot of classes the project.
- Depending on the implementation of the mediator/pipeline object, there could be some performance impact.
Conclusion
Let's be honest: it's a lot of code for something as simple as CRUD operations. But this approach shines in certain scenarios, listed in the Pros section above.
While the examples shown here relied on Pipelinr, there are other alternatives out there (such as https://github.com/kmhigashioka/ShortBus) that embrace the same concepts.
The final code can be found at https://github.com/davibandeira/pipelinr-demo.
Let me know your thoughts about this and other approaches ;)
¹ Gamma, Erich- Design Patterns: Elements of Reusable Object-Oriented Software: Addison-Wesley, 1994.
² YAGNI and KISS principles.
³ https://refactoring.guru/design-patterns/command
⁴ https://sourcemaking.com/design_patterns/chain_of_responsibility
Top comments (2)
Hi Davi,
I was looking for this exact thing, I've used MediatR in several of my projects and I loved it. Thank you very much! Keep up the good work.
Hi Emmanuel! Thank you for the kind words. I'm glad this was helpful to you. Take care!