Hierarchical Routers
Build complex routing structures with nested routers, path-based navigation, and automatic plugin inheritance.
Overview
Genro Routes supports hierarchical router composition where:
Every RoutingClass owns exactly one router, exposed as the read-only
routepropertyParent instances attach child instances through explicit instance binding
Path separator
/navigates the hierarchy (root.route.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 onRoutingClass)detach_instance(child)- Remove a RoutingClass instance from the hierarchy (method onRouter)Parent tracking - Children track their parent via
_routing_parentattributeAuto-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, route
class Child(RoutingClass):
@route()
def list(self):
return "child:list"
class Parent(RoutingClass):
def __init__(self):
# Attach child directly — no need to store as attribute
self.attach_instance(Child(), name="sales")
parent = Parent()
# Access through hierarchy
assert parent.route.node("sales/list")() == "child:list"
# node() can also resolve to a child router (falls back to its default_entry)
child_node = parent.route.node("sales")
assert child_node.path == "sales"
# Retrieve the child instance later via nodes(basepath=...)
child = parent.route.nodes(basepath="sales")["instance"]
assert child._routing_parent is parent
Key points:
The
nameparameter provides the alias under which the child’s router is linked into the parent’s routerStoring the child as an attribute is optional — the router tree keeps a strong reference to the child instance via
router.instancenodes(basepath="child")returns the child subtree; its"instance"key is the child RoutingClass instanceParent 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_entryCheck
node.errorto see if resolution succeeded
Multiple Surfaces: Composition
A RoutingClass owns exactly one router. When a service must expose more than one
surface (e.g. a public API and an admin area), define one class per surface
and compose them with attach_instance. Grouping levels without handlers are
Section instances:
from genro_routes import RoutingClass, Section, route
class OrdersApi(RoutingClass):
@route()
def get_data(self):
return "data"
class OrdersAdmin(RoutingClass):
@route()
def manage(self):
return "manage"
class Application(RoutingClass):
def __init__(self):
api = Section("Public API")
admin = Section("Admin area")
self.attach_instance(api, name="api")
self.attach_instance(admin, name="admin")
api.attach_instance(OrdersApi(), name="orders")
admin.attach_instance(OrdersAdmin(), name="orders")
app = Application()
assert app.route.node("api/orders/get_data")() == "data"
assert app.route.node("admin/orders/manage")() == "manage"
Composition rules:
One class per surface: each
RoutingClassexposes exactly one routerSelective exposure and renaming happen at attach time via
name=The same alias can be reused under different parents (
api/ordersvsadmin/orders)Handlers shared by several surfaces belong in a plain (non-routing) collaborator class, or can be exposed as entry aliases via
include()(see the Visual Guide)
Grouping with Section
Create pure organizational nodes with Section — an empty RoutingClass used as
a grouping level:
from genro_routes import RoutingClass, Section, route
class OrganizedService(RoutingClass):
def __init__(self):
# Section: pure container, no handlers
api = Section("API surface")
self.attach_instance(api, name="api")
# Attach handler services as children of the section
api.attach_instance(UserService(), name="users")
api.attach_instance(ProductService(), name="products")
service = OrganizedService()
# Access through the section
service.route.node("api/users/list")()
service.route.node("api/products/create")()
Section characteristics:
No handlers of its own - Just an empty router used as container
Optional description -
Section("Admin area")sets the router descriptionUseful for - API namespacing and logical grouping without a dedicated class
When to use Sections:
# Good: Organize related services under an /api namespace
api = Section("API")
self.attach_instance(api, name="api")
api.attach_instance(self.auth, name="auth")
api.attach_instance(self.users, name="users")
# Routes: api/auth/login, api/users/list
When NOT to use Sections:
Sections add a level to your URL hierarchy. Use them only when you need pure organizational containers:
# DON'T: Section with a single child (unnecessary nesting)
api = Section()
self.attach_instance(api, name="api")
api.attach_instance(UserService(), name="users")
# Result: api/users/list - the "api" level adds nothing
# DO: attach children directly; root-level handlers live on the class itself
class Application(RoutingClass):
def __init__(self):
self.attach_instance(UserService(), name="users")
@route()
def health(self): # health - root level handler
return "ok"
# Result: health + users/list - no empty intermediate level
Decision guide:
Scenario |
Use Section? |
|---|---|
Pure namespace (no root handlers) |
Yes |
Root class with handlers + children |
No |
Single child service |
No (attach directly) |
Multiple children, common namespace |
Yes |
Composing Services
Hierarchies are always built by composing RoutingClass instances — there is no way (and no need) to create several routers on the same instance:
class UsersService(RoutingClass):
@route()
def list_users(self):
return ["alice", "bob"]
class OrdersService(RoutingClass):
@route()
def list_orders(self):
return ["order1", "order2"]
class Service(RoutingClass):
def __init__(self):
self.attach_instance(UsersService(), name="users")
self.attach_instance(OrdersService(), name="orders")
@route()
def health(self):
return "ok"
svc = Service()
# Access through hierarchy
assert svc.route.node("users/list_users")() == ["alice", "bob"]
assert svc.route.node("orders/list_orders")() == ["order1", "order2"]
assert svc.route.node("health")() == "ok"
Key characteristics:
One router per instance: every level of the tree is a RoutingClass instance
Plugin inheritance: the child router inherits the parent’s plugins on attach
Name required: the
name=alias is the hierarchy keyCollision detection: attaching raises
ValueErrorif the alias already exists
Auto-Detachment
Replacing a child attribute automatically detaches the old instance:
class Parent(RoutingClass):
def __init__(self):
self.child = Child()
self.attach_instance(self.child, name="child")
parent = Parent()
assert parent.child._routing_parent is parent
assert "child" in parent.route._children
# Replacing the attribute triggers auto-detach
parent.child = None
# Old child is automatically removed from hierarchy
assert "child" not in parent.route._children
Auto-detachment behavior:
Triggered when setting
parent.attribute = new_valueOnly detaches if old value’s
_routing_parentis this parentClears
_routing_parenton detached instanceRemoves the child’s router (all aliases) from the parent router
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):
@route()
def ping(self):
return "pong"
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 getattr(child, "_routing_parent", None) 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.route.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
@route()
def process(self):
return f"{self.name}:process"
class Application(RoutingClass):
def __init__(self):
# Plugin attached to the application's router
self.route.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.route, "logging")
# Plugin applies to child handlers
result = app.service.route.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
Inheritance is triggered by the primary attachment (the first parent); linking the same child elsewhere with
include()is navigational only
Introspection
Inspect the full hierarchy structure:
class Inspectable(RoutingClass):
def __init__(self):
self.service = Service("child")
self.attach_instance(self.service, name="sub")
@route()
def action(self):
pass
insp = Inspectable()
# Get complete hierarchy metadata
info = insp.route.nodes()
assert "action" in info["entries"]
assert "sub" in info["routers"]
# Child routers included
child_info = info["routers"]["sub"]
assert child_info["name"] == "route"
# Get nodes starting from a child
sub_only = insp.route.nodes(basepath="sub")
assert "process" in sub_only["entries"]
# Lazy mode: children are router references
lazy = insp.route.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 recursivelypattern: Regex pattern to filter entry namesforbidden: Include blocked entries with their rejection reason
nodes() returns the dialect-neutral introspection tree; OpenAPI/MCP schemas are produced by transport-layer translators (e.g. genro-asgi) that read this tree, not by a nodes() mode parameter.
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=TrueNeutral
result/paramsblocks that transport-layer translators turn into OpenAPI/MCP schemas
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):
# default_entry="index" is the router default
@route()
def index(self, *path_segments):
return f"File: {'/'.join(path_segments)}"
class Application(RoutingClass):
def __init__(self):
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.route.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 |
|---|---|---|
|
Empty path |
Uses this router’s |
|
Handler exists |
Returns RouterNode with handler |
|
Child exists, path unresolved |
Uses child’s |
|
Single segment, is router |
Uses child’s |
|
Nothing found |
Uses this router’s |
Root node (empty path):
When you call node("/") or node(""), you get a root node:
class Service(RoutingClass):
@route()
def index(self):
return "home"
svc = Service()
node = svc.route.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.route.default_entry = "catch_all"
@route()
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):
pass # No "index" entry!
svc = EmptyService()
node = svc.route.node("unknown/path")
# node.error will indicate the path couldn't be resolved
Real-World Examples
Microservice-Style Organization
class AuthService(RoutingClass):
@route()
def login(self, username: str, password: str):
return {"token": "..."}
@route()
def logout(self, token: str):
return {"status": "ok"}
class UserService(RoutingClass):
@route()
def list_users(self):
return ["alice", "bob"]
@route()
def get_user(self, user_id: int):
return {"id": user_id, "name": "..."}
class Application(RoutingClass):
def __init__(self):
# Root router with logging
self.route.plug("logging")
# Create and attach services
self.attach_instance(AuthService(), name="auth")
self.attach_instance(UserService(), name="users")
app = Application()
# Access through hierarchy
token = app.route.node("auth/login")("alice", "secret123")
users = app.route.node("users/list_users")()
# Logging applies to all handlers automatically
Multi-Level Organization
class ReportsAPI(RoutingClass):
@route()
def sales_report(self):
return "sales data"
@route()
def inventory_report(self):
return "inventory data"
class AdminAPI(RoutingClass):
def __init__(self):
self.attach_instance(UserService(), name="users")
self.attach_instance(ReportsAPI(), name="reports")
class Application(RoutingClass):
def __init__(self):
# Public API (simplified interface)
self.attach_instance(UserService(), name="public")
# Admin API (protected, more capabilities)
self.attach_instance(AdminAPI(), name="admin")
app = Application()
# Clean hierarchy
app.route.node("public/list_users")() # Public access
app.route.node("admin/users/get_user")(123) # Admin user access
app.route.node("admin/reports/sales_report")() # Admin reports
Dynamic Service Replacement
class ServiceV1(RoutingClass):
@route()
def process(self, data: str):
return f"v1:{data}"
class ServiceV2(RoutingClass):
@route()
def process(self, data: str):
return f"v2:{data}"
class Application(RoutingClass):
def __init__(self):
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.route.node("processor/process")("test") == "v1:test"
app.upgrade_service() # Seamless replacement
assert app.route.node("processor/process")("test") == "v2:test"
Best Practices
Logical Grouping
# Compose related services under the root class
class API(RoutingClass):
def __init__(self):
self.attach_instance(AuthService(), name="auth")
self.attach_instance(UserService(), name="users")
self.attach_instance(OrderService(), name="orders")
Deep Hierarchies
# Organize by domain and subdomain
app.attach_instance(admin, name="admin")
admin.attach_instance(user_admin, name="users")
admin.attach_instance(report_admin, name="reports")
# Access: app.route.node("admin/users/create_user")()
# app.route.node("admin/reports/sales_report")()
Retrieve Child Instances
# Retrieve attached child instances via nodes(basepath=...)
child = parent.route.nodes(basepath="child")["instance"] # the RoutingClass instance
Explicit Detachment
# Explicit detachment for clarity
if should_remove_service:
self.route.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.route.node("auth_v1/login")()
self.route.node("auth_v2/login")()
Common Patterns
Parent-Aware Children
class ChildService(RoutingClass):
@route()
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):
# Attach based on configuration
if config.get("enable_auth"):
self.attach_instance(AuthService(), name="auth")
if config.get("enable_admin"):
self.attach_instance(AdminService(), name="admin")
Multi-Surface Services
class PublicOrders(RoutingClass):
@route()
def public_endpoint(self):
return "public data"
class AdminOrders(RoutingClass):
@route()
def admin_endpoint(self):
return "admin data"
# Compose: one class per surface, attached where needed
api = app.route.nodes(basepath="api")["instance"]
admin = app.route.nodes(basepath="admin")["instance"]
api.attach_instance(PublicOrders(), name="orders")
admin.attach_instance(AdminOrders(), name="orders")
Next Steps
Branches Guide - Lazy/eager subtrees and aliases (declarative hierarchies)
Visual Guide - Mermaid diagrams for all connection scenarios
Plugin Configuration - Configure plugins across hierarchies
Best Practices - Production-ready patterns
API Reference - Complete API documentation