| name | prefix-match-processor |
| description | Use when branching off the prefix of a string with str.startswith (namespaced name families like "file.", "edit.", "edit.image.") and the number of prefix branches is growing, when a startswith if-chain is becoming hard to test or extend per-family, or when adding several new prefix families to an existing dispatcher. |
Prefix-Match Processor
Overview
A growing if/str.startswith chain where each branch carries real logic is a processor registry waiting to happen (a.k.a. the plugin / strategy registry, chain-of-responsibility). Replace the chain with a list of self-contained plugins — each one an object with run(command) -> Result[T, Command] that returns Failure(command) when it does not handle the input — and a runner that returns the first Success. Adding a family becomes "write a plugin, register it"; the dispatcher never grows.
The input is typically a tagged value — model it with precise-type-modeling. Result composition rules (map, alt, bind) are owned by chaining-returns-results.
When to extract — the threshold
Keep the inline if/match | Extract to a processor registry |
|---|
| ≤ ~5 branches AND every branch is a one-line mapping | > ~5 prefix families, or any branch needs multi-line logic (read fields off the input, validate, branch on the suffix), or families are added over time / by different people and need independent tests |
Do not extract 2–3 one-liners — that is premature. Extract when the chain is the thing that keeps growing.
Before (a prefix if-chain that outgrew itself) ❌
def handle(command: Command) -> Output:
if command.name.startswith("file."):
return handle_file(command)
if command.name.startswith("edit."):
return handle_edit(command)
if command.name.startswith("view."):
return handle_view(command)
return fallback
After (the canonical shape) ✅
The plugin contract — a Protocol, so plugins are plain classes with no inheritance:
from typing import Protocol
from returns.result import Failure, Result, Success
class Plugin[T](Protocol):
def run(self, command: Command) -> Result[T, Command]: ...
One self-contained plugin per family — the prefix predicate lives inside run, and suffix-specific logic stays local to the plugin:
class EditPlugin:
def run(self, command: Command) -> Result[Output, Command]:
if not command.name.startswith("edit."):
return Failure(command)
suffix = command.name.removeprefix("edit.")
if suffix.startswith("image."):
return Success(edit_image(command))
return Success(edit_text(command))
edit_plugin = EditPlugin()
The runner — first match wins (write once, reuse; this is the dispatch engine, not a thin wrapper). .lash runs the next plugin only on Failure and passes a Success straight through — first match wins with no mid-flow unwrapping (see chaining-returns-results):
from collections.abc import Callable, Sequence
def create_runner[T](
plugins: Sequence[Plugin[T]],
) -> Callable[[Command], Result[T, Command]]:
def run(command: Command) -> Result[T, Command]:
result: Result[T, Command] = Failure(command)
for plugin in plugins:
result = result.lash(plugin.run)
return result
return run
Registry + processor — a module-level tuple, the only place that lists families; ordered most-specific-prefix first:
PLUGINS: tuple[Plugin[Output], ...] = (edit_image_plugin, edit_plugin, file_plugin, view_plugin)
runner = create_runner(PLUGINS)
def dispatch(command: Command) -> Result[Output, str]:
return runner(command).alt(lambda c: f"no plugin for: {c.name}")
Adding a family: write SearchPlugin, add its instance to PLUGINS. Test it in isolation: SearchPlugin().run(Command(name="search.run", …)) — no runner, no other families.
Order matters
First match wins, so list the more specific prefix before the broader one: edit.image. before a bare edit.. A broad prefix placed first shadows every specific one after it.
Common mistakes
| Mistake | Fix |
|---|
Plugin raises or returns a bool/None instead of Failure(command) | The runner chains on Result; a non-match MUST be return Failure(command). |
| Re-implementing the dispatch loop ad-hoc each time | Use one shared create_runner; only plugins are per-family. |
| Suffix logic placed in the dispatcher | Keep startswith + suffix branching inside the plugin; the runner stays dumb. |
A dict[str, Callable] keyed by prefix instead of the plugin tuple | A dict keys on exact equality and can't express startswith + suffix branching + specificity ordering. Use ordered plugins. |
| Broad prefix registered before a specific one | Order most-specific-first; first match wins. |
| Extracting 2–3 one-liners into plugins | Premature — keep the inline branch until the threshold above is met. |
Red Flags — STOP
- A
startswith if-chain that keeps gaining branches, or branches gaining their own multi-line logic → extract to a processor registry.
- About to invent a new dispatch shape (
dict[prefix, fn], a list of (prefix, resolver) pairs, a match over startswith guards) → use the run(command) -> Result[T, Command] plugin + first-match runner instead.
- A plugin that returns
None/False/raises on non-match → it must return Failure(command).
- Per-family logic that can only be tested by driving the whole dispatcher → each plugin must be testable on its own.