I've been struggling with coming up with a way to handle validation in my command bus architecture.
Throwing validation exceptions in middleware felt wrong. Those aren't really exceptional occurences. Validating data outside of the bus(in controllers/console commands) felt wrong. Validation could be easily side-stepped. Validating in handlers felt cluttered and seemed to violate the SRP.
I think I've finally come up with something I'm fairly happy with though.
I was/am using symfony forms to map incoming data to command objects. I was doing the validation there. I'd define the validation rules in a bundle's validation.yml
file so my controllers looked something like this.
class UserController extends ApiController
{
public function registerAction(Request $request)
{
$form = $this->createForm('register_user');
$form->handleRequest($request);
if ($form->isValid()) {
$command = $form->getData();
$this->getCommandBus()->handle($command);
return $this
->setData(['message' => "Registration successful."])
->setStatusCode(Response::HTTP_CREATED)
->respond();
}
return $this->respondWithForm($form);
}
}
The problem was that validation is now kind of coupled to a web request. If I create a command object in a console command and pass it to the bus I skip validation...not exactly what I want.
I decided I needed/wanted that validation check to happen somewhere in the depths of the ->handle($command)
call. This way the validation couldn't ever be side-stepped.
I decided to use a middleware and a trait.
class CommandValidatorMiddleware implements MessageBusMiddleware
{
/**
* @var Validator
*/
private $validator;
/**
* @param Validator $validator
*/
public function __construct(Validator $validator)
{
$this->validator = $validator;
}
/**
* {@inheritdoc}
*/
public function handle(Message $command, callable $next)
{
$traits = class_uses($command);
// Command isn't worried about validation, continue on
if ($traits === false || !in_array(HasErrorsTrait::CLASS, $traits)) {
$next($command);
return;
}
// Validate the command and bail if errors
$errors = $this->validator->validate($command);
if (count($errors) > 0) {
$command->addErrors($errors);
return;
}
// No errors, continue on
$next($command);
}
}
And now my controller will look like this.
class UserController extends ApiController
{
public function registerAction(Request $request)
{
$form = $this->createForm('register_user');
$form->handleRequest($request);
if ($form->isValid()) {
$command = $form->getData();
$this->getCommandBus()->handle($command);
if ($command->hasErrors()) {
return $this->respondWithErrors($command->getErrors());
}
return $this
->setData(['message' => "Registration successful."])
->setStatusCode(Response::HTTP_CREATED)
->respond();
}
return $this->respondWithForm($form);
}
}
Perfect...except now my command is being validated twice. Once by symfony's form component and once by the middleware I just introduced. That's not very efficient, especially if validation requires hitting the database. The solution to that is simple though. You can actually disable some of the form components validation by setting the validation_groups
setting to false
.
class RegisterUserCommandType extends AbstractType
{
/**
* {@inheritdoc}
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
$notMapped = ['mapped' => false];
$builder
->add('email', null, $notMapped)
->add('password', null, $notMapped);
}
/**
* {@inheritdoc}
*/
public function setDefaultOptions(OptionsResolverInterface $resolver)
{
$resolver->setDefaults(
[
'validation_groups' => false, // <-- disable validation check
'empty_data' => function (FormInterface $form) {
return new RegisterUserCommand(
Uuid::generate(),
$form->get('email')->getData(),
$form->get('password')->getData()
);
}
]
);
}
/**
* {@inheritdoc}
*/
public function getName()
{
return 'register_user';
}
}
Now the form will only have errors if basic integrity checks fail, ex. missing fields. This allows the form to be sure it has just enough information to build up the command but doesn't care if the data is any good which is exactly what I want. That's my middleware's job now.
I don't think this is perfect but I like it better than anything I've come up with so far. Handling validation in a different way? I'd love to see how. Comment or tweet at me!