Domain routing in Waaseyaa: replacing a giant dispatcher with small routers

Published: (April 5, 2026 at 09:36 PM EDT)
6 min read
Source: Dev.to

Source: Dev.to

Ahnii!

graph TD
    A[HTTP Request] --> B[Router]
    B -->|matches URL to route| C[Dispatcher]
    C -->|picks controller, injects context| D[Controller]
    D --> E[Response]

The router matches a URL to a route definition. The dispatcher takes that match and figures out which controller to instantiate, which method to call, and how to pass in the request context. In most frameworks you never think about it because the framework handles it for you.

The Problem

The dispatcher becomes the place where “which code to run” turns into a long chain of conditionals. A single dispatcher that handles every request type accumulates conditionals fast. You end up with something like this:

public function dispatch(Request $request): Response
{
    $controller = $request->attributes->get('_controller');

    if ($controller === 'entity_types') {
        // 40 lines of entity type listing logic
    } elseif (str_starts_with($controller, 'entity_type.')) {
        // 60 lines of lifecycle management
    } elseif ($controller === 'openapi') {
        // 80 lines of OpenAPI spec generation
    } elseif (str_contains($controller, 'SchemaController')) {
        // 50 lines of schema handling
    }
    // ... and so on for every domain
}

Entity CRUD, schema generation, lifecycle management, OpenAPI docs – all funneling through one class. Each new feature adds another branch, and testing any one path means loading the context for all of them.

The Fix

The fix isn’t a “better” dispatcher. It’s smaller, focused routers that each own one domain.

A domain router is a small class that owns one slice of your application’s request handling. Instead of one dispatcher knowing about every domain, each router answers two questions:

  1. “Is this request mine?” – inspects the request, returns a boolean.
  2. “Yes, handle it.” – does the work, returns a response.
interface DomainRouterInterface
{
    // "Is this request mine?" — inspects the request, returns a boolean.
    public function supports(Request $request): bool;

    // "Yes, handle it." — does the work, returns a response.
    public function handle(Request $request): Response;
}

The dispatcher iterates registered routers in order. The first one to return true from supports() wins. This is the Chain of Responsibility pattern with an explicit contract.

Example Routers

Entity‑type lifecycle router

Waaseyaa lets you define entity types (think “Article”, “User”, “Comment”). Sometimes you need to disable one, maybe you’re deprecating a content type, or re‑enable one that was turned off. That’s what this router handles.

final class EntityTypeLifecycleRouter implements DomainRouterInterface
{
    use JsonApiResponseTrait; // consistent JSON:API response formatting

    public function __construct(
        // Registry: knows which entity types exist and their capabilities
        private readonly EntityTypeManager $entityTypeManager,
        // Handles disable/enable state changes
        private readonly EntityTypeLifecycleManager $lifecycleManager,
    ) {}

    public function supports(Request $request): bool
    {
        // Prefix match: anything starting with "entity_type." belongs here.
        // Adding entity_type.archive later requires zero changes to this method.
        $controller = $request->attributes->get('_controller', '');

        return $controller === 'entity_types'
            || str_starts_with($controller, 'entity_type.');
    }

    public function handle(Request $request): Response
    {
        $controller = $request->attributes->get('_controller', '');

        return match ($controller) {
            'entity_types'        => $this->listTypes($request),
            'entity_type.disable' => $this->disableType($request),
            'entity_type.enable'  => $this->enableType($request),
            default               => $this->jsonApiResponse(404, []),
        };
    }
}

One class, one domain, fully testable in isolation.

Schema router

Your API also needs to serve OpenAPI specs and schema definitions for each entity type. That’s a different domain from lifecycle management, so it gets its own router.

final class SchemaRouter implements DomainRouterInterface
{
    use JsonApiResponseTrait;

    public function __construct(
        private readonly EntityTypeManager $entityTypeManager,
        // Schema endpoints enforce the same access rules as the entities themselves.
        // Can't access an entity type? Can't read its schema either.
        private readonly EntityAccessHandler $accessHandler,
    ) {}

    public function supports(Request $request): bool
    {
        $controller = $request->attributes->get('_controller', '');

        return $controller === 'openapi'
            || str_contains($controller, 'SchemaController');
    }

    public function handle(Request $request): Response
    {
        $controller = $request->attributes->get('_controller', '');

        if ($controller === 'openapi') {
            return $this->generateOpenApiSpec($request);
        }

        // SchemaController routes carry the entity type as a route parameter
        $entityType = $request->attributes->get('entity_type_id', '');
        return $this->showSchema($entityType, $request);
    }
}

Both routers pull typed context from the request without doing any parsing themselves.

Where does that context come from?

Middleware upstream handles authentication, body parsing, and context assembly before any router sees the request:

// By the time handle() runs, the request carries everything the router needs.
// No token parsing, no JSON decoding, no service lookups.
$account = $request->attributes->get('_account');               // authenticated user
$storage = $request->attributes->get('_broadcast_storage');    // storage backend
$body    = $request->attributes->get('_parsed_body');          // already decoded JSON, etc.

With this separation:

  • Routers stay tiny and focused on a single domain.
  • Dispatching becomes a simple loop over DomainRouterInterface implementations.
  • Testing each router in isolation is straightforward – you only need to provide the request attributes it expects.

TL;DR

Replace a monolithic dispatcher with a collection of domain‑specific routers that implement a two‑method contract (supports + handle). This yields:

  • Cleaner, more maintainable code.
  • Faster, more focused tests.
  • A natural place to apply the Chain of Responsibility pattern.

Give it a try in your next project – you’ll notice the difference after the first router is added!

$t->attributes->get('_parsed_body');       // deserialized JSON
$context = $request->attributes->get('_waaseyaa_context');  // framework context

The infrastructure work happens once, upstream. Routers stay focused on domain logic.

Skeleton for a new domain

Say you want to handle bulk‑import operations:

final class BulkImportRouter implements DomainRouterInterface
{
    use JsonApiResponseTrait;

    public function supports(Request $request): bool
    {
        return str_starts_with(
            $request->attributes->get('_controller', ''),
            'bulk_import.',
        );
    }

    public function handle(Request $request): Response
    {
        $controller = $request->attributes->get('_controller', '');

        return match ($controller) {
            'bulk_import.csv'  => $this->importCsv($request),
            'bulk_import.json' => $this->importJson($request),
            default            => $this->jsonApiResponse(404, []),
        };
    }
}
  • No existing router changes.
  • No dispatcher modifications.
  • Register it in the router collection and the dispatcher picks it up.
  • The interface guarantees new domains are additive.

The 1,000‑line dispatcher is gone. In its place: small classes with clear boundaries, each testable in isolation:

$router = new EntityTypeLifecycleRouter($entityTypeManager, $lifecycleManager);
$request = new Request();
$request->attributes->set('_controller', 'entity_type.disable');
$request->attributes->set('_parsed_body', ['entity_type_id' => 'article']);

$response = $router->handle($request);
// No framework boot, no database, no middleware chain

When you see a request to entity_type.disable, you know exactly which file handles it. No tracing through a switch statement in a god class.

Baamaapii

0 views
Back to Blog

Related posts

Read more »

Um resumo sobre coesão e SRP

O que é uma classe coesa? Uma classe coesa é aquela que tem apenas uma responsabilidade. Classes coesas são menores, mais organizadas, fáceis de manter e reuti...