Hierarchical Routers

Build complex routing structures with nested routers, path-based navigation, and automatic plugin inheritance.

Overview

Genro Routes supports hierarchical router composition where:

  • Parent routers can have child routers attached through explicit instance binding

  • Path separator / navigates the hierarchy (root.api.node("users/list")())

  • Plugins propagate from parent to children automatically

  • Each level maintains independent handler registration

  • Parent tracking maintains the relationship between parent and child instances

  • Automatic cleanup when child instances are replaced

Managing Hierarchies

Genro Routes provides explicit methods for managing RoutingClass hierarchies:

  • attach_instance(child, name=...) - Attach a RoutingClass instance to create parent-child relationship (method on RoutingClass)

  • detach_instance(child) - Remove a RoutingClass instance from the hierarchy (method on Router)

  • Parent tracking - Children track their parent via _routing_parent attribute

  • Auto-detachment - Replacing a child attribute automatically detaches the old instance

Important: attach_instance is a method on RoutingClass (the owner), not on Router. detach_instance remains on Router.

Basic Instance Attachment

Attach a child instance explicitly with an alias:

from genro_routes import RoutingClass, Router, route

class Child(RoutingClass):
    def __init__(self):
        self.api = Router(self, name="api")

    @route("api")
    def list(self):
        return "child:list"

class Parent(RoutingClass):
    def __init__(self):
        self.api = Router(self, name="api")
        # Attach child directly — no need to store as attribute
        self.attach_instance(Child(), name="sales")

parent = Parent()

# Access through hierarchy
assert parent.api.node("sales/list")() == "child:list"

# node() can also resolve to a child router
child_node = parent.api.node("sales")
assert child_node.error is None
assert child_node.path == "sales"

# Retrieve the child instance later via routing.instance()
child = parent.routing.instance("api/sales")
assert child._routing_parent is parent

Key points:

  • The name parameter provides the alias for accessing the child’s router (works as a shortcut when the child has a single router)

  • Storing the child as an attribute is optional — the router tree keeps a strong reference to the child instance via router.instance

  • Use routing.instance("router/child") to retrieve a child instance at any time

  • Parent tracking is handled automatically

node() return values:

  • Returns a callable RouterNode if the path resolves to a handler

  • If the path points to a child router, uses that router’s default_entry

  • Check node.error to see if resolution succeeded

Multiple Routers: Cross-Mapping

When a child has multiple routers, use explicit cross-mapping via router_<parent_router>=... kwargs:

class MultiRouterChild(RoutingClass):
    def __init__(self):
        self.api = Router(self, name="api")
        self.admin = Router(self, name="admin")

    @route("api")
    def get_data(self):
        return "data"

    @route("admin")
    def manage(self):
        return "manage"

class Parent(RoutingClass):
    def __init__(self):
        self.api = Router(self, name="api")
        self.admin = Router(self, name="admin")
        self.child = MultiRouterChild()

parent = Parent()

# Cross-map child routers to parent routers
parent.attach_instance(parent.child,
    router_api="api:sales",
    router_admin="admin:admin_panel",
)

assert parent.api.node("sales/get_data")() == "data"
assert parent.admin.node("admin_panel/manage")() == "manage"

Cross-mapping syntax:

  • Each kwarg is named router_<parent_router_name>

  • Value format: "child_router:alias" — comma-separated for multiple child routers on the same parent

  • Unmapped routers are not attached

  • Useful for selective exposure and renaming

Parent with Multiple Routers

When child has a single router, use name= to specify the alias. The child’s router is attached to the parent router that matches by name, or to the single parent router if there is only one:

class MultiRouterParent(RoutingClass):
    def __init__(self):
        self.api = Router(self, name="api")
        self.admin = Router(self, name="admin")
        self.child = Child()  # Child has single router named "api"

parent = MultiRouterParent()

# name= shortcut: child's "api" router attaches to parent's "api" router
parent.attach_instance(parent.child, name="child_alias")
assert "child_alias" in parent.api._children

When both parent and child have multiple routers, use cross-mapping kwargs instead of name= (see previous section).

Branch Routers

Create pure organizational nodes with branch routers:

