
The Dispatcher Pattern
When you need to maintain multiple Python implementations that must expose identical interfaces, you face a tricky problem: how do you keep the namespaces and function signatures perfectly in sync? Today, we want to share how we solved this in Cloacina using what we call the dispatcher pattern.
The Challenge
While we’ll use Cloacina’s database backends as our example, this pattern applies to any situation where you need to maintain multiple implementations with identical interfaces. In our case, Cloacina is a workflow orchestration library written in Rust that supports both PostgreSQL and SQLite as storage backends. When creating Python bindings, we wanted to:
- Allow users to install only the implementation they need (minimize dependencies)
- Provide a single, consistent import interface regardless of implementation
- Avoid runtime overhead from unused implementations
- Make implementation selection explicit and predictable
The Dispatcher Pattern
The solution we implemented uses what we call the “dispatcher pattern” - a pure Python package that acts as a thin routing layer between the user and the actual backend implementation.
Here’s how it works:
1. Separate Packages
We have three packages:
cloaca
- The dispatcher (pure Python)cloaca-postgres
- PostgreSQL backend (Rust extension via PyO3)cloaca-sqlite
- SQLite backend (Rust extension via PyO3)
INFO
These packages are mutually exclusive because Cloacina uses Rust’s feature flag system to compile only the code needed for each backend. The feature flags are set at compile time, making it impossible to have both backends active in the same binary.
2. Installation via Pip Extras
Users install the package with their desired backend:
# For PostgreSQL
pip install cloaca[postgres]
# For SQLite
pip install cloaca[sqlite]
This uses pip’s “extras” feature to pull in the appropriate backend package as a dependency.
3. Dynamic Backend Loading
Let’s walk through how the dispatcher works at runtime. When a user imports the package, Python executes the module in this order:
- Module Setup First, the module imports and initial setup:
import importlib
from typing import Any, Optional
__backend__: Optional[str] = None
- Helper Functions Next, Python defines the helper functions (but doesn’t execute them yet):
def _validate_backend_version(module: Any, backend_name: str) -> None:
if hasattr(module, "__version__") and module.__version__ != __version__:
raise ImportError(f"Version mismatch: dispatcher {__version__} != {backend_name} {module.__version__}")
INFO
The __version__
variable is automatically injected by the build system (in our case, Maturin) from the version specified in pyproject.toml
. This ensures version consistency between the dispatcher and its backends.
- Backend Loading Logic Then, the backend loading logic is defined (before it’s used):
def _load_backend() -> tuple[Any, str]:
available_backends = []
# Try each backend
for backend in ["postgres", "sqlite"]:
try:
module = importlib.import_module(f"cloaca_{backend}")
_validate_backend_version(module, backend)
available_backends.append((backend, module))
except ImportError as e:
if "Version mismatch" in str(e): raise
continue
# Select appropriate backend
if not available_backends:
raise ImportError("No backend installed. Install: pip install cloaca[postgres] or cloaca[sqlite]")
# there can only be 1 backend
if len(available_backends) > 1:
raise ImportError(f"Multiple backends installed: {', '.join(b for b, _ in available_backends)}")
return available_backends[0]
- Main Execution Finally, the main execution block runs during import:
try:
# Try to load a backend
_backend_module, __backend__ = _load_backend()
# Re-export all backend symbols
__all__ = getattr(_backend_module, "__all__", [])
for attr in __all__:
globals()[attr] = getattr(_backend_module, attr)
# Explicitly export common symbols
if hasattr(_backend_module, "hello_world"):
hello_world = _backend_module.hello_world
if hasattr(_backend_module, "get_backend"):
get_backend = _backend_module.get_backend
except ImportError as import_error:
# Create placeholder functions if no backend
def _raise_no_backend(*args, **kwargs):
raise ImportError(str(import_error))
hello_world = get_backend = _raise_no_backend
__all__ = ["hello_world", "get_backend"]
This means when a user does import cloaca
, the backend is already loaded and all symbols are ready to use (or error-raising placeholders are in place if no backend was found).
Here’s what happens in different scenarios:
# Happy path - PostgreSQL backend installed
>>> import cloaca
>>> cloaca.hello_world()
'Hello from Cloaca backend!'
>>> cloaca.get_backend()
'postgres'
# Happy path - SQLite backend installed
>>> import cloaca
>>> cloaca.hello_world()
'Hello from Cloaca backend!'
>>> cloaca.get_backend()
'sqlite'
# Sad path - No backend installed
>>> import cloaca
>>> cloaca.hello_world()
ImportError: No backend installed. Install: pip install cloaca[postgres] or cloaca[sqlite]
# Sad path - Version mismatch
>>> import cloaca
ImportError: Version mismatch: dispatcher 1.0.0 != postgres 1.1.0. Fix: pip uninstall cloaca cloaca-postgres && pip install cloaca[postgres]==1.0.0
# Sad path - Multiple backends installed
>>> import cloaca
ImportError: Multiple backends installed: postgres, sqlite. Use separate virtual environments.
The dispatcher ensures that:
- Users get a clear error if no backend is installed
- Version mismatches are caught early with helpful fix instructions
- Multiple backends are prevented to avoid confusion
- The API remains consistent regardless of which backend is used
Benefits of This Approach
1. Clean User Experience
Users always import from the same package:
import cloaca
# Works the same regardless of backend
workflow = cloaca.Workflow("my-workflow")
2. Minimal Dependencies
Users only install what they need. A PostgreSQL user doesn’t need SQLite libraries, and vice versa.
CAUTION
These backends aren’t just optional dependencies - they’re mutually exclusive because they’re compiled with Rust’s feature flags. The PostgreSQL and SQLite implementations can’t coexist in the same binary, making the dispatcher pattern essential for managing these competing implementations.
3. Compile-Time Optimization
Since we use conditional compilation in Rust, each backend package only contains the code it needs:
#[pymodule]
#[cfg(feature = "postgres")]
fn cloaca_postgres(m: &Bound<'_, PyModule>) -> PyResult<()> {
// PostgreSQL-specific implementation
}
#[pymodule]
#[cfg(feature = "sqlite")]
fn cloaca_sqlite(m: &Bound<'_, PyModule>) -> PyResult<()> {
// SQLite-specific implementation
}
4. Clear Error Messages
If no backend is installed, users get a helpful error message telling them exactly what to do.
5. Virtual Environment Isolation
The pattern naturally encourages users to use separate virtual environments for different backends, preventing configuration conflicts.
The Build System Challenge
INFO
While we’ve embraced WET (Write Every Time) in previous posts for its simplicity, this case required a more sophisticated approach. The complexity of managing competing Rust implementations and ensuring version consistency across multiple packages would create an unsustainable maintenance burden if we duplicated code.
Implementing the dispatcher pattern introduces a significant build system challenge: how do you avoid code duplication while maintaining clean separation between backends?
Our solution uses a sophisticated template-based build system that generates all configuration files and Python code from a single source. We built this using Angreal, our task automation and project templating tool that combines Python’s flexibility with Rust’s performance.
# .angreal/task_cloaca.py
@cloaca()
@angreal.command(name="generate", about="generate all configuration files from templates")
@angreal.argument(name="backend", long="backend", help="Backend to generate for: postgres or sqlite", required=True)
def generate(backend):
"""Generate all configuration files from templates."""
try:
version = get_workspace_version()
project_root = Path(angreal.get_root()).parent
template_dir = Path(angreal.get_root()) / "templates"
# Generate dispatcher pyproject.toml
dispatcher_template = template_dir / "dispatcher_pyproject.toml.j2"
dispatcher_content = render_template(dispatcher_template.read_text(), {"version": version})
dispatcher_path = project_root / "cloaca" / "pyproject.toml"
# Generate backend Cargo.toml
backend_template = template_dir / "backend_cargo.toml.j2"
backend_content = render_template(backend_template.read_text(), {"backend": backend, "version": version})
backend_path = project_root / "cloaca-backend" / "Cargo.toml"
# Generate backend pyproject.toml
backend_pyproject_template = template_dir / "backend_pyproject.toml.j2"
backend_pyproject_content = render_template(backend_pyproject_template.read_text(), {"backend": backend, "version": version})
backend_pyproject_path = project_root / "cloaca-backend" / "pyproject.toml"
# Write files
files_to_write = {
dispatcher_path: dispatcher_content,
backend_path: backend_content,
backend_pyproject_path: backend_pyproject_content
}
print(f"Writing {len(files_to_write)} files...")
for file_path, content in files_to_write.items():
write_file_safe(file_path, content, backup=False)
print(f" {file_path}")
We generate our build files from templates, rendering them with version extracted from the workspace Cargo file and the selected backend. These files are intentionally clobbered regularly to maintain a clean “just-in-time” build pattern - we’ll explore this approach in depth later.
Testing Considerations
Testing becomes quite complex with this pattern. We solved it by creating ephemeral virtual environments for each test run:
def _build_and_install_cloaca_backend(backend, venv_name):
"""Build and install backend in a fresh virtualenv for testing."""
venv = VirtualEnv(path=venv_name, now=True)
pip = venv.path / "bin" / "pip3"
python = venv.path / "bin" / "python"
backend_dir = Path("cloaca-backend")
# Install dependencies
subprocess.run([python, "-m", "ensurepip"], check=True)
subprocess.run([pip, "install", "maturin", "pytest"], check=True)
subprocess.run([pip, "install", "-e", "cloaca"], check=True)
# Build and install backend wheel
subprocess.run([
venv.path / "bin" / "maturin", "build",
"--no-default-features", "--features", backend, "--release"
], cwd=backend_dir, check=True)
wheel = next((backend_dir / "target" / "wheels").glob(f"cloaca_{backend}-*.whl"))
subprocess.run([pip, "install", str(wheel)], check=True)
return venv, python, pip
This snippet shows the core steps for building and installing a backend in an isolated environment for testing. For clarity, we’ve omitted logging, defensive checks, and cleanup logic that would be present in “production” code.
Rust Module Implementation
A minimal backend module with feature flag and exports:
use pyo3::prelude::*;
#[pyfunction]
fn hello_world() -> String {
"Hello from Cloaca backend!".to_string()
}
#[pymodule]
#[cfg(feature = "postgres")]
fn cloaca_postgres(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_function(wrap_pyfunction!(hello_world, m)?)?;
Ok(())
}
#[pymodule]
#[cfg(feature = "sqlite")]
fn cloaca_sqlite(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_function(wrap_pyfunction!(hello_world, m)?)?;
Ok(())
}
This is a PyO3 implementation that compiles and binds Rust code to Python. For more details, check out the PyO3 documentation.
Version Safety and Backend Coupling
One critical aspect of the dispatcher pattern is ensuring tight coupling between the dispatcher version and backend versions to prevent runtime failures and API inconsistencies.
The Version Coupling Challenge
Since the dispatcher dynamically loads backends at runtime, there’s a risk of version mismatches:
# What happens if these versions don't match?
pip install cloaca[postgres]==1.0.0 # Installs cloaca==1.0.0 + cloaca-postgres==1.0.0
pip install cloaca-postgres==1.1.0 # Accidentally upgrades backend but not dispatcher
Our Solution: Synchronized Versioning
We enforce version synchronization through several mechanisms:
1. Exact Version Pinning in pip extras
# dispatcher pyproject.toml template
[project.optional-dependencies]
postgres = ["cloaca-postgres=={{version}}"] # Exact version, no flexibility
sqlite = ["cloaca-sqlite=={{version}}"]
2. Runtime Version Validation
# In dispatcher __init__.py
def _validate_backend_version(backend_module, expected_version):
"""Ensure backend version matches dispatcher version."""
backend_version = getattr(backend_module, '__version__', None)
if backend_version != expected_version:
raise ImportError(
f"Backend version mismatch: expected {expected_version}, "
f"got {backend_version}. Reinstall with 'pip install cloaca[backend]' "
f"to ensure version compatibility."
)
# Called during backend loading
_validate_backend_version(_backend_module, __version__)
3. Build-Time Version Consistency
Our template system ensures all packages use the same version:
# All templates receive the same version context
context = {"backend": backend, "version": version} # From workspace Cargo.toml
API Compatibility Enforcement
Beyond version numbers, we ensure API compatibility:
1. Shared Interface Definition
# Each backend must expose the same interface
REQUIRED_BACKEND_ATTRS = ['hello_world', 'get_backend', '__backend__', '__version__']
def _validate_backend_interface(backend_module):
"""Ensure backend implements required interface."""
missing_attrs = [attr for attr in REQUIRED_BACKEND_ATTRS
if not hasattr(backend_module, attr)]
if missing_attrs:
raise ImportError(f"Backend missing required attributes: {missing_attrs}")
Safety Implications
Version coupling failures can cause:
- Silent API changes - Methods exist but behave differently
- Missing functionality - New dispatcher features not available in old backends
- Runtime crashes - Incompatible data structures or calling conventions
- Security issues - Different validation logic between versions
Trade-offs
Benefits of tight coupling:
- Guaranteed compatibility at runtime
- Simplified testing (fewer version combinations)
- Clear error messages for version mismatches
Costs of tight coupling:
- Users can’t mix and match versions for bug fixes
- Coordinated releases required for all packages
- More complex CI/CD pipeline
When to Use This Pattern
The dispatcher pattern is ideal when:
- You have multiple implementations that are mutually exclusive (e.g., different backends, feature sets, or configurations).
- You want to minimize user dependencies by allowing them to install only what they need.
- You need compile-time optimization for each implementation.
- You want a single, consistent API regardless of the underlying implementation.
It might not be the best choice if:
- Users commonly need to switch implementations at runtime.
- The implementations are lightweight and don’t benefit from separate compilation.
- You need to support multiple implementations simultaneously in the same process.
Lessons Learned: Complexity vs. Benefits
Building this system taught us valuable lessons about when sophisticated automation makes sense versus simpler approaches. Let’s break down what we learned:
When This Approach Makes Sense
The template-based build system was worth the complexity because:
- Scale: We needed to support multiple implementations with identical APIs
- Maintenance: Manual duplication would lead to drift and inconsistencies
- Testing: Complete isolation between implementations was critical
- Distribution: We needed production-ready wheels for PyPI
When Simpler Approaches Are Better
For many projects, this would be overkill. Consider simpler alternatives if:
- You have only one implementation or implementations that don’t need isolation
- Code duplication is minimal and manageable manually
- Testing doesn’t require complete environment isolation
- You don’t need automated wheel building
The Real Cost
The sophistication came with trade-offs:
- Learning curve: New contributors need to understand the template system
- Debugging complexity: Generated files make error tracing harder, requiring composable build steps that allow for picking apart the build process
- Build dependencies: Requires angreal, Docker, and virtual environment management
- Mental overhead: Every change requires thinking about template implications
- IDE limitations: The JIT compilation approach can break modern IDE features, as build files may not exist until generated. We solved this with additional commands to generate and scrub files consistently, but it required extra effort.
Conclusion
The dispatcher pattern provides an elegant solution for Python libraries with multiple implementations or configurations, giving users a clean API while maintaining flexibility and minimizing dependencies. However, the sophisticated build system required to support it represents a significant engineering investment.
The pattern works best for libraries where:
- Implementation separation provides real value to users
- The maintenance overhead of duplication would be significant
- Clean testing isolation is important
- Distribution complexity is manageable
For simpler use cases, traditional approaches may offer better development velocity with acceptable trade-offs. The key is honestly evaluating whether the user experience benefits justify the implementation complexity.
Next time you’re building a Python library with multiple implementation options, carefully weigh these factors before choosing your approach.