# A JSON DSL Doesn't Make Your Rules No-Code. It Just Moves the Code Into JSON.

> Source: <https://dev.to/snehasishkonger/a-json-dsl-doesnt-make-your-rules-no-code-it-just-moves-the-code-into-json-1fb5>
> Published: 2026-07-29 05:39:26+00:00

The pitch is always the same: wrap business logic in a JSON schema, let non-engineers edit the JSON through a form, and now product or ops can change a rule without a deploy. It's a good instinct. It's also where most teams stop thinking about the problem, right at the point where the actual engineering work starts.

A JSON DSL isn't a no-code layer by default. It's a second programming language you just invented, with none of the tooling the first one had — no linter, no type checker, no test runner, usually no version history. You've moved the logic out of Python and into JSON, and told yourself that's the same thing as making it safe for a non-engineer to touch. It isn't, until you build the four things that actually make it safe: a schema, validation, a safe evaluator, and versioning.

The appeal is real. [Python and no-code](https://www.nected.ai/blog/python-no-code) approaches like this exist because the alternative — hardcoding eligibility checks and pricing thresholds directly into application code — means every rule change is a PR, a review, and a deploy, for logic a pricing analyst could reason about in five minutes if they could just get to it. A JSON DSL is an attempt to separate "what the rule says" from "how the application runs," so the first part can change without touching the second.

That's a legitimate goal. The mistake is treating "we can now express rules in JSON" as the finish line instead of the starting point.

The schema is the contract between whoever's writing rules and whoever's evaluating them. A minimal but real one, using Pydantic for validation:

``` python
from pydantic import BaseModel
from typing import Literal, Union, List, Any

class Condition(BaseModel):
    field: str
    operator: Literal["eq", "gt", "lt", "gte", "lte", "in", "contains"]
    value: Any

class RuleGroup(BaseModel):
    all: List[Union["RuleGroup", Condition]] = []
    any: List[Union["RuleGroup", Condition]] = []

RuleGroup.model_rebuild()

class Rule(BaseModel):
    id: str
    version: int
    conditions: RuleGroup
    action: dict
```

Notice `operator`

is a `Literal`

, not a bare string. That single decision is the difference between a business user's typo getting rejected at the door versus silently evaluating to `False`

in production three weeks later because "gte" got typed as "get". The schema is where you decide what a rule is even allowed to say — get it loose here and every downstream layer inherits the ambiguity.

Pydantic gets you type and shape validation for free, but the failure modes that actually bite are semantic, not structural. A rule that references `facts.get("usre_tier")`

— a typo'd field name — passes schema validation cleanly. It's syntactically a perfect `Condition`

. It just silently returns `None`

from `facts.get()`

at evaluation time and quietly fails every comparison, and nobody notices until a customer who should've gotten a discount didn't.

``` php
def validate_rule(payload: dict, known_fields: set[str]) -> Rule:
    rule = Rule(**payload)  # raises on structural errors
    used_fields = collect_fields(rule.conditions)
    unknown = used_fields - known_fields
    if unknown:
        raise ValueError(f"Unknown fields referenced: {unknown}")
    return rule
```

That second check — validating field names against a known schema of available facts, not just validating the rule's own shape — is the part teams skip under time pressure, and it's the one that actually prevents the silent-failure class of bug. Type coercion is the other quiet one: comparing the string `"20"`

against the integer `20`

with `eq`

doesn't raise an error, it just returns `False`

, and a rule that looks correct in the JSON editor fails every single time it runs.

The fastest way to write an evaluator is also the most dangerous one:

``` python
# Don't do this
def evaluate_fast_and_wrong(condition, facts):
    field, op, value = condition["field"], condition["operator"], condition["value"]
    return eval(f"facts['{field}'] {op} {value}")
```

The moment `op`

or `value`

originates from a business user's input — even through a form, even sanitized-looking — `eval()`

on an assembled string is an arbitrary code execution vector. It's also exactly the shortcut teams reach for when they're behind schedule, because it "just works" against every test case they thought to write.

The safe version maps operators to a fixed, closed set of functions instead of assembling and executing a string:

```
OPS = {
    "eq": lambda a, b: a == b,
    "gt": lambda a, b: a > b,
    "lt": lambda a, b: a < b,
    "gte": lambda a, b: a >= b,
    "lte": lambda a, b: a <= b,
    "in": lambda a, b: a in b,
    "contains": lambda a, b: b in a,
}

def evaluate(node, facts):
    if isinstance(node, Condition):
        return OPS[node.operator](facts.get(node.field), node.value)
    if node.all:
        return all(evaluate(c, facts) for c in node.all)
    if node.any:
        return any(evaluate(c, facts) for c in node.any)
    return True
```

This is a real evaluator, and it's also roughly where most hand-built DSLs stop maturing — condition trees, boolean grouping, a closed operator set. It's worth comparing your design against something purpose-built at this point: a [JSON rules engine](https://www.nected.ai/blog/json-rules-engine) built specifically for this problem has already made — and hit the edges of — most of the design decisions you're making here, from operator sets to nested condition grouping. Running your own evaluator against one is a useful gut check on whether your homegrown version is converging toward something reasonable or quietly reinventing a worse one.

Here's the DIY version of what "versioned rules" actually requires, once you try to build it instead of just adding a `version: int`

field and calling it done:

```
class RuleVersion(BaseModel):
    rule_id: str
    version: int
    payload: dict
    changed_by: str
    changed_at: datetime
    diff_summary: str | None = None

def publish_new_version(rule_id: str, payload: dict, user: str, store: RuleStore):
    current = store.get_latest(rule_id)
    new_version = (current.version + 1) if current else 1
    store.save(RuleVersion(
        rule_id=rule_id, version=new_version, payload=payload,
        changed_by=user, changed_at=datetime.utcnow(),
    ))
    return new_version
```

That's the easy 20%. The harder 80% is everything around it: a UI where a compliance reviewer can actually see a diff between version 4 and version 5 without reading raw JSON, access control over who's allowed to publish versus who can only propose a change, a rollback path that's tested and not just theoretically possible, and doing all of that consistently across every rule set your team owns, not just the first one you built it for. This is exactly the design space a [DSL rule engine](https://www.nected.ai/blog/dsl-rule-engine) has to solve deliberately, and it's usually the part that reveals whether a DSL was designed as a real system or assembled as a convenience layer over `eval()`

and a config file.

The schema and field-aware validation from earlier don't become wasted effort once you look past the DIY path — that thinking is exactly what a managed rules platform is doing under the hood, just exposed through a builder instead of a Pydantic model you maintain by hand. What stops scaling is the evaluator and the versioning system, because both are open-ended engineering commitments with no natural finish line: the evaluator needs hardening against every future edge case, and the versioning system needs a UI, access control, and a tested rollback path before it's actually usable by anyone who isn't reading raw JSON.

Nected is built specifically to take over at that seam. Business users edit rules through a visual builder instead of hand-editing JSON, which removes the schema-typo class of error at the source instead of catching it after submission — there's no raw field name or operator string for anyone to mistype in the first place. Every change is versioned automatically with a full audit trail from the first edit onward, not a `version`

field someone has to remember to bump, with a rollback path that's a platform feature rather than something your team designs, builds, and is then on call for. Evaluation runs inside a managed decision engine instead of a hand-rolled evaluator, which means your team isn't the one responsible for hardening it against every future edge case.

If you've got one rule set, owned by one team, changed a handful of times a year, with an engineer who's comfortable being the one who publishes changes — build the schema, validate it, evaluate it safely, skip the versioning UI and just use git on a JSON file with a PR review. That's a completely reasonable system, and it's less work than adopting a platform for a problem that size.

The DIY path stops being the right call the moment more than one non-engineer needs to publish changes, or the rule set is customer-facing enough that "who changed this and when" needs a real answer, not a git log someone has to know to check. At that point you're not really deciding whether to build a JSON DSL — you're deciding whether to spend the next quarter building the validation, safe evaluation, and versioning layers yourself, or start from a platform where those were already the whole point.

**Is a JSON DSL the same thing as a no-code rules engine?**

No. A JSON DSL is a schema and an evaluator someone built — it's still code, just expressed as data. It only becomes something a non-engineer can safely use once validation against known fields, a safe evaluator with no `eval()`

, and versioning with an audit trail are all in place.

**Why is eval() dangerous if the JSON is validated first?**

`eval()`

, which is a code execution path the moment any part of that string traces back to user input — sanitized-looking or not.**What's the minimum version of this worth building in-house?**

A typed schema and field-aware validation, as shown above. Both are self-contained, don't require ongoing platform maintenance, and are genuinely useful even if evaluation and versioning later move to a managed engine.

**When does it make sense to move to a platform instead of finishing the DIY build?**

Once more than one non-engineer needs to publish rule changes, or the rule set is customer-facing enough that an audit trail isn't optional. That's the point where the remaining 80% of the versioning and governance work stops being a weekend project.

**Does moving to a platform waste the schema and validation work already done?**

No. That logic maps directly onto what a platform like Nected does under the hood — the schema-design thinking carries over; it's the evaluator and the versioning infrastructure that get replaced.
