---
title: "The Three-Tier Dispatcher"
description: "The Three-Tier Dispatcher"
type: "docs"
category: "doc"
tags: []
authors: [Anonymous]
date: "2026-10-10"
last_update: "2026-10-10"
time_minutes: 3
draft: false
unlisted: false
url: "https://www.derafu.dev/docs/core/backbone-dispatcher/tiers"
---

# The Three-Tier Dispatcher

Each tier wraps the one below it and adds exactly one thing — none of them re-implement the others' job.

## `DirectDispatcherInterface`

```php
public function dispatch(
    string $package, string $component, string $worker,
    string $operation, array $params = []
): mixed;
```

Resolves the worker from the package registry, resolves and validates the parameters against the operation's real method signature, and calls it. Returns exactly what the operation returned, as a real PHP value — no wrapping. Any `Throwable` propagates unaltered. Use this directly only when you're a PHP-only caller happy to get real domain objects back and to handle exceptions yourself.

## `TypedDispatcherInterface`

```php
public function dispatch(OperationRequestInterface $request): OperationResultInterface;
```

Adapts `DirectDispatcher` to the `OperationRequest` → `OperationResult` shape. Still does **not** catch exceptions — a thrown `Throwable` propagates unaltered, and a successful call always has `isSuccess() === true` (there is no "failure" `OperationResult` coming out of this tier, only a thrown exception). It doesn't serialize the return value either: a domain object comes back as the real object.

## `SafeDispatcherInterface`

```php
public function dispatch(OperationRequestInterface $request): OperationResultInterface;
```

Same signature as `TypedDispatcher`, but **never throws**: any `Throwable` becomes a failure `OperationResult` carrying a `ProblemDetail`. It's also the only tier that **serializes** the success value before returning it — because it's the tier meant to actually cross a process/language boundary, where an uncaught PHP exception is a dead end.

```php
$inspector = new Inspector();

$directDispatcher = new DirectDispatcher(
    $registry,
    $inspector,
    new Resolver(
        $inspector,
        new Caster(new ObjectFactoryRegistry(fallback: new FromArrayDeserializer())),
        new Validator(),
    ),
    // 4th argument, OperationPolicyInterface, defaults to AllowAllOperationPolicy — see below.
);

$safeDispatcher = new SafeDispatcher(
    new TypedDispatcher($directDispatcher),
    new Serializer(),
    environment: 'prod',
    debug: false,
);
```

There is no bundled DI wiring for this chain — wire it however your application already wires services (a `config/services.yaml`, a PHP-DI container, etc.), using the composition above as the reference.

`DirectDispatcher` invokes the resolved operation with a native named-argument call (`$worker->$operation(...$args)`) rather than a generic invoker library: `Resolver` already produces `$args` as a plain, name-keyed, fully-cast array, so nothing is left for an invoker to resolve that hasn't already been resolved.

## Dispatching an Operation

```php
use Derafu\BackboneDispatcher\ValueObject\OperationRequest;

$request = OperationRequest::fromId(
    'billing.invoice.builder::build',
    ['number' => 'F-001', 'amount' => 15000],
);

$result = $safeDispatcher->dispatch($request);

if ($result->isSuccess()) {
    $invoice = $result->getValue(); // already serialized: array/scalar, never a raw PHP object.
} else {
    $problem = $result->getProblem(); // RFC 7807-shaped ProblemDetail.
}
```

`OperationRequest::fromId()` parses `"package.component.worker::operation"` — an id with the wrong shape (missing `::`, wrong number of `.`-segments, any empty piece) throws `InvalidOperationIdException`. `::` (not a single `:`) on purpose: `derafu/backbone` already uses a single `:` for its own `.job:name`/`.handler:name`/`.strategy:name` ids — this identifier is a dispatcher-only concept, unrelated to that family, and `::` keeps it visually distinct.



---
Last updated on 10/10/2026

