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.