class OrganizedService(RoutingClass):
    def __init__(self):
        # Branch router: pure container, no handlers
        self.api = Router(self, name="api", branch=True)

        # Add handler routers as children
        self.users = UserService()
        self.products = ProductService()

        self.attach_instance(self.users, name="users")
        self.attach_instance(self.products, name="products")

service = OrganizedService()

# Access through branch
service.api.node("users/list")()
service.api.node("products/create")()

Branch router characteristics:

  • Cannot register handlers - No @route methods allowed

  • Pure containers - Only for organizing child routers

  • Useful for - API namespacing and logical grouping

When to use branches:

# Good: Organize related services under /api namespace
self.api = Router(self, name="api", branch=True)
self.attach_instance(self.auth, name="auth")
self.attach_instance(self.users, name="users")
# Routes: api/auth/login, api/users/list

# Not needed: Single level with handlers
self.api = Router(self, name="api")  # Regular router

When NOT to use branches:

Branch routers add a level to your URL hierarchy. Use them only when you need pure organizational containers:

# DON'T: Branch with single child (unnecessary nesting)
self.api = Router(self, name="api", branch=True)
self.users = UserService()
self.attach_instance(self.users, name="users")
# Result: api/users/list - the "api" level adds nothing

# DO: Regular router with handlers + attached children
self.api = Router(self, name="api")  # Has its own handlers
self.users = UserService()
self.attach_instance(self.users, name="users")

@route("api")
def health(self):  # api/health - root level handler
    return "ok"
# Result: api/health + api/users/list - "api" has purpose

Decision guide:

Scenario

Use Branch?

Pure namespace (no root handlers)

Yes

Root router with handlers + children

No

Single child service

No (attach directly)

Multiple children, common namespace

Yes

Direct Router Hierarchies with parent_router

Create router hierarchies directly without separate RoutingClass instances using parent_router:

class Service(RoutingClass):
    def __init__(self):
        # Parent branch router
        self.api = Router(self, name="api", branch=True)

        # Child routers attached via parent_router parameter
        self.users = Router(self, name="users", parent_router=self.api)
        self.orders = Router(self, name="orders", parent_router=self.api)

    @route("users")
    def list_users(self):
        return ["alice", "bob"]

    @route("orders")
    def list_orders(self):
        return ["order1", "order2"]

svc = Service()

# Access through hierarchy
assert svc.api.node("users/list_users")() == ["alice", "bob"]
assert svc.api.node("orders/list_orders")() == ["order1", "order2"]

Key characteristics:

  • Same instance: All routers share the same owner instance

  • Automatic attachment: Child registers itself in parent’s _children dict

  • Plugin inheritance: _on_attached_to_parent() is called for plugin propagation

  • Name required: Child router must have a name (used as the hierarchy key)

  • Collision detection: Raises ValueError if name already exists in parent

When to use parent_router vs attach_instance:

Use Case

Method

Same instance, multiple routers

parent_router

Different RoutingClass instances

self.attach_instance(child, ...)

Dynamic attachment/detachment

self.attach_instance(child, ...) / router.detach_instance(child)

Static hierarchy at init time

parent_router

Example: Mixed hierarchy:

class Application(RoutingClass):
    def __init__(self):
        # Root branch
        self.api = Router(self, name="api", branch=True)

        # Direct children via parent_router
        self.users = Router(self, name="users", parent_router=self.api)
        self.products = Router(self, name="products", parent_router=self.api)

        # External service via attach_instance
        self.auth_service = AuthService()
        self.attach_instance(self.auth_service, name="auth")

    @route("users")
    def list_users(self):
        return ["alice", "bob"]

    @route("products")
    def list_products(self):
        return ["widget", "gadget"]

app = Application()

# All accessible through hierarchy
app.api.node("users/list_users")()      # Direct child
app.api.node("products/list_products")() # Direct child
app.api.node("auth/login")()            # Attached instance

Auto-Detachment

Replacing a child attribute automatically detaches the old instance:

class Parent(RoutingClass):
    def __init__(self):
        self.api = Router(self, name="api")
        self.child = Child()
        self.attach_instance(self.child, name="child")

parent = Parent()
assert parent.child._routing_parent is parent
assert "child" in parent.api._children

# Replacing the attribute triggers auto-detach
parent.child = None

# Old child is automatically removed from hierarchy
assert "child" not in parent.api._children

