Logo
Published on

How I Decided Which Runtime Owns a Business Rule

Authors
  • avatar
    Name
    Jeremy Garrell
    Twitter

My side project, a legal-research app called Laymans Lawyer, runs on three runtimes that can each start work and decide something: TypeScript on Cloudflare Workers, Rust background workers, and Postgres itself, through pg_cron jobs, enqueue functions, and classification views. A routine PR audit turned up a business rule duplicated across two of those runtimes, and a migration comment that described the duplication as unavoidable. It wasn't. The fix was a rule that gives every piece of logic exactly one legal home, based on the role it plays rather than which runtime happens to be running it, enforced in CI by checking the system against a declared shape instead of scanning code for bad patterns.

What made three decision-making runtimes a real problem?

I was auditing a merged PR on notification provenance handling, and the audit kept widening. The actual issue wasn't in that PR. It was that my system has three runtimes that can each initiate work and decide what should happen: TypeScript on Cloudflare Workers, Rust background workers, and Postgres, with 9 pg_cron jobs, enqueue functions that gate on conditions, and views that classify data. For any new rule, there were three legal places to put it and no rule for choosing between them. In a spec-to-code-to-QA pipeline where different people, or agents, touch the code over time, "wherever the last person put it" isn't a policy. It's drift.

The detail that convinced me this was a real problem and not a theoretical one: a migration file's own comment described a piece of duplicated business logic, a spend-cap check that existed in both a SQL view and a Rust function, as "unavoidable across the language boundary." That's a placement mistake recorded as if it were a law of nature. Postgres was pulling in logic simply because it was the one thing all three runtimes could reach, the path of least resistance, not the correct owner.

What test does an ownership rule actually have to pass?

The bar I set for any fix: for any new rule, can you name exactly one runtime that owns it, without discussion? The system's existing informal split, web handles user-facing things and Rust handles background things, failed that test. It splits logic by when the work happens, not by what owns the decision, so anything that has both a user-facing and a background component, like summarization, straddled the line and could plausibly live in either place.

How does decomposing by role, instead of runtime, fix it?

Every piece of logic in the system got reclassified into one of ten roles, each with exactly one legal home:

RoleWhat it isLives in
FactsWhat happened; meaning never changesDB tables
RetrievalAssembling facts into the shape a decision needs (joins/aggregates), no conditionals over configDB views/queries
ConfigurationTunable values that policy readsconfig tables / env, never literals in code
PolicyDeciding what facts mean and what should happen; pure, no I/Oapp-side policy modules
MechanismMaking an effect happen exactly once; deliberately dumbDB transactions/queue/unique constraints
EffectThe outward actionapp code / workers
Adapter (in)Translates an external arrival into a use-case call; validation/auth only, no decisionswebhook routes, cron-triggered handlers
Use caseThe named operation; orchestrates Retrieval → Policy → Mechanism → Effect, decides nothing itselfdomain use-case files
PresentationHow a decision looks, formatting only, no business meaningUI components
View stateLocal interaction state (expanded/pending/draft)UI components/hooks

What two rules fall out of that table?

Only Policy and Effect need a runtime owner; everything else is database or data. The rule: whichever runtime performs the Effect owns the Policy for it, one naming rule, not a shared service. And the UI renders decisions, it never makes them: if a product decision could change the answer, it's Policy, not Presentation. One deliberate exception: database row-level security, kept on purpose as defense-in-depth for when application code gets an authorization decision wrong.

A central policy service and shared TS↔Rust logic both got rejected. A shared service would add latency on the money-spending path for a problem the ownership rule already solves. Shared logic between TypeScript and Rust wasn't minimized, it went to zero, permanently. And enforcement is CI-based, not prose-based: checks compare the system to a declared shape, not pattern-matching for "badness."

How do you migrate a live system without freezing feature work?

A register of known violations, one row per issue, can only shrink: every unit of work removes at least one row and adds none. I call it the ratchet. It let this migration run without a freeze, and held up under real pressure: an unrelated summarization feature drained two rows just by doing its job well, deleting a cron-based gating function once that decision lived naturally inside a long-running worker.

That's the same discipline behind most of the system-design and platform work I do at Garrell Tech Solutions: not a bigger framework, but a rule specific enough that nobody has to argue about where a decision belongs.

FAQ

Does this mean Postgres can never hold business logic? No. Facts, Retrieval, and Mechanism still live in the database; Configuration lives in config tables or env. What Postgres shouldn't hold is Policy, the part that decides what should happen. Row-level security is the one named exception.

What's the actual difference between Policy and Mechanism here? Policy decides what should happen: pure, no I/O. Mechanism just makes an effect happen exactly once, via database transactions, queues, or unique constraints. It doesn't decide anything, it just guarantees an already-made decision executes once.

Sources