Skip to content
All posts

Config-driven UI: what changes when the screen is data, not code

Sep 14, 20269 min readarchitecture · ui-config · fintech

A lending product with one tenant needs one onboarding form. A lending product with a dozen tenants needs a dozen onboarding forms that are almost identical — different fields, different copy, a different set of actions someone is allowed to take — and "almost identical" is the most expensive phrase in software. It is not different enough to justify a separate codebase per tenant, and not identical enough to hardcode once and forget.

I have spent the last stretch of multi-tenant onboarding work on the far side of that problem: screens that are not written, they are described, and a small number of generic renderers turn the description into a page. This is what actually changes when you make that move, and what it costs — because it does cost something.

Two questions, two documents

A form answers two separate questions: what does this data look like, and how should a person fill it in. Most hardcoded forms answer both at once, in the same component, which is exactly why they are expensive to vary — changing the layout means touching the same file that defines what a valid submission is.

Splitting them into two documents is the whole trick. One document is a plain data schema: field names, types, which ones are required. It doesn't know what a dropdown is. The other is a presentation layer on top of it: which widget renders each field, in what order, at what width, with what placeholder text. Neither one is executable code — both are just data a generic form component reads at render time.

The sketch below is illustrative, not the real shape — the actual documents carry a lot more than this — but it is the split that matters:

// schema.json — what the data is
{
  "type": "object",
  "properties": {
    "monthlyIncome": { "type": "number", "title": "Monthly income" },
    "employmentType": { "type": "string", "title": "Employment type" }
  },
  "required": ["monthlyIncome", "employmentType"]
}
// uiSchema.json — how a person fills it in
{
  "monthlyIncome": { "ui:widget": "CurrencyInput", "ui:placeholder": "0" },
  "employmentType": {
    "ui:widget": "Select",
    "ui:options": { "selectOptions": ["SALARIED", "SELF_EMPLOYED"] }
  }
}

Once a shared renderer knows how to walk both documents, adding a field to one tenant's form is a data change, not a deploy. That is the entire pitch, and it is a genuinely good one. It is also not the whole story.

A screen needs an address, not just a shape

A schema and a uiSchema get you one form. A product has pages, menus, grids, detail views and one-off actions, and every one of them can vary by tenant, by region, by language, by role, and by version as it evolves. So the config itself needs an address: an id, a type, and a handful of optional coordinates the server can filter on before it decides which document to hand back.

That address is the part that turns "a form is data" into "the whole screen is data." The same generic page renderer produces a wildly different page depending only on which document the server resolved for this tenant, this region, this role — the component tree never branches on tenant at all. All the variation lives one layer down, in which document got selected, not in the code that renders it.

Most tenants don't specialise most coordinates, so resolution has to answer "which document is the closest match" rather than demanding an exact one. The most specific document available for that address wins, and it falls back to a global default wherever a tenant hasn't bothered to override — a tenant with no region override for a page just gets the tenant-level one; a tenant with neither gets the product default. Nobody has to author a document for every coordinate combination that could theoretically exist, only the ones that are actually different.

Requestid · type · tenantregion · role · versionResolverpicks + filterspermission filter runs hereschema.jsonwhat the data isuiSchema.jsonhow it's presentedGenericrenderer→ becomes the screen

A simplified shape of the pipeline described above — the real one has more coordinates and more document types than this, but the split never changes: an address picks a filtered pair of documents, and one renderer turns them into a screen.

Permissions belong in the config, not just the API

The obvious place to enforce permissions is the API that the screen eventually calls — reject the request if the user cannot do the thing. That is necessary but not sufficient. If the menu still shows the button, the API rejection is the second thing the user learns is wrong, after clicking on something that was never going to work.

So the menu and page configs get filtered against the caller's permitted actions before they are served, not after. A menu tree walks itself and drops any item the caller cannot act on; a page config drops sections the same way. The user only ever sees the smaller product they are actually entitled to.