Auto-detachment behavior:

  • Triggered when setting parent.attribute = new_value

  • Only detaches if old value’s _routing_parent is this parent

  • Clears _routing_parent on detached instance

  • Removes from all parent routers automatically

  • Best-effort: ignores errors to avoid blocking attribute assignment

Use cases:

# Replacing a service implementation
parent.auth_service = OldAuthService()
parent.attach_instance(parent.auth_service, name="auth")

# Later: automatic cleanup
parent.auth_service = NewAuthService()  # Old service auto-detached
parent.attach_instance(parent.auth_service, name="auth")

Parent Tracking

Every attached RoutingClass tracks its parent:

class Child(RoutingClass):
    def __init__(self):
        self.api = Router(self, name="api")

    def get_parent_info(self):
        if self._routing_parent:
            return f"My parent is {type(self._routing_parent).__name__}"
        return "No parent"

child = Child()
assert child._routing_parent is None  # Not attached

parent = Parent()
parent.child = child
parent.attach_instance(parent.child, name="child")
assert child._routing_parent is parent  # Parent tracked

parent.api.detach_instance(child)
assert child._routing_parent is None  # Cleared on detach

Parent tracking enables:

  • Context awareness in child methods

  • Access to parent’s state and configuration

  • Proper cleanup on detachment

  • Preventing duplicate attachments

Plugin Inheritance

Plugins propagate automatically from parent to children:

class Service(RoutingClass):
    def __init__(self, name: str):
        self.name = name
        self.api = Router(self, name="api")

    @route("api")
    def process(self):
        return f"{self.name}:process"

class Application(RoutingClass):
    def __init__(self):
        # Plugin attached to parent
        self.api = Router(self, name="api").plug("logging")
        self.service = Service("main")

app = Application()

# Attach child - plugins inherit automatically
app.attach_instance(app.service, name="service")

# Child router has the logging plugin
assert hasattr(app.service.api, "logging")

# Plugin applies to child handlers
result = app.service.api.node("process")()
# Logging plugin was active during call

Inheritance rules:

  • Parent plugins apply to all child handlers

  • Children can add their own plugins

  • Plugin order: parent plugins -> child plugins

  • Configuration inherits but can be overridden

Path Navigation

Navigate hierarchy with path separator / via routing.get_router():

class Child(RoutingClass):
    def __init__(self):
        self.api = Router(self, name="api")

class Parent(RoutingClass):
    def __init__(self):
        self.api = Router(self, name="api")
        self.child = Child()
        self.attach_instance(self.child, name="child")

parent = Parent()

# Get child router directly
child_router = parent.routing.get_router("api/child")
assert child_router.name == "api"
assert child_router.instance is parent.child

Navigation features:

  • get_router("router/child/grandchild") traverses hierarchy

  • Returns the target router instance

  • Enables programmatic router access

  • Useful for dynamic configuration

Direct Router Lookup with router_at_path

You can also navigate the hierarchy directly from any router using router_at_path():

# From a router, find a child router by path
child_router = parent.api.router_at_path("child/grandchild")
if child_router is not None:
    print(child_router.name)

Differences from routing.get_router():

  • router_at_path(path) is called directly on a Router instance and navigates its children

  • Returns None if the path doesn’t resolve (instead of raising AttributeError)

  • Does not require RoutingClass — works on any BaseRouter

Introspection

Inspect the full hierarchy structure:

class Inspectable(RoutingClass):
    def __init__(self):
        self.api = Router(self, name="api")
        self.service = Service("child")
        self.attach_instance(self.service, name="sub")

    @route("api")
    def action(self):
        pass

insp = Inspectable()

# Get complete hierarchy metadata
info = insp.api.nodes()
assert "action" in info["entries"]
assert "sub" in info["routers"]

# Child routers included
child_info = info["routers"]["sub"]
assert child_info["name"] == "api"

# Get nodes starting from a child
sub_only = insp.api.nodes(basepath="sub")
assert "list" in sub_only["entries"]

# Lazy mode: children are router references
lazy = insp.api.nodes(lazy=True)
sub_router = lazy["routers"]["sub"]  # Router reference, not expanded
sub_expanded = sub_router.nodes()  # Expand on demand

