Advanced Proposal: Composable and Schema-Driven WebSocket Routing

Updated Nov 18, 2025

Building upon the foundational concepts outlined above, this section details a more advanced, composable architecture for WebSocket handling. This evolved design introduces a dedicated WebSocketRouter, support for nested resources, and a high-performance, schema-driven message dispatch mechanism. Its primary goal is to achieve true ergonomic parity with Falcon's acclaimed HTTP routing while enabling highly structured, maintainable, and performant real-time applications.

The following diagram provides a high-level overview of the main classes and their relationships within this advanced proposal.

classDiagram
    class App {
        +add_route(path, resource)
    }
    class WebSocketRouter {
        <<Falcon Resource>>
        +on_websocket(req, ws)
        +add_route(path, resource_cls, *args, **kwargs)
        +url_for(name, **params)
    }
    class WebSocketResource {
        +schema: TaggedUnion
        +state: dict
        +on_<tag>()
        +on_unhandled()
        +on_connect()
        +on_disconnect()
    }
    class TaggedUnion {
        <<msgspec tagged union>>
    }
    App "1" --o "*" WebSocketRouter : mounts
    WebSocketRouter "1" --o "*" WebSocketResource : routes
    WebSocketResource ..> TaggedUnion : uses for schema

5.1. The `WebSocketRouter` as a Composable Falcon Resource

To provide a cleaner separation of concerns and a more powerful, composable API, this proposal treats the WebSocketRouter as a standard Falcon resource. This allows routers to be mounted on the main application, enabling the creation of modular, hierarchical WebSocket APIs.

5.1.1. Mounting the Router

Instead of assigning a router to a special application attribute, it is mounted at a URL prefix using Falcon's standard app.add_route() method. This makes the WebSocketRouter a first-class citizen in Falcon's routing tree.

import falcon.asgi
from falcon_pachinko.router import WebSocketRouter # New component

app = falcon.asgi.App()
chat_router = WebSocketRouter()

# The WebSocketRouter instance is mounted like any other Falcon resource.
# It will handle all WebSocket connections under the '/ws/chat/' prefix.
app.add_route('/ws/chat', chat_router)

# To achieve this, the WebSocketRouter will implement the on_websocket
# responder method, which will contain the logic to dispatch the connection
# to the correct sub-route defined on the router itself.

This creates a clean architectural boundary and leverages Falcon's existing, well-understood routing mechanism for composition.

5.1.2. add_route API, Factories, and URL Reversal

The WebSocketRouter features its own add_route() method for defining sub-routes. These paths are relative to the router's mount point.

  • Resource Factories: To mirror Falcon's flexibility, add_route will accept not only WebSocketResource subclasses but also callable factories. A factory is any callable that accepts the same arguments as a resource's constructor and returns a resource instance. This keeps the barrier to entry low for simple, functional use cases.

  • Resource Initialization: The method accepts *args and **kwargs to be passed to the resource's constructor (or factory), allowing for route-specific configuration.

  • URL Reversal: To discourage hard-coding paths, the router will provide a url_for(name, **params) helper to generate URLs for its routes, similar to other modern web frameworks.

# Continuing the previous example:
chat_router = WebSocketRouter(name='chat') # Give the router a name for reversal

# Add a route to the router, giving it a name for url_for.
chat_router.add_route(
    '/{room_id}', 
    ChatResource,
    name='room',
    init_args={'history_size': 100}
)

app.add_route('/ws/chat', chat_router)

# Generate a URL:
# url = app.url_for('chat:room', room_id='general') # Hypothetical top-level reversal
# -> '/ws/chat/general'

The router is responsible for matching the incoming connection URI against its registered routes, parsing any path parameters (like {room_id}), instantiating the correct WebSocketResource with its specific initialization arguments, and handing off control of the connection.

5.1.3. Router Flow and Structure

The diagrams below illustrate the flow of a WebSocket connection through the router and the relationships between the main classes.

sequenceDiagram
    participant Client
    participant FalconApp
    participant WebSocketRouter
    participant WebSocketResource

    Client->>FalconApp: Initiate WebSocket connection (URL)
    FalconApp->>WebSocketRouter: on_websocket(req, ws)
    WebSocketRouter->>WebSocketRouter: Match URL to route
    WebSocketRouter->>WebSocketResource: Instantiate resource
    WebSocketRouter->>WebSocketResource: on_connect(req, ws, **params)
    alt on_connect returns False
        WebSocketRouter->>Client: Close WebSocket
    else on_connect returns True
        WebSocketRouter->>Client: Accept WebSocket
    end
