Composable Backends with Modular Handlers: Building Services That Grow Without Rewrites

Composable Backends with Modular Handlers: Building Services That Grow Without Rewrites

When a backend is young, everything feels fast: one service, a few routes, everyone knows where things live. A year later, the same service is handling alerts, audits, preferences, billing hooks, experiment flags, and "just one more feature" after every sprint. Regression risk climbs, onboarding slows, and even small refactors start to feel dangerous.

This post is about a different way to grow: composable Python backends built from modular feature handlers. Each feature behaves like a small plugin, it owns its routes, validators, config, teardown path, and test contract. Instead of rewriting the core when new capabilities arrive, you just add or remove a handler.

We'll walk through the problem, the architecture pattern, a step-by-step process to implement it in Python (Flask/FastAPI style), and how this approach keeps services testable, CI-friendly, and maintainable as your product surface area expands.

Problem: when features accumulate, backends lose shape

Most teams start with something like a single Python service (app.py or main.py), a few modules (alerts.py, users.py, billing.py), and shared DB/session clients imported everywhere.

Over time you get scattered feature logic, alerts code mixed with billing code in the same controllers; ad-hoc lifecycle hooks, teardown code sprinkled in try/finally blocks and random decorators; tests that mirror the mess, end-to-end tests for the entire service but very few unit or contract tests per feature; and risky feature additions, adding "just one more" capability feels like threading a needle through fragile wiring.

If you're supporting real devices like a wearable tracker or smart feeder alongside mobile apps, the surface area grows fast. When you add "alerts for low battery" or "timeline of feeding events," those features shouldn't require you to reason about every other feature in the backend. That's exactly what modular handlers give you.

Approach: feature modules as "handlers" with contracts

At a high level, a handler is a feature module that knows how to register itself with the app (routes, background jobs, events), knows what dependencies it needs (DB clients, cache, config), exposes a clear contract for tests, owns its teardown or cleanup, and can be added or removed without touching the core router or other handlers.

The core backend becomes a host, it configures shared infrastructure like the HTTP server, DI container, and logging, discovers and registers handlers at startup, defines a minimal base interface each handler implements, and coordinates global policies like auth, tracing, and rate limiting while handlers focus on business logic. If you've seen plugin architectures in frontends, like modular feature bundles in React or micro-frontend setups, this is the same idea applied to backends.

Process: designing a composable backend in Python

Start with a clear handler contract. Define what it means to be a handler in your system, a clean starting point includes a name and config key, a registration hook (attach routes), lifecycle hooks (on_startup, on_shutdown), and tests that can run without real network calls:

# core/handlers/base.py
from typing import Protocol, Any
 
class Handler(Protocol):
    name: str
 
    def register_routes(self, app: Any) -> None:
        ...
 
    def on_startup(self) -> None:
        ...
 
    def on_shutdown(self) -> None:
        ...

Your actual interface may include DI containers, background schedulers, or event buses, but the idea is the same: define the minimal common shape.

Give each feature a first-class home by structuring your repo so each handler is self-contained, with core/handlers holding base and registry, and features/ holding folders like alerts, preferences, and audits, each with its own handler, models, validators, and tests. A handler folder should be safe to move or delete without breaking unrelated features, and each should have its own mini-API surface rather than random helper functions.

Centralize registration in a handler registry. You don't want app.py importing 20 handlers manually, instead create a registry that knows which handlers exist:

# core/handlers/registry.py
from typing import List
from core.handlers.base import Handler
from features.alerts.handler import AlertsHandler
from features.preferences.handler import PreferencesHandler
from features.audits.handler import AuditHandler
 
def get_all_handlers() -> List[Handler]:
    return [
        AlertsHandler(),
        PreferencesHandler(),
        AuditHandler(),
    ]

In core/app.py, startup looks like:

# core/app.py
from fastapi import FastAPI
from core.handlers.registry import get_all_handlers
 
def create_app() -> FastAPI:
    app = FastAPI()
    handlers = get_all_handlers()
 
    for handler in handlers:
        handler.register_routes(app)
 
    @app.on_event("startup")
    async def startup():
        for handler in handlers:
            handler.on_startup()
 
    @app.on_event("shutdown")
    async def shutdown():
        for handler in handlers:
            handler.on_shutdown()
 
    return app

This keeps the core app dumb and generic. The only thing it knows about features is that they implement the Handler contract. You can later evolve get_all_handlers() into a config-driven list, dynamic discovery via entry points or reflection, or environment-specific handler sets.

Make handlers dependency-injected, not global. Handlers need dependencies like DB sessions, cache clients, and message queues, avoid importing globals directly, inject a container or context instead:

# core/container.py
class Container:
    def __init__(self, db, cache, settings):
        self.db = db
        self.cache = cache
        self.settings = settings

Then in the handler:

# features/alerts/handler.py
from core.handlers.base import Handler
 
class AlertsHandler:
    name = "alerts"
 
    def __init__(self, container):
        self.db = container.db
        self.cache = container.cache
 
    def register_routes(self, app):
        @app.get("/alerts")
        def list_alerts(user_id: str):
            # use self.db, self.cache
            ...
 
    def on_startup(self):
        # preload alert templates, warm caches, etc.
        ...
 
    def on_shutdown(self):
        # optional cleanup
        ...

