DEV Community

Vladislav Malikov
Vladislav Malikov

Posted on • Edited on

Action-Domain-Responder VS. MVC: Laravel Implementation Example

Basically, Action-Domain-Responder (ADR) is simply an improved version of the classic Model-View-Controller (MVC) pattern, nothing more.

Here are the main advantages of ADR compared to MVC:

1. Better Domain Separation and Application Structure

  • ADR was originally designed with Domain-Driven Design (DDD) principles in mind, which allows better structuring of the application by domains rather than by component types (controllers, models, views). As a result, the code structure becomes more logical and focused on business goals rather than technical layers.

  • In MVC, you often end up with many folders full of controllers and mixed responsibilities, whereas ADR suggests grouping code by meaning and purpose, which makes maintenance and scaling easier.

2. Clear Separation of Responsibilities

In ADR, each component has a strictly defined role:

  • Action – handles business logic and interacts with the domain.
  • Domain – the business domain model.
  • Responder – builds the HTTP response (including headers and content).

In MVC, controllers often get bloated with business logic and responsibility for generating the response, which complicates maintenance and testing. In ADR, response generation is completely separated from business logic, improving readability and simplifying testing.

3. Closer Alignment with Web Architecture

  • ADR better reflects the real flow of web applications: a request goes to an action, the action interacts with the domain, then the response is formed. In MVC, the concept of a “view” is often mistakenly equated with a template, while in ADR the responder is responsible for the entire HTTP response.

  • ADR encourages the use of middleware and request interceptors, allowing authorization and other checks to be moved out of actions, further separating responsibilities.

4. Improved Maintainability and Scalability

Although ADR increases the number of classes (each action and responder is a separate class), this leads to a flatter and clearer hierarchy, making it easier to maintain and extend the application in the long term.

Example of ADR in Laravel

Action class:

<?php declare(strict_types=1);

namespace App\Account\User\Presentation\Action;

use App\Shared\Presentation\Controller;
use App\Shared\Domain\Bus\QueryBusInterface;
use App\Account\User\Application\Query\GetUsersPaginationQuery;
use Spatie\RouteAttributes\Attributes\Prefix;
use Spatie\RouteAttributes\Attributes\Middleware;
use Spatie\RouteAttributes\Attributes\Route;
use Spatie\RouteAttributes\Attributes\WhereUuid;
use App\Account\User\Presentation\Responder\IndexResponder;
use App\Shared\Presentation\Response\ResourceResponse;

#[Prefix(prefix: 'v1')]
#[Middleware(middleware: 'auth:api')]
#[WhereNumber(param: 'perPage')]
final class IndexAction extends Controller
{
    private QueryBusInterface $queryBus;

    public function __construct(QueryBusInterface $queryBus)
    {
        $this->queryBus = $queryBus;
    }