erDiagram
    WEBSOCKETROUTER ||--o{ ROUTE : registers
    ROUTE {
        string path
        regex pattern
        callable factory
    }
    WEBSOCKETROUTER {
        string name
        dict names
    }
    ROUTE }o--|| WEBSOCKETRESOURCE : instantiates
    WEBSOCKETRESOURCE {
        method on_connect
    }
classDiagram
    class WebSocketRouter {
        - _routes: list[tuple[str, re.Pattern[str], Callable[..., WebSocketResource]]]
        - _names: dict[str, str]
        - name: str | None
        + __init__(name: str | None = None)
        + add_route(path: str, resource: type[WebSocketResource] | Callable[..., WebSocketResource], name: str | None = None, args: tuple = (), kwargs: dict | None = None)
        + url_for(name: str, **params: object) str
        + on_websocket(req: falcon.Request, ws: WebSocketLike)
    }
    class WebSocketResource {
        + on_connect(req: falcon.Request, ws: WebSocketLike, **params)
    }
    class WebSocketLike
    class falcon.Request

    WebSocketRouter --> WebSocketResource : creates
    WebSocketRouter ..> WebSocketLike : uses
    WebSocketRouter ..> falcon.Request : uses
    WebSocketResource ..> WebSocketLike : uses
    WebSocketResource ..> falcon.Request : uses

5.2. Composable Architecture: Nested Resources

To facilitate the creation of modular and hierarchical applications, this design introduces the concept of nested resources via an add_subroute method on a resource instance. This allows developers to organize complex, related functionality into distinct, reusable classes.

5.2.1. The add_subroute Mechanism

A parent resource can mount a sub-resource at a relative path, promoting a logical code hierarchy.

# --- In project_resource.py ---
class ProjectResource(WebSocketResource):
    def __init__(self, req, ws, project_id: str):
        super().__init__(req, ws)
        # ... project-specific setup using project_id ...

        # Mount sub-resources
        self.add_subroute('tasks', TasksResource)
        self.add_subroute('files', FilesResource)

# --- In the main application ---
# This assumes a router is already defined.
router.add_route('/projects/{project_id}', ProjectResource)

# A connection to "wss://.../projects/123/tasks" would be handled
# by an instance of TasksResource, with the context of project "123".

5.2.2. Path Composition and State Management

The WebSocketRouter must resolve these composite paths. A trie (prefix tree) is the natural data structure for this task. When resolving a path like /projects/123/tasks, the router would first match /projects/{project_id} and instantiate ProjectResource, passing it the project_id. The ProjectResource constructor would then call add_subroute, registering TasksResource. The router would then match the tasks segment and instantiate TasksResource.

A critical aspect is context passing. The router must facilitate passing state from parent to child. A robust implementation would involve the router instantiating the entire resource chain, allowing parent resources to pass relevant state (or self) into the constructors of their children.

sequenceDiagram
    participant Client
    participant FalconRequest as Falcon Request
    participant WebSocketRouter
    participant ResourceFactory

    Client->>FalconRequest: Initiates WebSocket request
    FalconRequest->>WebSocketRouter: on_websocket(req, ws)
    WebSocketRouter->>WebSocketRouter: Check req.path_template == _mount_prefix
    WebSocketRouter->>WebSocketRouter: Match full request path against compiled routes
    alt Match found
        WebSocketRouter->>ResourceFactory: Instantiate resource
        ResourceFactory-->>WebSocketRouter: Resource instance
        WebSocketRouter->>ResourceFactory: Call resource handler
    else No match
        WebSocketRouter->>FalconRequest: Raise 404
    end

5.2.3. Context-Passing for Nested Resources

Explicit context handoff keeps nested resources predictable and mirrors Falcon's philosophy of avoiding hidden state. Each parent resource controls what data flows to its children, allowing shared state without resorting to globals.

  1. Per-resource context provider

