← Back to Blog

Explicit beats magic: declaring your backend in pyrpc.json

·7 min read

v0.13.0’s headline change looks bureaucratic, a JSON schema edit. It is actually a philosophy change about who the source of truth is. The old config described your frontend and implied your backend from imports; the new one declares both.

Diagram: flat legacy pyrpc.json flows through the wizard prompts into the nested backend/clients schema

What changed, concretely

{
 "backend": {
 "framework": "flask",
 "entrypoint": "main:app",
 "types_module": "main"
 },
 "clients": [
 { "framework": "Next.js", "root": "../frontend" }
 ]
}
  • framework selects the native dev server (uvicorn / flask run / manage.py runserver).
  • entrypoint is framework-specific by design: a module[:app] target for FastAPI/Flask/ASGI, a filesystem path to manage.py for Django. One name, honest semantics per framework.
  • types_module names where registration happens (see its own post).
  • clients[] carries a framework each, killing the old "Mixed" pseudo-framework that existed only because one field served many roots.

Sniffing demoted from oracle to default

Auto-detection still exists, it is good UX. What changed is its authority:

  • Interactive: sniffing preselects the wizard’s framework choice. You press Enter to confirm or arrow away. Detection is a suggestion, never a decision.
  • --yes: sniff-or-error. If markers (mount_fastapi(, mount_flask(, mount_django(, PyRPCAsgiApp) are absent, the command exits nonzero with guidance instead of guessing. CI gets determinism, not vibes.
  • Invalid input fails before anything else: --framework express exits immediately with the four valid choices, before config loading, before module resolution.

Validation as a data model, not scattered ifs

Parsing lives in one place producing an immutable value:

@dataclass(frozen=True)
class BackendSpec:
 framework: str
 entrypoint: str
 types_module: str | None = None

spec = parse_backend(cfg) # None for absent/invalid/legacy

Three payoffs fell out of this shape. Bare entrypoints normalize once ("main" becomes main:app) so every consumer sees canonical form. Dataclass equality gives the live-reload watcher free, precise diffing. And legacy configs are simply parse_backend() is None, no migration code paths, no version field to maintain: an unreadable file is treated as unconfigured, rewritten in place on next run.

The trade we made on purpose

Yes, first runs now answer one more question. In exchange: no more inferring servers from import graphs, no silent wrong-framework launches, configs readable at a glance, and every flag (--framework, --module, --client) maps to exactly one schema key. Explicitness is not ceremony here, it is what makes the other features (native runners, live restarts) safe to ship.