← Home

Deeply nested, variable forms from a backend perspective (metaprogramming)

Some context for anyone who doesn't know the codebase I'm talking about, because this one doesn't make sense without it: I work on a payroll platform. Most of it was built, for years, around one specific kind of worker — the full-time local employee — and everything about that side of the product assumes you know exactly what a person needs: a contract shaped a particular way, a specific set of government filings, a specific set of nested records that always look the same because the law says they have to.

Contractors are not that. When the business decided the platform needed to handle contractor payroll across several countries, the actual data problem turned out to be the interesting part, more than the payroll math. A contractor in one country needs a person, a contract, a bank account, and nothing else. A contractor somewhere else needs all of that plus an address, plus a couple of country-specific nested records that only exist because of a local regulation someone on the team read a PDF about. The shape of "what do I need to create when I onboard this person" is genuinely different every time, and it multiplies with every country and every contract type you add.

The naive way to build this is one onboarding endpoint per shape. We didn't do that, and the reason we didn't is a piece of code that's small enough to paste in full.

The primitive: build one model, and only a whitelisted one

Buried in the codebase is a tiny concern, originally written to live on a controller, whose entire job is: given a model's name as a string and a hash of attributes, build an instance of it and run validations. That's it. No saving, no associations, just "here's a name and some data, give me back a validated (or invalid) object."

def build_instance(model_name, attrs = {})
raise_bad_request unless ModelValidator.valid_model_name?(model_name)

klass = model_name.camelize.constantize
instance = klass.new
instance.attributes = attrs.stringify_keys.slice(*klass.attribute_names)
instance.validate
instance
end

Two lines in there are doing all the actual work, and both exist because the model name is coming from the client as a raw string in a JSON payload. If you constantize an arbitrary string from a request body, you've built a very small, very elegant way for someone to instantiate literally any class in your app. So there's a whitelist — a short, explicit list of the handful of top-level things you're allowed to create through this door: a person, a user, an employee, a contract, a bank account, an address. And even once you're past the whitelist, attributes get filtered down to the model's actual known attribute names before assignment, so a client can't smuggle in a field that happens to exist on the class for some unrelated reason. Small function, but it's the load-bearing wall for everything else in this design: it's the only place in the whole system that turns "a string the client sent" into "a real class," and every other piece is built to only ever go through it or through the same rules it enforces.

From one model to a whole graph

A contractor onboarding isn't one model, it's a tree of them — a contract that has a bank account, that has an address, that maybe has a couple of country-specific nested records hanging off the employee. The actual API the frontend talks to accepts exactly that shape as JSON:

{
"model": "Contract",
"attributes": { "...": "..." },
"nested": [
{ "model": "BankAccount", "attributes": { "...": "..." } },
{ "model": "Address", "attributes": { "...": "..." } }
]
}

and a service walks it recursively: find the root nodes (the ones nobody else lists as a nested child), and for each one, figure out how its nested children actually relate to it. That last part is the piece I like most about this design. Instead of hand-maintaining a map of "which nested models are allowed under which parent," it asks ActiveRecord directly — it reflects on the parent class's real associations and matches the nested node's model name against them. If your Contract model has_one bank account and has_one address, those are exactly the nested models the tree walker will accept under a contract node, because that's what the schema says is true. Nothing more needs to be declared anywhere.

That's a genuinely nice property: the reachable graph for this generic builder is exactly your real association graph, so there's no second source of truth to keep in sync as the domain grows. It's also, if you don't think about it for a second, a slightly uncomfortable property, because it means every has_one/has_many you add to one of the whitelisted root models quietly becomes something a client can now build through this endpoint. The whitelist protects the front door. Your associations are the walls after that. Worth remembering before you casually add a relation to Contract for some unrelated reason.

Two tree walkers wearing the same trench coat

Creating a contractor and editing one look almost identical from the outside — same JSON shape, same recursive walk — but they diverge in exactly the places where "generic tree builder" meets "this is actually a payroll system with history."

Creation always builds fresh records. Editing has to figure out, node by node, whether a given piece of the tree already exists (by id or by a token) and, if it does, update it in place instead of creating a duplicate — including cleaning up stuff like existing cost-center assignments before reapplying new ones, so an edit doesn't silently leave orphaned rows behind. Editing also has to know things creation doesn't: that changing an employee's contract in the middle of a payroll cycle means stamping a versioning date and a creator onto the record, because "when did this change take effect, for purposes of this period's payroll" is a real question the system has to be able to answer later, not just a metadata nicety. And there's a short, explicit list of fields that this generic path is not allowed to touch on update, because they're actually owned by a completely separate, more careful flow elsewhere — an honest admission that "generic" only goes so far before some field is important enough to deserve its own guarded door instead of sharing this one.

The part that's a known compromise, not a bug

There's a separate, non-persisting validate endpoint that the onboarding form calls as someone fills it in, so they can see field errors before submitting anything for real. It reuses that same tiny build_instance primitive from the beginning of this post, per node, recursively over whatever tree the frontend currently has in memory.

Here's the honest part: it validates each node's own fields in isolation. It does not assemble the same nested-attributes association graph that the real create path builds, because doing that would mean partially constructing and half-persisting a graph just to check it, which is its own can of worms. So "this looks valid" during live form-filling and "this actually saved" at submit time are answering two related but not identical questions — one checks that each piece looks right on its own, the other checks that the whole graph holds together as ActiveRecord actually assembles and persists it. Most of the time that gap doesn't matter. Occasionally it's exactly the gap where a genuinely confusing bug report comes from: a form that showed no errors, followed by a submit that failed anyway. Once you know that's the shape of the tradeoff, it stops being mysterious and starts being a thing you can explain in one sentence to whoever's confused by it.

The escape hatch for the fields nobody could have predicted

No matter how well you design the whitelist and the association graph, you cannot anticipate every custom field every company in every country is going to want on a contractor. Rather than let that pressure slowly bloat the schema with one-off columns nobody outside a single client cares about, there's a separate, deliberately loose "optionals" mechanism riding along on the same payload — arbitrary code/value pairs that get synced out to a different internal system instead of living on the model at all. It's the pressure valve: the generic tree builder handles the structural, always-true part of a contractor's data, and anything genuinely one-off gets routed somewhere that was built to be messy on purpose, instead of forcing that mess into the part of the system that has to stay predictable.

One small honest detail

That original build_instance concern still lives under the app's controllers/concerns folder, named like it's only ever meant to be mixed into a controller. These days its only real user is a plain service object, nowhere near a controller. Nobody moved the file when the second use case showed up, because moving a file that everyone already knows how to find is its own small tax, and the cost of "the folder name is a little wrong now" was obviously lower than the cost of a rename PR touching one require. That's not a design decision anyone is proud of in a design-review sense. It's just what actually happens to a file once the thing it was built for turns out to be more generally useful than whoever wrote it originally expected.

What I'd take from this

If you're facing the "the shape of what I need to create varies too much to hand-write an endpoint per shape" problem, a whitelist plus your own schema's real associations gets you further than it sounds like it should — you're not inventing a second graph of what's allowed, you're just asking the one you already have. But be honest with yourself about where "generic" quietly stops covering the real thing, whether that's a validate path that only checks pieces instead of the whole, or fields important enough to need their own flow instead of sharing the general one. The trick isn't avoiding those seams entirely. It's knowing exactly where they are so nobody has to rediscover them from a confused bug report.