Add an overridable get_child_context() method to WebSocketResource. The router calls this hook after instantiating the parent to obtain keyword arguments for the next child. The default implementation returns {}, so resources opt in to sharing.

   def get_child_context(self) -> dict[str, object]:
       """Return kwargs to be forwarded to the immediate child resource."""
       return {}

A concrete signature signals that callers can rely on a plain dict and keeps the hook symmetric with Falcon's HTTP-style get_child_scope() patterns.

  1. Shared state object

Pass the same connection-scoped state proxy down the chain. The router sets child.state = parent.state unless the parent supplies an alternative via get_child_context().

  1. Router chain instantiation

For each path segment the router will:

  • Instantiate the parent with path parameters and static init args.
  • Invoke get_child_context() to obtain context for the child.
  • Instantiate the child, merging path params with the context kwargs.
  • Propagate the shared state proxy.
  1. Convenience API

add_subroute() records the child factory along with any static positional/keyword args and retains a reference to the parent. This enables the router to look up subroutes while composing the resource chain.

  1. Testing and docs

Provide examples where a parent loads a project object and injects it into TasksResource, verifying that the child receives the object and both modify the shared state.

The relationships and runtime behavior are illustrated below.

   classDiagram
       class WebSocketResource {
           +get_child_context() kwargs
           state
       }
       class WebSocketRouter {
           +instantiate_resource_chain(path_segments, path_params, static_args)
           +add_subroute(child_factory, *args, **kwargs)
       }
       WebSocketResource <|-- ParentResource
       WebSocketResource <|-- ChildResource
       WebSocketRouter o-- WebSocketResource : instantiates
       ParentResource o-- ChildResource : add_subroute
       ParentResource --> ChildResource : get_child_context()
       ParentResource --> ChildResource : state (shared)
   sequenceDiagram
       participant Router as WebSocketRouter
       participant Parent as ParentResource
       participant Child as ChildResource
       Router->>Parent: Instantiate with path params, static args
       Router->>Parent: get_child_context()
       Parent-->>Router: context kwargs
       Router->>Child: Instantiate with path params + context kwargs
       Router->>Child: Set child.state = parent.state (unless overridden)

5.3. High-Performance Schema-Driven Dispatch with `msgspec`

This proposal elevates the dispatch mechanism using msgspec and its support for tagged unions. This moves from simple dispatch to a declarative, schema-driven approach.

5.3.1. Declaring Message Schemas

A resource defines its message schema using a typ.Union of ms.Struct types, where each Struct represents a distinct message.

import msgspec as ms
import typing as typ

# Define individual message structures
class Join(ms.Struct, tag='join'):
    room: str

class SendMessage(ms.Struct, tag='sendMessage'): # Note CamelCase tag
    text: str

# Create a tagged union of all possible messages
MessageUnion = typ.Union[Join, SendMessage]

class ChatResource(WebSocketResource):
    # Associate the schema with the resource
    schema = MessageUnion

    # Handler by decorator (canonical) for tag='join'
    @handles_message('join')
    async def on_join(self, req, ws, msg: Join):
        print(f"Joining room: {msg.room}")

    # Handler by convention (convenience) for tag='sendMessage'
    # The framework converts 'sendMessage' to 'on_send_message'
    async def on_send_message(self, req, ws, msg: SendMessage):
        print(f"Message received: {msg.text}")

5.3.2. Automated Dispatch Logic

The base WebSocketResource would contain a receive loop that performs the following steps:

  1. Receive a raw message from the WebSocket.

  2. Decode the message using msgspec.json.decode(message, type=self.schema). msgspec's tagged union support automatically inspects a tag field in the JSON to select the correct Struct for decoding.

  3. Inspect the tag of the resulting Struct instance.

  4. Find a handler registered with @handles_message for that tag. If not found, attempt to find a handler by the on_{tag} naming convention.

  5. Dynamically invoke the handler, e.g., await self.on_send_message(req, ws, msg).

This schema-driven design completely eliminates boilerplate dispatch logic and provides "zero-cost" validation via msgspec.

5.3.3. Error Handling

The framework must gracefully handle dispatch errors.

  • A msgspec.ValidationError raised during decoding should be caught, and a customisable error hook should be invoked.

  • If a message has a valid tag that does not correspond to any registered handler, the on_unhandled(self, req, ws, msg) fallback method on the base resource should be called.