nodes() parameters:

  • basepath: Start from a specific point in the hierarchy (e.g., "child/grandchild")

  • lazy: Return router references instead of expanding recursively

  • mode: Output format mode (e.g., "openapi" for OpenAPI schema generation)

# Generate OpenAPI schema for the hierarchy
schema = insp.api.nodes(mode="openapi")

Introspection provides:

  • Complete handler list at each level

  • Child router names and structure

  • Plugin configuration per level

  • Nested hierarchy representation

  • On-demand expansion with lazy=True

  • OpenAPI schema generation with mode="openapi"

Catch-All Routes with default_entry

Routers can handle paths that don’t fully resolve by delegating to a default_entry handler (best-match resolution):

class FileService(RoutingClass):
    def __init__(self):
        # default_entry="index" is the default
        self.api = Router(self, name="api")

    @route("api")
    def index(self, *path_segments):
        return f"File: {'/'.join(path_segments)}"

class Application(RoutingClass):
    def __init__(self):
        self.api = Router(self, name="api")
        self.files = FileService()
        self.attach_instance(self.files, name="files")

app = Application()

# Path "files/docs/readme.md" - "files" is a child router,
# "docs/readme.md" doesn't exist, so best-match resolution uses default_entry
node = app.api.node("files/docs/readme.md")
# Unconsumed segments passed as args: ["docs", "readme.md"]
assert node() == "File: docs/readme.md"

Behavior by scenario (best-match resolution):

Path

Scenario

Result

/ or ""

Empty path

Uses this router’s default_entry

child/handler

Handler exists

Returns RouterNode with handler

child/unknown/path

Child exists, path unresolved

Uses child’s default_entry with args

child (router only)

Single segment, is router

Uses child’s default_entry

unknown/path

Nothing found

Uses this router’s default_entry with args

Root node (empty path):

When you call node("/") or node(""), you get a root node:

class Service(RoutingClass):
    def __init__(self):
        self.api = Router(self, name="api")

    @route("api")
    def index(self):
        return "home"

svc = Service()
node = svc.api.node("/")

# Root node properties
assert node.path == ""
assert node.error is None  # default_entry exists

# If default_entry exists, it's callable
assert node() == "home"

If no default_entry exists, calling raises NotFound.

Custom default_entry:

class CustomService(RoutingClass):
    def __init__(self):
        self.api = Router(self, name="api", default_entry="catch_all")

    @route("api")
    def catch_all(self, *args):
        return f"Caught: {args}"

Error handling:

If default_entry handler doesn’t exist in the target router, an empty RouterNode is returned:

class EmptyService(RoutingClass):
    def __init__(self):
        self.api = Router(self, name="api")  # No "index" entry!

svc = EmptyService()
node = svc.api.node("unknown/path")
# node.error will indicate the path couldn't be resolved

Real-World Examples

Microservice-Style Organization

class AuthService(RoutingClass):
    def __init__(self):
        self.api = Router(self, name="api")

    @route("api")
    def login(self, username: str, password: str):
        return {"token": "..."}

    @route("api")
    def logout(self, token: str):
        return {"status": "ok"}

class UserService(RoutingClass):
    def __init__(self):
        self.api = Router(self, name="api")

    @route("api")
    def list_users(self):
        return ["alice", "bob"]

    @route("api")
    def get_user(self, user_id: int):
        return {"id": user_id, "name": "..."}

class Application(RoutingClass):
    def __init__(self):
        # Root router with logging
        self.api = Router(self, name="api").plug("logging")

        # Create services
        self.auth = AuthService()
        self.users = UserService()

        # Attach to hierarchy
        self.attach_instance(self.auth, name="auth")
        self.attach_instance(self.users, name="users")

app = Application()

# Access through hierarchy
token = app.api.node("auth/login")("alice", "secret123")
users = app.api.node("users/list_users")()

# Logging applies to all handlers automatically

Multi-Level Organization with Branches

class ReportsAPI(RoutingClass):
    def __init__(self):
        self.api = Router(self, name="api")

    @route("api")
    def sales_report(self):
        return "sales data"

    @route("api")
    def inventory_report(self):
        return "inventory data"

class AdminAPI(RoutingClass):
    def __init__(self):
        # Branch for organization
        self.api = Router(self, name="api", branch=True)

        self.users = UserService()
        self.reports = ReportsAPI()

        self.attach_instance(self.users, name="users")
        self.attach_instance(self.reports, name="reports")

