Implementing Action-Domain-Responder Pattern With Symfony

Marcin Dźwigała
Dec 7, 2020 · 8 min read
Photo by Ben on Unsplash

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?

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: trueautoconfigure: 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):

Action

Now, when we have our Domain objects ready, we can create an Action that covers our Use Case of Adding an User.

AddUser Action

Besides Action, you must also add Input and Output classes:

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:

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.

ServiceLocator InputFactoryProvider Implementation

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:

__invoke(AddUserInput $input): AddUserOutput

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:

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.

Example validation rules assigned to a DTO

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:

JsonResponder

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:

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:

TableConsoleResponder

Note that this . 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:

UserFactory method

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.

Console command for handling user input

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 approach. Just separating controller actions into class per action services (Services with __invoke method) may be . 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.

The Startup

Get smarter at building your thing. Join The Startup’s +786K followers.

Sign up for Top 10 Stories

By The Startup

Get smarter at building your thing. Subscribe to receive The Startup's top 10 most read stories — delivered straight into your inbox, once a week. Take a look.

By signing up, you will create a Medium account if you don’t already have one. Review our Privacy Policy for more information about our privacy practices.

Check your inbox
Medium sent you an email at to complete your subscription.

Marcin Dźwigała

Written by

The Startup

Get smarter at building your thing. Follow to join The Startup’s +8 million monthly readers & +786K followers.

Marcin Dźwigała

Written by

The Startup

Get smarter at building your thing. Follow to join The Startup’s +8 million monthly readers & +786K followers.

Medium is an open platform where 170 million readers come to find insightful and dynamic thinking. Here, expert and undiscovered voices alike dive into the heart of any topic and bring new ideas to the surface. Learn more

Follow the writers, publications, and topics that matter to you, and you’ll see them on your homepage and in your inbox. Explore

If you have a story to tell, knowledge to share, or a perspective to offer — welcome home. It’s easy and free to post your thinking on any topic. Write on Medium

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store