This pattern scales nicely for device-driven systems where handlers manage streams from wearables or smart bowls, experimentation systems managing feature flags and cohorts, and anything where testability and swap-ability matter.

Treat teardown as a first-class concern. Most backends treat it as an afterthought, in long-lived systems with background tasks this becomes a source of leaks. Putting an explicit on_shutdown hook in the handler contract lets you cleanly stop background jobs or polling loops, flush last-minute metrics or checkpoints, and release non-framework resources. For example, a handler scheduling periodic reconciliation:

# features.audit/handler.py
import threading
import time
 
class AuditHandler:
    name = "audits"
 
    def __init__(self, container):
        self.db = container.db
        self._thread = None
        self._stop = False
 
    def on_startup(self):
        def worker():
            while not self._stop:
                self._run_reconciliation()
                time.sleep(60)
        self._thread = threading.Thread(target=worker, daemon=True)
        self._thread.start()
 
    def on_shutdown(self):
        self._stop = True
        if self._thread:
            self._thread.join(timeout=5)

Because this behavior is per handler, you don't end up with a giant teardown method in app.py that needs to know about every background job in the service.

Making handlers testable by design

If each handler is a module with a clear contract, testing becomes composable too. Contract tests per handler assert certain routes, status codes, and side effects, running with a fake container in isolation. Integration tests for combinations of handlers use the same registry but substitute a subset when needed, running the app with only two specific handlers to validate shared behavior. Golden tests for responses store JSON responses in fixtures and ensure handlers don't break existing contracts.

An example contract test:

# features/alerts/tests/test_contract.py
from fastapi.testclient import TestClient
from core.app import create_app
from core.handlers.registry import get_all_handlers_for_testing
 
def test_alerts_list_contract():
    app = create_app(handlers=get_all_handlers_for_testing(["alerts"]))
    client = TestClient(app)
 
    response = client.get("/alerts?user_id=test-user")
    assert response.status_code == 200
    payload = response.json()
    assert "items" in payload
    # Further assertions on schema, ordering, etc.

The key idea: handlers should be testable with a fake container and without real external services. This is especially useful when you have device-driven features, you can simulate the event stream in the handler's tests without booting the entire stack.

Results: how modular handlers change day-to-day work

Adding features stops feeling dangerous. It becomes creating a new handler file, implementing the Handler contract, registering it, and adding contract tests, all while rarely touching core routing, other handlers, or global teardown logic. This reduces regression risk dramatically and encourages experimentation, especially for cross-cutting capabilities like new alert types, audit sinks, or notification channels.

Onboarding becomes map-driven, not detective work. New engineers can understand the architecture by looking at the handler registry (list of features and their names) and the features folder (each handler's home), instead of grepping for "alerts" across 40 files, they open features/alerts/ and see routes, models, validators, and tests in one place. This is a big win for teams with multiple products or device types.

CI pipelines become more targeted, changes in a specific feature trigger just that feature's unit and contract tests, changes in core trigger a broader smoke suite, and changes in shared models trigger all affected handler tests via a dependency map. You can even run handlers' test suites in parallel, since they're designed to be isolated.

At Hoomanely, we're building long-lived systems around pet wellness, smart devices in the home, mobile apps, and backend pipelines that translate raw data into actionable insights. That means multiple device types and firmware generations over time, new analytics and alerting features as we learn from real-world behavior, and a need to evolve quickly without constantly rewriting the backend core. A modular handler architecture lets us add new event processors as separate handlers, evolve alerting or personalization logic in tight, testable modules, and keep the core HTTP and DI layer stable while device and feature capabilities grow.

Practical advice and common pitfalls

Keep the handler contract small but opinionated, don't turn it into a god interface, include only what every feature truly needs, routes plus lifecycle plus name. Avoid cross-handler imports, if two handlers need shared logic extract it into a shared module, handlers should communicate via events, queues, or shared services, not direct imports. Be intentional about config, give each handler its own config namespace, keeping secrets and credentials in global config but feature toggles per handler. Enforce handler boundaries in code review, reject PRs that put feature logic directly in core/app.py. And document the handler catalog, maintaining a simple table of handler name, purpose, owned routes, and dependencies as your map of the backend for onboarding and incident response.

Key takeaways

Think in feature handlers, not sprawling modules, each handler owns its routes, lifecycle, and tests. Make the core boring, it should host handlers, configure infra, and enforce global policies, nothing more. Let handlers define their dependencies explicitly, use a container or DI so they're easy to test and refactor. Treat lifecycle hooks as design, not cleanup hacks, startup and shutdown are first-class parts of the handler contract. And align architecture with how your team works, handlers make it easy to assign ownership, slice CI, and onboard new engineers. Composable backends with modular handlers won't solve every problem, but they give you a sane way to grow, new capabilities arrive as plug-in modules, not invasive rewrites. For long-lived Python services, especially those supporting devices, apps, and evolving products, that difference is what keeps your backend fast to change and safe to maintain, years down the line.