Implementing Action-Domain-Responder Pattern With Symfony
Action-Domain-Responder can help you keep your architecture clean, have better separation of concerns, and make your code more reusable.
But what actualy is an Action-Domain-Responder pattern?
It’s an architectural pattern that is a an evolution of a MVC Pattern. It was proposed by Paul M. Jones few years ago. It was created with request-response cycle in mind. It’s based on 3 components:
- Action — Takes the input and orchestrates calls to the Domain.
- Domain — Contains all of the business logic. Modifies state of the domain.
- Responder — It is passed the response from the Action. It’s only role is to create output based on what is returned from the Action.
Why not MVC?
Most of Web Application Developers use MVC architecture pattern to create applications. But when we dive into history, we discover that MVC was proposed way before Web Application were even taken into consideration. It was proposed in 1979 by Trygve Reenskaug. It was designed to handle small parts of desktop applications — a button, textbox, etc. Not a whole application or a whole page. Time passed, this pattern evolved and got adapted to Web Development. We have classes that are suffixed with “Controller”, that operate on some domain objects (Model) and return a View. Sure, it works, but we can do better.
What do we gain by using ADR?
- We have well segregated code
- We have context agnostic code. The same Action can be called from the console, by Http Request or event by some other action. It’s actually just a service.
- Code is cleaner and easier to change. Aligning with business changes is significantly faster.
- Reusability
Let’s do some coding
We will make a simple Web App that for the sake of simplicity is only to create an user.
Let’s start with plain symfony/skeleton
application.
composer create-project symfony/skeleton adr-example
After installing, change PHP Language level to 7.4.
At first, we have to do some cleanup. Let’s get rid of the src/Controller
directory and remove from services.yaml
what is below:
App\Controller\:
resource: '../src/Controller/'
tags: ['controller.service_arguments']
We will declare our actions as callable services (with __invoke
method).
Now let’s make new directory structure:
src/
Action/
Input/
Output/
Domain/
Infrastructure/
Responder/
ParamConverter/
- Action Directory will hold our Action classes
- Domain will hold all of our Domain logic which should be completely framework agnostic.
- Infrastructure will have ties to our framework.
- Responder catalog will hold classes responsible for parsing Action output to desired response formats.
- ParamConverter will convert HttpRequest into a DTO.
We also have to make our services public by default (If we want to use autowiring to register our services), so actions can be used as controllers. And add excludes to Input and Output directories. So, services.yaml should look like this now:
services:
_defaults:
autowire: true
autoconfigure: true
public: true
bind:
$salt: 'some_salt' App\:
resource: '../src/'
exclude:
- '../src/Kernel.php'
- '../src/Tests/'
- '../src/Action/Input/'
- '../src/Action/Output/'
- '../src/Domain/Model/'
- '../src/Infrastructure/Exception/'
Now we can get into coding. Let’s create User
class. We will make it as a Rich Domain Model, instead of classic Anemic one with getters and setters. You can read more about this in my other article.
Domain
Before we create an action, we need to have Domain objects that we can operate on.
First, we need to create few classes (Click to follow to gists, I don’t want to paste it directly because it could make this article less readable):
- User [Our User Class]
- Users [Repository Interface]
- UserFactory [To create Users from DTOs]
- InMemoryUsers [InMemory Implementation of repository, this article does not cover Doctrine usage. You can use anything as persistance layer.]
- PasswordEncoder [Interface of Password Encoder]
- Md5PasswordEncoder [Implementation of Password Encoder]
Action
Now, when we have our Domain objects ready, we can create an Action that covers our Use Case of Adding an User.
Besides Action, you must also add Input and Output classes:
- AddUserInput [Contains Input of an Action]
- AddUserOutput [Contains Output of an Action]
With this, you can already write a Unit Test testing the action! You do not even have to bootstrap Kernel to do this, or even use any of framework components.
Autowire will take care of injecting proper services in places of Interfaces. Thanks to Dependency Inversion, you can switch implementations whenever you will need to.
ParamConverter
To use ParamConverter we need to install SensioFrameworkExtraBundle
to do this, run this composer command:
(For the time for installing this bundle, you have to create src/Controller
Directory so the Symfony Recipe executes properly)
composer require sensio/framework-extra-bundle
Now let’s create our converter. We will create generic converter for all our inputs. It will consist of:
- ParamConverter itself
- DataValidator [Validates the data, explained below]
- InputFactory [Transforms Request into our Input DTO]
- AddUserInputFactory [Creates our input from Request]
- InputFactoryProvider [A contract for a provider of all InputFactories]
For implementation of InputFactoryProvider, we will use a Service Locator. What is nice about it and worth mentioning, is that a Service Locator is lazy loading services. If we would just tag all of the InputFactory Services, and add it using !tagged_iterator, all of the factories would get instantionated every time ParamConverter is called.
Service Locator is lazy-loading Services that are inside of it. But to do this, we first have to add two thing to our services.yaml
:
This will tag all of our services, that are instances of InputFactory Interface, with a tag named app.input_factory
_instanceof:
App\Infrastructure\ParamConverter\InputFactory\InputFactory:
tags:
- {name: app.input_factory}
This will inject ServiceLocator with tagged services. What is more, it will use our static method supportedInput
as keys for our collection.
App\Infrastructure\ParamConverter\InputFactory\ServiceLocatorInputFactoryProvider:
class: App\Infrastructure\ParamConverter\InputFactory\ServiceLocatorInputFactoryProvider
arguments:
- !tagged_locator {tag: app.input_factory, index_by: 'key', default_index_method: 'supportedInput'}
There’s one thing about ParamConverters that is worth mentioning. If you use autowire, and register a lot of ParamConverters in your application, you can encounter performance issues because by default, all of the Converters are added to a stack and checked one by one if they support the given value. To skip this, we will register our InputParamConverter explicitly in services.yaml. Add this to your services.yaml
:
App\Infrastructure\ParamConverter\InputParamConverter:
tags:
- {name: request.param_converter, converter: converter.action_input, priority: false}
What did we do? First, we registered our ParamConverter and named it, so we can use it directly on our Action. By setting priority to false, it will not be registered in the ParamConverters stack, so it won’t be checked if it matches in other situations.
Now, unfortunately, we must use Annotations on our Action. I have ambivalent feelings towards Annotations, but this is the only way to do this. Let’s add this Annotation to AddUser Action:
/**
* @ParamConverter(converter="converter.action_input", name="input")
*/
public function __invoke(AddUserInput $input): AddUserOutput
- converter — Name of the registered Converter
- name — Name of the variable that ParamConverter will take action on
InputValidaton
As we do not want to mess our Action with the request validation, we will do this in the ParamConverter using Symfony Validator and return any validation errors by throwin an error and catching it with kernel.exception event. But remember, it’s just a defensive validation, you should also guard your domain state inside your Domain Model! But in Domain, you can just throw Exceptions to prevent code from executing, not caring about informing the user what went wrong.
We will use Symfony Validator Component for this job.
Run this command to install it:
composer require symfony/validator
But instead of using it directly in ParamConverter, we will use DataValidator interface created before. We will create:
- SymfonyValidator [Performs validation using symfony validator and throws an Exception with errors]
- SymfonyViolationListConverter [Converts ViolationList to array of errors]
- DataValidationException [It will be used to throw validation errors]
- DataValidationExceptionHandler [It will handle the exception and convert it into a Response]
Now, when we have these building blocks, we have to register DataValidationException handler as kernel.exception event listener. This event is thrown, whenever there is an unhandled exception inside a Kernel. To do this, add this to your services.yaml
App\Infrastructure\ExceptionHandler\DataValidationExceptionHandler:
tags:
- {name: kernel.event_listener, event: kernel.exception}
Now we have to assign some validation rules to our DTOs. We can do this using annotations or yaml. In this case, I think annotation is better because it will be easier to maintain. But you can use YAML if you want to.
You will now have your object validated before framework passes it to an Action.
Responder
Our Action already exists. It takes an input and returns a response. But to make it work as Http Endpoint, we need to create a Responder that will convert our plain DTO into a JsonResponse.
To create a Json Responder we will use Symfony Serializer Component. We will use it in a class that will be listening to kernel.view event.
Install it via composer:
composer require symfony/serializer
After installing, register this normalizer in services.yaml
get_set_method_normalizer:
class: Symfony\Component\Serializer\Normalizer\GetSetMethodNormalizer
tags: [serializer.normalizer]
This normalizer is faster than default one, that uses Reflection. But to use this, you have to keep get{FieldName}
method names convention. Of course, you could serialize it any way you want — this is just an example!
Now we need to create a JsonResponder, that will handle parsing our DTO into a JsonResponse:
We check Request Content-Type, to see if it is a desired format. If it is, JsonResponder will serialize DTO using Symfony Serializer and put it into a JsonResponse. We could also create some mechanisms to dynamically assign Status Codes, but for this example, we will leave it as is.
We also need to register it in services.yaml
as an event listener:
App\Infrastructure\Responder\JsonResponder:
tags:
- {name: kernel.event_listener, event: kernel.view}
If you need to create response in other format, you just create another Responder that handles parsing into another format and register it as above.
The last thing we have to do, is to add our route declaration to routes.yaml
file:
add_user:
path: /user/add
controller: App\Action\AddUser
Implementing Symfony Console Command using the same Action
Our Action [Use Case] is call agnostic. We can use it in our console command. We just have to create a Responder that will write our response data to OutputInterface instead of serializing it to json.
First, we need to create a contract for a ConsoleResponder:
Now we can create an implementation of ConsoleResponder. This will be a simple one, that just serializes an array [without nested ones for simplicity] into an output:
Note that this is a simple implementation of ConsoleResponder. If you want to create more sophisticated one, dive into it! This is just to show a whole concept.
We also need to add additional method to UserFactory:
The last thing we have to do is to create our method that will handle input, pass the input to an action, and then pass the output from action to a Responder.
Summary
As you can see, with this implementation you end up with a well separated code, where the application layer is separated from infrastructure and the domain layer is separated from the application layer, and infrastructure. Application Layer and Domain Layer are Framework agnostic. You are less coupled to the framework. Even if you will never change the whole framework, it will be easier in the future to upgrade to newer versions of Symfony. You have less places that interact with the internals of the Symfony.
If you are creating a simple CRUD app, this may be an overkill. If you are completely, completely, sure, that you will never ever have to use the same action in other context, you may choose to use more traditional approach. Just separating controller actions into class per action services (Services with __invoke method) may be just enough. But if your application and the domain are more sophisticated, in the long run you can really benefit from using Action-Domain-Responder architecture pattern. It makes it really flexible and stable.