    #[Route(methods: ['GET'], uri: '/users/{perPage?}')]
    public function __invoke(int $perPage = 11): ResourceResponse
    {
        $query = new GetUsersPaginationQuery(perPage: $perPage);

        return new IndexResponder()->respond(
            data: $this->queryBus->ask(query: $query)
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

Responder class:

<?php declare(strict_types=1);

namespace App\Account\User\Presentation\Responder;

use App\Account\User\Presentation\AccountResource;
use App\Shared\Presentation\Response\ResourceResponse;
use Illuminate\Http\Response;

final readonly class IndexResponder
{
    public function respond(mixed $data): ResourceResponse
    {
        if (!blank(value: $data)) {
            return new ResourceResponse(
                data: AccountResource::collection(resource: $data),
                status: Response::HTTP_OK
            );
        }

        return new ResourceResponse(
            data: ['message' => __('No Users Found.')],
            status: Response::HTTP_NOT_FOUND
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

Structurally, it might look like this:

DDD Structura In Laravel

Or like this:

Base Structura In Laravel

In conclusion

ADR is an evolution of MVC, adapted specifically for the web, providing better separation of concerns, cleaner architecture, and easier work with modern web applications.

Thus, Action-Domain-Responder is better than MVC because it:

  • Offers a more logical division of the application by domains rather than technical layers.
  • Clearly separates business logic handling from HTTP response formation.
  • Better matches the real flow of web interactions.
  • Encourages the use of middleware for code separation.
  • Provides simpler maintenance and scalability thanks to a clear class structure.

Top comments (7)

Collapse
 
xwero profile image
david duymelinck

I don't see the benefit of having a responder and an action? If they are inseparable why split the code?

If you do almost nothing in the controller and make it a single method class, why not add a callable in the router file?

// routes/api.php
Route::prefix('V1')
   ->middleware('auth:api')
   ->group(function() {
     Route::get('/'/users/{perPage?}', function (int $perPage = 11, QueryBusInterface $queryBus)
    {
        $query = new GetUsersPaginationQuery(perPage: $perPage, );

        return new IndexResponder()->respond(
            data: $queryBus->ask(query: $query)
        );
    }
   }
);
Enter fullscreen mode Exit fullscreen mode

Think about all the classes you don't have to write.

How does it scale better?

Collapse
 
initstack profile image
Vladislav Malikov • Edited

Why Separating Action and Responder is Justified Even for Simple Logic?

Your approach with route closures works for small projects but becomes problematic at scale. Here are the key reasons why ADR is preferable:

#1. Single Responsibility Principle

  • Action → Handles business logic and domain interaction
  • Responder → Builds HTTP responses (JSON, XML, headers)
  • Example: If you later need to add XML responses for the same endpoint, you’ll only modify the Responder without touching the Action logic.

#2. Testability

  • Action can be easily tested in isolation using QueryBus mocks
  • Responder is tested separately for response formatting
public function test_responder_returns_404_on_empty_data()
{
    $responder = new IndexResponder();
    $response = $responder->respond([]);

    $this->assertEquals(404, $response->getStatus());
    $this->assertEquals(['message' => 'No Users Found'], $response->getData());
}
Enter fullscreen mode Exit fullscreen mode

#3. Code Reusability

  • Responders can be inherited for common scenarios (e.g., JsonResponder, ErrorResponder)
  • Actions can be combined via middleware (caching, validation)

#4. Project Evolution

When adding features (caching, logging, custom headers), changes remain localized:

Before: Router → Logic → Response  
After:  Router → [Middleware] → Action → [Domain] → Responder → Response  
Enter fullscreen mode Exit fullscreen mode

#5. Readability and Navigation

In a 50+ endpoint project:

  • Where’s the response logic? → Responders folder
  • Where’s request handling? → Actions folder
  • With closures, everything is scattered across route files.

When Is Your Approach Acceptable?

  • Microservices with 5-10 endpoints
  • Prototypes/MVPs
  • Script-like endpoints with no business logic

ADR shines in long-term maintainability, while closures work for short-lived or trivial projects. The more complex your domain, the more value ADR provides.

P. S. Placing this kind of logic in route files in hexagonal, layered, or clean architecture is a mortal sin. It violates the separation of concerns principle and creates tightly coupled code that becomes untestable and unmaintainable as the project grows.

Collapse
 
xwero profile image
david duymelinck

I agree that closures are an extreme way of doing things. I'm sorry that made you take a sidetrack.
I just wanted to make the point that having a lot of invokable classes is not inherently better than using controllers.

Controllers can have method dependency injection, which makes it possible that the methods can be tested in isolation. It is frowned upon to add all the dependencies using the constructor.
That is also what I wanted to show in the router example. But I agree you can't test that in isolation.

Controllers don't stop you from adding middleware. Laravel comes with quite a few added middlewares out-of-the-box.

Controllers don't stop you from having a domain driven design of your application. MVC for me is a part of the presentation layer.

Placing this kind of logic in route files in hexagonal, layered, or clean architecture is a mortal sin

If clean architecture leads to classes that could be functions, I am skeptical if that is what the code should be.

If you later need to add XML responses for the same endpoint, you’ll only modify the Responder without touching the Action logic

As I understand it a responder needs to be made aware how to behave by passing data.
So if you need a XMl response the action needs to pass that to the responder. Which means you do need to change the action.
With a controller that is only one file that changes.

Thread Thread
 
initstack profile image
Vladislav Malikov • Edited

As I understand it a responder needs to be made aware how to behave by passing data.
So if you need a XMl response the action needs to pass that to the responder. Which means you do need to change the action.

The responder returns a response, so it really depends on the response itself.

<?php declare(strict_types=1);

namespace App\Shared\Presentation\Response;

use Illuminate\Contracts\Support\Responsable;
use Illuminate\Support\Facades\Context;
use Illuminate\Http\{JsonResponse, Response};

final class MessageResponse implements Responsable
{
    /**
     * Constructs a new MessageResponse instance.
     *
     * @param string $message
     * @param int $status
     */
    public function __construct(
        private string $message,
        private int $status,
    ) {}

    /**
     * Converts the response to a JSON response.
     *
     * @param mixed $request
     * @return \Illuminate\Http\JsonResponse
     */
    public function toResponse($request): JsonResponse
    {
        $requestId = Context::get(key: 'request_id');
        $timestamp = Context::get(key: 'timestamp');

        return new JsonResponse(
            data: [
                'status' => $this->status,
                'data' => [
                    'message' => __(key: $this->message),
                ],
                'metadata' => [
                    'request_id' => $requestId,
                    'timestamp' => $timestamp
                ],
            ],
            status: $this->status
        );
    }
}
Enter fullscreen mode Exit fullscreen mode
<?php declare(strict_types=1);

namespace App\Shared\Presentation\Response;

use Illuminate\Contracts\Support\Responsable;
use Illuminate\Support\Facades\Context;
use Illuminate\Http\{JsonResponse, Response};

final class ResourceResponse implements Responsable
{
    /**
     * Constructs a new ResourceResponse instance.
     *
     * @param mixed $data
     * @param int $status
     */
    public function __construct(
        private mixed $data,
        private int $status
    ) {}

    /**
     * Converts the response to a JSON response.
     *
     * @param mixed $request
     * @return \Illuminate\Http\JsonResponse
     */
    public function toResponse($request): JsonResponse
    {
        $requestId = Context::get(key: 'request_id');
        $timestamp = Context::get(key: 'timestamp');

        return new JsonResponse(
            data: [
                'status' => $this->status,
                'data' => $this->data,
                'metadata' => [
                    'request_id' => $requestId,
                    'timestamp' => $timestamp
                ],
            ],
            status: $this->status
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

Etc.

I'm sorry that made you take a sidetrack.

I didn’t so much avoid the topic as just not understand the question, since my English isn’t that great.

Here’s another example I’ll give-it might make it easier to understand what’s what:

#[Prefix(prefix: 'v1')]
#[Middleware(middleware: 'api')]
final class RegisterAction extends Action
{
    private CommandBusInterface $commandBus;

    public function __construct(CommandBusInterface $commandBus)
    {
        $this->commandBus = $commandBus;
    }

    #[Route(methods: ['POST'], uri: '/register')]
    public function __invoke(RegisterRequest $request): MessageResponse
    {
        $command = RegisterCommand::fromRequest(request: $request);

        return new RegisterResponder()->respond(
            result: $this->commandBus->send(command: $command)
        );
    }
}
Enter fullscreen mode Exit fullscreen mode
final readonly class RegisterResponder
{
    public function respond(bool $result): MessageResponse
    {
        if ($result) {
            return new MessageResponse(
                message: __('Registration successful!'),
                status: Response::HTTP_OK
            );
        }

        return new MessageResponse(
            message: __('Registration failed. Try again.'),
            status: Response::HTTP_BAD_REQUEST
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

Again, everyone has their own way of doing things, but in Porto (the architectural pattern), it’s generally used like this:

#[Prefix(prefix: 'v1')]
#[Middleware(middleware: 'api')]
final class RegisterAction extends Action
{
    private RegisterResponder $responder;

    public function __construct(RegisterResponder $responder)
    {
        $this->responder = $responder;
    }

    #[Route(methods: ['POST'], uri: '/register')]
    public function __invoke(RegisterRequest $request): MessageResponse
    {
        return $this->responder->respond(request: $request);
    }
}
Enter fullscreen mode Exit fullscreen mode
final readonly class RegisterResponder
{
    private CommandBusInterface $commandBus;

    public function __construct(CommandBusInterface $commandBus)
    {
        $this->commandBus = $commandBus;
    }

    public function respond(RegisterRequest $request): MessageResponse
    {
        $command = RegisterCommand::fromRequest(request: $request);
        $result = $this->commandBus->send(command: $command);

        if ($result) {
            return new MessageResponse(
                message: __('Registration successful!'),
                status: Response::HTTP_OK
            );
        }

        return new MessageResponse(
            message: __('Registration failed. Try again.'),
            status: Response::HTTP_BAD_REQUEST
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

Whichever way works best for you...

Thread Thread
 
xwero profile image
david duymelinck

I appreciate the effort, but I don't think you are going to convince me it is a better pattern.

Thread Thread
 
initstack profile image
Vladislav Malikov

My goal was just to show how ADR can help organize code and separate concerns, especially as projects grow in complexity. But if your current approach works well for you, that’s what really matters.

I’m always open to discussing different approaches and learning from each other, so thanks for sharing your perspective!

Collapse
 
initstack profile image
Vladislav Malikov

If you do almost nothing in the controller and make it a single method class, why not add a callable in the router file?

You can use regular routes with ADR or attribute-based routes - whatever works best for you, whichever you find more convenient.