5.4. A Multi-Tiered Middleware and Hook System

To handle cross-cutting concerns like authentication, logging, and metrics, a flexible, multi-layered hook system is proposed.

5.4.1. Global and Per-Resource Hooks

  • Global Hooks: Registered on the WebSocketRouter (router.global_hooks), these apply to every connection handled by that router. They are ideal for universal concerns like authentication or establishing database connections.

  • Per-Resource Hooks: Registered on a WebSocketResource class (resource.hooks), these apply only to that resource and any of its sub-resources. This allows for specific logic, such as permissions checks for a particular channel.

5.4.2. Lifecycle Events and Execution Order

The hooks correspond to the WebSocket lifecycle: before_connect, after_connect, before_receive, after_receive, and before_disconnect. The execution order follows a layered, "onion-style" model for any given event, as illustrated below.

flowchart TD
    A[WebSocket Event] --> B[Global Hook]
    B --> C[Resource Hook]
    C --> D[Handler Logic]
    D --> E[After Hooks]
    E --> F[Cleanup/Error Handling]

The specific sequence is as follows:

  1. Global before_* hook(s)

  2. Parent Resource before_* hook(s) (if nested)

  3. Resource-specific before_* hook(s)

  4. The core handler logic (e.g., on_connect or an on_<tag> message handler)

  5. Resource-specific after_* hook(s)

  6. Parent Resource after_* hook(s) (if nested)

  7. Global after_* hook(s)

This provides maximum control, allowing outer layers to act as setup/teardown guards for inner layers. An exception in a before_* hook should terminate the connection attempt immediately.

5.5. Architectural Implications

5.5.1. Stateful Per-Connection Resources and Dependency Injection

This design solidifies the paradigm of a stateful, per-connection resource instance, a shift from Falcon's traditional stateless HTTP model. The resource instance itself becomes the container for connection-specific state. This raises a critical architectural question: how do these ephemeral resource instances get access to long-lived, shared services (like database connection pools)?

To keep the solution Falcon-idiomatic and testable, we adopt an explicit router-level resource factory strategy. Rather than hiding DI concerns behind global registries or magical decorators, the router gains an optional resource_factory callable that participates in every instantiation.

Problem Statement and Goals

WebSocketResource instances are short-lived by design—they exist for the duration of a single connection. They frequently require access to long-lived services (database pools, metrics clients, configuration providers) that should be created once per process. The DI approach must therefore:

  • Make dependencies explicit at the resource boundary.
  • Allow the application to decide how shared services are created and reused.
  • Support simple manual factories as well as full-featured DI containers.
  • Remain easy to override in tests by providing alternate factories.
Router-Level Resource Factory

WebSocketRouter.__init__ accepts an optional resource_factory argument. When omitted, the router preserves the current behaviour—calling the per-route factory captured by add_route() with no extra indirection. When provided, the callable receives that per-route factory (typically a functools.partial wrapping the resource class and static init_args) and returns a fully constructed WebSocketResource instance. This indirection allows the application to inspect the resource signature, resolve dependencies from a DI container, merge them with static route configuration, and return the constructed instance. The public :class:ResourceFactory type alias documents the expected call signature for these factories so applications can annotate their DI hooks consistently.

from falcon_pachinko.router import ResourceFactory


class WebSocketRouter:
    def __init__(
        self,
        *,
        resource_factory: ResourceFactory | None = None,
        ...,
    ):
        self._resource_factory = resource_factory or (lambda factory: factory())

    async def _instantiate_resource(
        self,
        route_factory: Callable[..., WebSocketResource],
        ws: WebSocketLike,
    ) -> WebSocketResource:
        try:
            return self._resource_factory(route_factory)
        except Exception as exc:
            await ws.close()
            exc._pachinko_factory_closed = True
            raise
Example Container Integration

Applications can wire a custom container by passing the resolver to the router. The library ships with a small helper, falcon_pachinko.di.ServiceContainer, that demonstrates one possible integration:

from falcon_pachinko.di import ServiceContainer

container = ServiceContainer()
router = WebSocketRouter(resource_factory=container.create_resource)

router.add_route(
    "/{room_id}",
    ChatResource,
    kwargs={"history_size": 100},
)

