Custom Fields That Plug Into Any Entity
Every business object in a SaaS product eventually needs fields you never modeled. One customer wants a “Data Classification” dropdown on vendors. Another wants a “Business Owner” on risk items. A third wants both, on audits. Build that per entity and you will hardcode the same feature forever.
I built this engine at Sprinto. Today more than twenty entity types (vendors, users, controls, audits, risk items, policy exceptions, questionnaires, and counting) plug into the same tables and the same code path. Nothing below is specific to compliance.
Two companion pieces: the frontend half covers how these fields render on any page, and custom entities covers the sibling problem of whole record types you never modeled. This article is the system between them: definitions, values, filtering, and scale.
Three tables, three lifecycles
The core design is a split into three tables, because three different things evolve at three different speeds.
Definitions hold a field’s identity: name, type, required flag, formula. A definition does not know which entities use it. That ignorance is deliberate.
Scopes attach a definition to an entity type. One row per attachment. This is the composability trick: the same “Owner” field can be attached to vendors and controls at once, with two scope rows and one definition. Supporting a brand new entity type is an insert, not a migration. Detaching a field from one entity type deactivates one scope and touches nothing else.
Options hold the choices for select fields, one row each, independently retirable. Retiring an option stops new picks without breaking the historical values that used it.
The rule of thumb behind the split: if two things can change independently, they get separate rows. Definition, attachment, and allowed values all can, so they do.
The plug-in seam
The engine joins the product at one seam. Whenever any code asks for an entity’s definition, the schema layer fetches the active custom fields scoped to that entity type and concatenates them onto the native field list, translated into the exact same shape as a hardcoded field: type, validations, enum values.
Downstream of that seam, custom and native fields are indistinguishable. Form generators, validators, filter builders, and the API all consume one field list. That is what “plugs into any entity” means mechanically: no consumer was ever taught about custom fields. They only know about fields.
Storing values: typed columns, one table
Values live in one shared table keyed by entity type, entity id, and definition. Not a JSONB blob on each host row, and not a value table per entity. The row has typed columns: a string column, a date column, a decimal column, an array column (JSONB, for multi-selects and attachments), and a user reference column.
A single bidirectional type router decides everything: given a field’s type, it picks which column to write and how to rebuild the value on read. Adding a new field type means touching exactly two registries, the type router and the filter strategies below. That symmetry is what keeps eleven field types (text, dates, numbers, percentages, selects, user references, formulas, attachments) from becoming eleven special cases scattered through the codebase.
The bug that taught us this. The first version of the read path picked a value by chaining the columns with ||: string, or date, or number, whichever was set. It worked until a value was legitimately falsy. A number field holding 0 fell through to the next column and returned the wrong thing. The fix is the type-aware read: the definition’s type names the column, always. The old field still exists in the API, deprecated, with a comment explaining why. Polymorphic columns are fine; guessing which one is loaded is not.
Filtering: a strategy per type, SQL where it counts
Filtering is where custom field systems usually die. Ours has two layers.
Each field type registers its own filter strategies: search, in-list, is-empty. Text searches with ILIKE. Selects use JSONB containment, and full text search inside multi-select arrays unnests the array in SQL. Numbers and formulas support ranges. User references split into id and role branches. It is a small plugin registry keyed by type, mirroring the write side.
Combining filters across fields needs semantics the ORM could not express cleanly, so that query is raw SQL: group the value rows by entity, and keep entities where the count of distinct matched definitions equals the number of filters. That gives AND across fields, while the per-field strategies give OR within a field. Faceted search over a value table, in one query, backed by composite indexes on (entity type, entity id) and (org, entity type, entity id).
The parts that only matter at scale
- Batching. The schema seam runs for every entity loaded in a request, which is a textbook N+1. A request-scoped loader batches all definition lookups into one query and re-partitions in memory.
- Snapshots. Audits and assessments need the value as it was at assessment time. Definitions and values both snapshot, so renaming or retyping a field later never rewrites history.
- Rollout as data. New entity type attachments can be gated by feature flags through a mapping table. Rolling out custom fields on a new entity is a config entry, not new guard code.
- Formulas. Fields can be computed from other fields, which quietly turns the engine into a small spreadsheet layer. A dependency graph validates the expressions, blocks circular references, and refuses to deactivate a field that another formula still reads.
Custom fields are not custom entities
Worth separating, because the two get conflated. This engine adds fields to entities you already model: first class product objects with typed relational storage. The custom entities system models record types that never existed, with a schema catalog over JSONB, fed by data pipelines. Both extend the same schema layer at the same kind of seam, and that shared seam is why the product’s UI and validation treat them uniformly. But they are different problems with different storage answers, and forcing them into one system would have made both worse.
Takeaways
- Split by lifecycle: definition, attachment, and options change independently, so store them independently. Attachment as data is what makes the engine composable.
- Enrich at one seam so every consumer sees one field list. Nobody downstream should know a field is custom.
- Typed columns with a type router beat both a JSONB blob and column guessing. Never resolve a polymorphic value with
||. - Put per-type behavior in registries (one for writes, one for filters). New types become additive changes.
- Plan for the boring scale problems on day one: batching the definition lookups, snapshotting for history, and rollout gates as data.