class Application(RoutingClass):
    def __init__(self):
        self.api = Router(self, name="api", branch=True)

        # Public API
        self.public = UserService()  # Simplified public interface

        # Admin API (protected, more capabilities)
        self.admin = AdminAPI()

        self.attach_instance(self.public, name="public")
        self.attach_instance(self.admin, name="admin")

app = Application()

# Clean hierarchy
app.api.node("public/list_users")()           # Public access
app.api.node("admin/users/get_user")(123)     # Admin user access
app.api.node("admin/reports/sales_report")()  # Admin reports

Dynamic Service Replacement

class ServiceV1(RoutingClass):
    def __init__(self):
        self.api = Router(self, name="api")

    @route("api")
    def process(self, data: str):
        return f"v1:{data}"

class ServiceV2(RoutingClass):
    def __init__(self):
        self.api = Router(self, name="api")

    @route("api")
    def process(self, data: str):
        return f"v2:{data}"

class Application(RoutingClass):
    def __init__(self):
        self.api = Router(self, name="api")
        self.service = ServiceV1()
        self.attach_instance(self.service, name="processor")

    def upgrade_service(self):
        # Auto-detachment happens here
        self.service = ServiceV2()
        self.attach_instance(self.service, name="processor")

app = Application()
assert app.api.node("processor/process")("test") == "v1:test"

app.upgrade_service()  # Seamless replacement
assert app.api.node("processor/process")("test") == "v2:test"

Best Practices

Logical Grouping with Branches

# Use branch routers for pure organization
class API(RoutingClass):
    def __init__(self):
        self.root = Router(self, name="root", branch=True)

        # Group related services
        self.auth = AuthService()
        self.users = UserService()
        self.orders = OrderService()

        self.attach_instance(self.auth, name="auth")
        self.attach_instance(self.users, name="users")
        self.attach_instance(self.orders, name="orders")

Shared Plugins at Root

# Apply common plugins to entire hierarchy
self.api = Router(self, name="api")\
    .plug("logging")\
    .plug("pydantic")

# All children inherit both plugins
self.attach_instance(self.auth, name="auth")
self.attach_instance(self.users, name="users")

Deep Hierarchies

# Organize by domain and subdomain
app.attach_instance(self.admin, name="admin")
admin.attach_instance(self.user_admin, name="users")
admin.attach_instance(self.report_admin, name="reports")

# Access: app.api.node("admin/users/create_user")()
#         app.api.node("admin/reports/sales_report")()

Retrieve Child Instances

# Retrieve attached child instances via routing.instance()
child = parent.routing.instance("api/child")  # returns the RoutingClass instance

Explicit Detachment

# Explicit detachment for clarity
if should_remove_service:
    self.api.detach_instance(self.old_service)
    self.old_service = None  # Clear reference

Prevent Name Collisions

# Use descriptive aliases
self.attach_instance(self.auth, name="auth_v1")
self.attach_instance(self.new_auth, name="auth_v2")

# Access both versions
self.api.node("auth_v1/login")()
self.api.node("auth_v2/login")()

Common Patterns

Parent-Aware Children

class ChildService(RoutingClass):
    def __init__(self):
        self.api = Router(self, name="api")

    @route("api")
    def get_config(self):
        # Access parent context
        if self._routing_parent:
            return self._routing_parent.config
        return {}

Conditional Attachment

class Application(RoutingClass):
    def __init__(self, config):
        self.api = Router(self, name="api")

        # Attach based on configuration
        if config.get("enable_auth"):
            self.auth = AuthService()
            self.attach_instance(self.auth, name="auth")

        if config.get("enable_admin"):
            self.admin = AdminService()
            self.attach_instance(self.admin, name="admin")

Multi-Router Services

class DualInterfaceService(RoutingClass):
    def __init__(self):
        self.public = Router(self, name="public")
        self.admin = Router(self, name="admin")

    @route("public")
    def public_endpoint(self):
        return "public data"

    @route("admin")
    def admin_endpoint(self):
        return "admin data"

# Cross-map child routers to parent routers
parent.attach_instance(service,
    router_api="public:api",
    router_admin="admin:admin_api",
)

Next Steps