ServiceContainer.create_resource() receives the partial generated by add_route(), inspects the target class, and injects dependencies (for example db or analytics attributes on the container) before instantiating the resource. Internally the helper caches each computed inspect.Signature in the private _signature_cache mapping so repeated instantiation does not redo reflection. Should a dependency be missing, ServiceContainer.resolve() raises ServiceNotFoundError (a LookupError subclass) to make failures explicit. Because the router delegates instantiation, unit tests can supply a lightweight factory that injects mocks, while production code can reuse existing DI infrastructure.

Component Relationships

The following class diagram illustrates how the sample ServiceContainer coordinates dependency injection for both HTTP and WebSocket endpoints in the random status example:

classDiagram
    class ServiceContainer {
        -_services: dict[str, object]
        +register(name: str, value: object)
        +resolve(name: str) -> object
        +create_resource(route_factory: Callable[..., WebSocketResource]) -> WebSocketResource
    }
    class StatusResource {
        -_conn_mgr: WebSocketConnectionManager
        -_db: aiosqlite.Connection
        -_conn_id: str | None
        +__init__(conn_mgr: WebSocketConnectionManager, db: aiosqlite.Connection)
        +on_connect(ws: WebSocketLike)
        +on_disconnect(ws: WebSocketLike, close_code: int)
        +update_status(ws: WebSocketLike, payload: StatusPayload)
    }
    class StatusEndpoint {
        -_container: ServiceContainer
        +__init__(container: ServiceContainer)
        +on_get(req: falcon.Request, resp: falcon.Response)
    }
    ServiceContainer --> StatusEndpoint : provides dependencies
    ServiceContainer --> StatusResource : provides dependencies
    StatusEndpoint --> ServiceContainer : resolves 'db'
    StatusResource --> WebSocketConnectionManager
    StatusResource --> aiosqlite.Connection

The comprehensive reference application under examples/reference_app builds on this pattern to exercise every advanced feature in one place. Its router is mounted at /ws and instantiates WorkspaceResourceProjectResourceTaskStreamResource chains through the shared ServiceContainer, allowing hooks to seed per-connection state while message handlers rely on schema-driven dispatch. The same container also wires the announcement worker and HTTP helpers, providing a concrete illustration of the stateful, per-connection resources described in §5.5.1.

Usage Patterns

Practical usage of the resource-factory pattern falls into two complementary scenarios:

  1. Application wiring. The examples/random_status/server.py module now demonstrates the shared ServiceContainer that registers process-wide dependencies at startup and exposes a create_resource() hook to the router. The container mirrors the behaviour described above—capturing the target callable from the route's partial, merging static kwargs with registered services, reusing cached signatures, and returning the final resource instance. A lightweight RouterEndpoint adapter wraps the router so it can be registered via app.add_websocket_route() while still delegating connection handling to the router itself. Both WebSocket resources and regular HTTP endpoints resolve dependencies from the same container, ensuring a single source of truth for shared services.

  2. Test-specific factories. Behavioural and unit tests use lightweight factories to inject fakes without bootstrapping the full container. The falcon_pachinko.unittests.resource_factories.resource_factory() helper returns a callable compatible with WebSocketRouter that simply merges a provided dependency into the stored partial's kwargs. Tests can therefore inject spies or mock services while still exercising the router's lifecycle exactly as production code would.

Both patterns emphasize explicit construction and keep the dependency graph outside the framework core, maintaining Falcon's principle of explicitness.

Benefits
  • Decoupling: Resource classes simply declare constructor arguments—the router decides how those arguments are provided.
  • Flexibility: Any DI approach (manual factories, dataclass-style wiring, external containers such as dependency-injector) can participate without additional framework support.
  • Testability: Tests provide bespoke factories to inject fakes or spies.
  • Backward Compatibility: Existing codebases that rely solely on add_route() behaviour require no changes because the default factory still calls the stored partial directly.

5.5.2. Enabling a "Schema-First" Workflow

The combination of AsyncAPI-aligned routing and msgspec schemas enables a powerful "Schema-First" development workflow. Teams can define their API contract in an asyncapi.yaml file, use tooling to generate msgspec.Struct classes and resource skeletons, and then implement the business logic. The framework, at runtime, uses these same schemas to enforce the contract, preventing drift between implementation and documentation. This is a significant strategic advantage.