If you are like me one who tries to keep up with all the cool stuff happening in the PHP world you’ve probably noticed the buzz around Domain Driven Design and more recently Event Sourcing and CQRS. Last year Qandidate released Broadway: a project providing infrastructure and helpers for introducing CQRS and Event Sourcing into your PHP stack. It wasn’t until last month before I got the change to get my hands on it. We adopted the framework in one of the latest projects at work. And it didn’t take long before we ran into all kind of problems and questions 🙂 .
So for every question we have I’ll try to write a blogpost so others can learn. Also I’m curious about how you handle the problems I describe in these posts, so don’t hesitate to comment if you have a different opinion. Let’s dive into what should be the first in a series of post about CQRS!
The problem
In the application we’re building one requirement is that users can configure attachments to be send to a user when performing some kind of action. We’re using Symfony2 and Broadway so I our code will be very specific to these frameworks. Consider the following form:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 |
namespace Wb\PoolBundle\Form\Type; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Validator\Constraints\File; use Symfony\Component\Validator\Constraints\NotBlank; class UploadAttachmentFormType extends AbstractType { public function buildForm(FormBuilderInterface $builder, array $options) { $builder ->add('title', 'text', [ 'label' => 'Naam', 'required' => true, 'constraints' => [ new NotBlank() ] ]) ->add('file', 'file', [ 'required' => true, 'label' => 'Bestand', 'constraints' => [ new NotBlank(), new File([ 'maxSize' => '4M', 'mimeTypes' => [ 'application/pdf', 'application/x-pdf', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'application/x-msword', 'application/msword', 'application/zip', 'image/png', 'image/jpeg', 'image/pjpeg', 'image/gif' ] ]) ] ]); } public function getName() { return 'upload_attachment'; } } |
In the controller we validate the form, construct our UploadAttachment
command – which is just a DTO – by passing all the values from to form to the command bus:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
class AttachmentController { public function uploadAction(Request $request, $poolId) { $form = $this->formFactory->create(new UploadAttachmentFormType()); if ($form->handleRequest($request)->isValid()) { $data = $form->getData(); $command = new UploadAttachment( new PoolId($poolId), new AttachmentId($this->uuidGenerator->generate()), $data['file'] $data['title'] ); $this->commandBus->dispatch($command); $request->getSession()->getFlashBag()->add('notice', sprintf( 'Attachment "%s" has been uploaded.', $title )); return new RedirectResponse($this->router->generate('pool_configure_procedure', [ 'poolId' => $poolId ])); } return new Response($this->twig->render('AcmeBundle:Form:default.html.twig', [ 'form' => $form->createView(), 'title' => 'Upload attachment', ])); } } |
And the command handler calls the appropriate method on our aggregate:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
namespace Wb\Pool\Model\Procedure; use Broadway\CommandHandling\CommandHandler; use Wb\Pool\Model\Pool\PoolRepository; use Wb\Pool\Model\Pool\Pool; class AttachmentCommandHandler extends CommandHandler { /** * @var PoolRepository */ private $poolRepository; public function __construct(PoolRepository $poolRepository) { $this->poolRepository = $poolRepository; } public function handleUploadAttachment(UploadAttachment $command) { $pool = $this->poolRepository->load($command->getPoolId()); $procedure = $pool->getProcedure(); $procedure->uploadAttachment( $command->getAttachmentId(), $command->getFile(), $command->getTitle(), $command->getPoolId() ); $this->poolRepository->save($pool); } } |
Our aggregate creates a new event:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
class Pool { public function uploadAttachment( AttachmentId $attachmentId, $file, $title, PoolId $poolId ) { $this->apply(new AttachmentUploaded( $attachmentId, $file, $title, $poolId )); } } |
But as you probably noticed now we run into problems because we’re passing around a UploadedFile
instance in an event. Imagine how this would get stored into the event store:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
class AttachmentUploaded implements SerializableInterface { // ... public function serialize() { return [ 'attachmentId' => (string) $this->getAttachmentId(), 'title' => $this->getTitle(), 'file' => file_get_contents($file->getPathname()) // This isn't gonna work 'poolId' => (string) $this->getPoolId() ]; } // ... } |
Storing the complete file in the event storage is theoretically possible but we prefer to store our files not in MySQL but somewhere in a S3 bucket in the cloud. If you do your event store will grow quickly and you’ll have other challenges to wrap your head around. Keep in mind events often will be transferred by some queue like RabbitMQ.
After some digging around on the internet I found some others with the same problem. On Freenode #qandidate I also asked for advice. In general everybody stores the file in the controller or command handler and passes on the id to the event.
The solution
We’ve chosen to store the file in our controller and pass on the UUID to the command. A code example is worth a thousand words:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 |
class AttachmentController { public function uploadAction(Request $request, $poolId) { $form = $this->formFactory->create(new UploadAttachmentFormType()); if ($form->handleRequest($request)->isValid()) { $data = $form->getData(); $file = $data['file']; $title = $data['title']; // Construct our command with some data we need from the file $command = new UploadAttachment( new PoolId($poolId), new AttachmentId($this->uuidGenerator->generate()), $file->getClientOriginalName(), // To display in backend etc. $this->fileStorage->put($file), // Store file in S3, return filename $file->getClientMimeType(), $title ); $this->commandBus->dispatch($command); $request->getSession()->getFlashBag()->add('notice', sprintf( 'Attachment "%s" uploaded.', $title )); return new RedirectResponse($this->router->generate('pool_configure_procedure', [ 'poolId' => $poolId ])); } return new Response($this->twig->render('AcmeBundle:Form:default.html.twig', [ 'form' => $form->createView(), 'title' => 'Upload attachment', ])); } } |
Drawbacks
There are a couple of drawbacks in this method:
- every new attachment results in a new file, this could take up a lot of storage from unused files
- if something goes wrong in the command handler, the file is stored already
Personally I see it as a benefit we have a history of every single attachment uploaded. We can easily go back in time and revert an erroneous upload or debug what our users did wrong in case of a problem.
By only passing around the UUID our event keeps small and this makes it easy to be published on RabbitMQ.