The lesson that took longer to learn: this filtering step has a kill switch — a flag that turns it off, for a rollout or while a permissions dependency is unhealthy — and the two ways it can fail look identical to a user but are not equivalent at all. Fail closed and the switch silently hides things that should be visible; fail open and it silently shows things that should not be. I picked fail-open-and-log-loudly for this: an unfiltered menu is annoying, a filter that's quietly stricter than intended is a support ticket nobody can explain. Whichever you pick, the failure mode has to be a decision you can point at later, not something that happens to be true of whatever code path you wrote first.

Hiding an element is not the same as protecting its data

Filtering a menu or a page config only removes what would have been rendered. If the hidden element shares a data call with something that survives the filter, the value never actually disappears — it's still sitting in the response, visible in a devtools network tab, just no longer bound to anything on screen. Config-level filtering only becomes real enforcement when the hidden element owns its own lazily-fetched data: remove it from the config and its fetch simply never fires, so the value never leaves the server in the first place.

That distinction matters more than it sounds like it should. Config filtering is the right tool for "this role has no business seeing this" — decluttering a screen down to what someone can act on. It is the wrong tool for "this role must never obtain this value." The second one is the data API's job, not the renderer's, and treating a hidden column as a security boundary is the kind of mistake that only shows up when someone opens the network tab.

Some visibility depends on data, not just role

Role answers "can this kind of user ever see this action." It does not answer "can this user see it on this record right now" — a close-account action that only makes sense once an account is actually in a closeable state, say. That check can't live in the static config, because it depends on data the config doesn't have.

The pattern that held up: the action config stays declarative — a small rule attached to the action, not a function — and the server evaluates that rule against whatever live record the screen is about, at request time, right before the action list goes out. The config author writes "show this if status is one of these values"; they never write an if statement, and they never need a deploy to change which values qualify.

// The rule stays this shape, whatever the field — a comparison, not a function
{
  "field": "accountStatus",
  "operator": "in",
  "values": ["ACTIVE", "DELINQUENT"]
}

What it costs

The config is a second codebase, and nothing checks it at compile time. A typo in a field name doesn't fail a build — the earliest anything catches it is a validation step in the backend that runs before a document is ever served, not a type checker at author time. That backstop is necessary, not sufficient: the renderer downstream still has to treat every document as untrusted input and degrade a missing or malformed field into "don't render this," never into a crash — the person authoring the config is not the person who will get paged.

Debugging moves up a layer. "Why is this field missing" stops being a component question and starts being a "which document, for which tenant, region and version, did the server actually resolve" question. That is a genuinely different debugging skill, and it took me longer to build good tooling for it than it took to build the renderer itself.

Versioning is not optional once more than one tenant depends on the same document. A change that fixes tenant A's form can silently break tenant B's if they were both quietly sharing one config. Every document needs its own identity and its own history, or "config" just becomes "global mutable state with extra steps."

Flexibility has a ceiling, and business logic will keep asking to cross it. A rule engine is happy to say "show this if the account is active." It has no good way to say "and also run this multi-step reconciliation first" — that pressure to smuggle real logic into config never goes away, and resisting it is ongoing work, the same way resisting business logic creeping into a BFF is ongoing work.

Where I'd start

Don't reach for this to solve one form. Reach for it when you can already point at three or four near-identical screens across tenants and name the thing that's actually different between them — a field, a permission, a visible action. Model that difference as data first, keep the renderer generic, and only widen the config schema when a fifth screen needs a shape the first four didn't. A config format designed for every hypothetical future field is exactly the kind of upfront complexity this pattern exists to avoid, not add.

Share

Written by Chandan SM. If this was useful — or wrong — I'd like to hear about it: chandan.sm.dev@gmail.com.

Always up for a good problem

Let's build something worth maintaining

Have a platform problem, a team that needs a hand, or just want to argue about API design? My inbox is open.