What is ADR?

Action — Domain — Responder

ADR is a design pattern that separates the concerns of handling requests, executing business logic, and returning responses.

Unlike MVC, which was originally designed for desktop applications, ADR is purpose-built for the request/response cycle of the web.

The three responsibilities

  • Action — Receives the request, coordinates the Domain and Responder. Contains no business logic.
  • Domain — All business logic lives here. Returns a typed result, knows nothing about HTTP.
  • Responder — Turns that result into a response. Renders a view or returns JSON, and is the only layer that knows what a template calls things.

A complete example

A user makes a GET request to /path. Here is the full journey through each layer.

1. Define the route

$router->get('/path', Actions\Path\Index::class);

Every feature is a directory, and each route is its own operation inside it: Actions\Path\Index lives at app/Actions/Path/Index.php. A second route means a second class beside it, never a second method.

2. The Action receives the request

namespace Actions\Path;

use Actions\Action;
use Domains\Path\Index as IndexDomain;
use Responders\Path\Index as IndexResponder;
use TetherPHP\framework\Http\Response;
use TetherPHP\framework\Interfaces\ActionInterface;
use TetherPHP\framework\Requests\Request;

class Index extends Action implements ActionInterface
{
    public function __construct(protected Request $request)
    {
        $this->domain = new IndexDomain();
        $this->responder = new IndexResponder($request);
    }

    public function __invoke(): Response
    {
        return $this->respond($this->domain->handle());
    }
}

The Action is thin by design. It wires the Domain to the Responder and nothing more.

3. The result the Domain returns

namespace Domains\Path\Results;

use TetherPHP\framework\Interfaces\DomainResult;

final readonly class Page implements DomainResult
{
    public function __construct(
        public string $name,
        public string $description,
    ) {
    }
}

A value object, named in the Domain's terms. DomainResult is an empty marker interface — it exists so the pipeline has a type to state.

4. The Domain handles the logic

namespace Domains\Path;

use Domains\Domain;
use Domains\Path\Results\Page;

class Index extends Domain
{
    public function handle(): Page
    {
        return new Page(
            name: 'Path Domain',
            description: 'This is where business logic lives.',
        );
    }
}

The Domain has no knowledge of HTTP, views, or responses — and now no knowledge of what a template calls things either.

5. The Responder formats the output

namespace Responders\Path;

use Domains\Path\Results\Page;
use Responders\Responder;
use TetherPHP\framework\Http\Response;

class Index extends Responder
{
    public function __invoke(Page $result): Response
    {
        return $this->view('pages.path.index', [
            'heading' => $result->name,
            'blurb' => $result->description,
        ]);
    }
}

The Responder names every variable the view will read. It could just as easily return JSON.

Why not just return an array?

Because a view renders its data with extract(), an array returned by a Domain has keys that are the template's variable names. Rename $tagline in a template and you are editing a business-logic class.

That is the coupling the Responder sits in the middle to absorb, and while it lasted the Responder did nothing but pass its argument along. A typed result puts the naming back where it belongs.

It buys a second thing: one result type per outcome. A page that can be missing returns a different class when it is missing, so the Responder picks the view and the status code from the type it was handed — instead of reading a found flag out of an array.