Mirroring System Features: How We Stopped Building Parallel Stacks
When your platform’s features need to live inside the platform
If you’ve built a platform where users define their own data models (custom objects in a CRM, content types in a CMS, entities in an ontology), you’ve hit this question without naming it: when you, the platform team, need a system-wide feature, does it live outside the platform as code (hardcoded entities, services, controllers), or inside the platform as data, expressed in the same model mechanism you’ve already given your users?
We hit this question on a platform I work on, and we got the answer wrong the first time.
The platform’s pitch was: define your own data model, ingest data into it, query and report on it, all without writing code. But when we started building vertical products on top of the platform (features that needed to span every tenant), we did what platform engineers reflexively do. We wrote Hibernate entities. Services. Controllers. Report endpoints. A data integration tracker. A dashboard config store. A widget registry. Each one a small, hardcoded stack sitting next to the dynamic one.
The contradiction took a while to land. We had built the engine precisely so this code wouldn’t need writing, and here we were, writing the most of it. Eventually I proposed flipping the model: instead of writing system features as parallel stacks, define them inside the platform’s own ontology mechanism. The pattern below is what we landed on.
A quick note on terminology before we go deeper: when I say “model” here, I don’t mean “database schema.” The model is an ontology: a knowledge map of entity types and the relations between them, not a set of tables. On the platform I’ll describe, those user-defined templates and instances live as data inside a fixed relational schema, with downstream tiers for search, graph, and analytics kept in sync, but that’s a topic for another post. The ontology is a metadata layer that every storage tier interprets in its own way, and the pattern below works at that metadata layer, not at the SQL DDL layer.
The naive fix doesn’t work
The obvious response is: just put the system features into a system-owned space. Define a “system ontology” alongside the user ontologies, with templates for things like DataFlow, DataFlowRun, DashboardConfig. Then reuse the same query, report, and chart services the users get.
This fails on security.
Most multi-tenant platforms enforce access control at the tenant boundary: dissemination lists, security level groups, row-level policies, whatever your isolation mechanism is. If all system data lives in one shared system ontology, you’ve created a cross-tenant pool. A data flow run produced inside Tenant A’s context now lives in a space that doesn’t inherit Tenant A’s security rules. You’d have to reimplement access control specifically for this system space, which is exactly the kind of parallel stack you were trying to avoid.
The pattern: mirror, don’t share
The pattern that worked for us, and I suspect generalizes, is this:
Define system features once, in a single source-of-truth system ontology that only the platform team can edit. Then, when a tenant is provisioned, mirror the system templates into the tenant’s own metadata space. The mirrored entities aren’t copies in the “duplicated data” sense. They’re read-only projections that delegate everything to the source definition, but they live inside the tenant and inherit the tenant’s security context.
flowchart TB
subgraph shared["Shared system ontology: the trap"]
direction TB
SO["System ontology<br/>one cross-tenant pool"]
SA["Tenant A data"] --> SO
SB["Tenant B data"] --> SO
SO --> HOLE{{"data pools across tenants:<br/>tenant security not inherited"}}
end
subgraph mirrored["Mirror into each tenant: the pattern"]
direction TB
SRC["SystemTemplate<br/>source of truth, platform-team-only"]
subgraph TA["Tenant A: own security context"]
MA["MirroredTemplate<br/>read-only projection"]
end
subgraph TB2["Tenant B: own security context"]
MB["MirroredTemplate<br/>read-only projection"]
end
SRC ==>|mirror on provision| MA
SRC ==>|mirror on provision| MB
end
The key properties:
- Single source of truth. System features are defined in one place. The platform team edits them there. Tenants can’t modify them.
- Automatic propagation. Changes to the source flow to every mirror. New tenants get the current set on provisioning.
- Transparent to the rest of the system. Query engines, report services, chart generators don’t need to know mirrored entities exist. They see templates and treat them as such.
- Security travels with the tenant. Because the mirrored entity lives inside the tenant, instance data produced against it inherits the tenant’s access rules automatically. No parallel access control to maintain.
In code, the cleanest expression is plain composition and delegation, not a dynamic proxy. A MirroredTemplate extends your base template type, holds a reference to the source SystemTemplate, and forwards every structural call to it:
class MirroredTemplate extends Template:
source: SystemTemplate # a reference, never a copy
tenant: TenantContext # the scope the mirror lives in
properties() -> source.properties()
relations() -> source.relations()
validate(value) -> source.validate(value)
securityScope() -> tenant.scope() # the one call it does NOT delegate
Everything about shape forwards to the source; the one thing the mirror owns is its security scope, which it takes from the tenant it lives in. That single override is the whole pattern. Serialization layers (caches, wire formats) write the mirror out as if it were a regular template, so downstream consumers stay oblivious.
This also explains why propagation needs no machinery. Because the mirror holds a reference rather than a copy, there’s no job to run and nothing to invalidate: a change to the source is visible through every mirror on the next read, since the mirror was always resolving through to the source.
Why this is more than just “reuse your own primitives”
“Eat your own dog food” is good advice but it’s not quite this. The dog-food version says: use your platform’s user-facing APIs to build your platform’s own features. The mirroring version goes further: it says the isolation boundary of your platform (tenant, ontology, workspace, whatever) should contain your system features too, projected in.
That second step is what buys you the security property. Reuse the ontology mechanism but skip the isolation boundary, and the work you saved comes straight back as a custom permission layer for system data. Put system features inside the boundary, and the isolation mechanism you already have does that work for free.
What changed after we shipped it
The most concrete payoff came from places we didn’t plan for.
The frontend team picked up the pattern first. They defined their own system templates for dashboards, screens, and widget layouts, all fully configurable and consumed by the existing query and rendering pipeline. Features that would have needed a backend ticket became things the frontend shipped on its own.
Then the LLM integration team used it for the Ask the AI feature. System prompts (the closest thing to “code” in an LLM workflow) became templates. Prompt versions, prompt variants for different contexts, prompt configurations per tenant: all of it modeled as templates, all of it editable through the same mechanism users already knew.
Then the simulation team. They run agent-based simulations, and every agent’s policy parameters are now defined as system templates. More interestingly, they’re starting to define the events their simulations produce as templates too, which means a simulation can effectively grow new ontology entities at runtime, and the reporting pipeline picks them up for free.
None of this was in scope when we designed the pattern. Three teams, three problems I hadn’t anticipated: frontend composition, LLM behavior configuration, simulation policy and event modeling. All of them fit on the same primitive. What surprised me most was that these uses started to compose: simulation produces events defined by templates, the reporting pipeline reads them through the dynamic query layer, and the LLM templates turn them into prose. Three “system features” stacked on each other, none of them written as a parallel stack.
That’s the test for a pattern like this, in my view: not whether the original team can use it, but whether the teams next door reach for it without being told to, and whether their uses end up composing in ways no one designed.
When it’s worth it, when it isn’t
I want to be honest about where this pattern earns its keep and where it’s overkill. It comes down to three conditions:
- A sunk dynamic-schema investment. If users already interact with a dynamic model directly, you’re just using more of the engine you already built. If your “dynamic schema” is really custom fields on otherwise fixed entities, the mirroring machinery is heavier than you need.
- Enough system features to amortize the design cost. With more than two or three, and more expected, each one you don’t write as a parallel stack pays back the up-front cost. With one or two stable ones, two parallel Hibernate entities are cheaper than a mirroring layer.
- Non-trivial tenant isolation. If isolation means security levels, dissemination lists, or hierarchical access, you won’t want to reimplement it for system data. If it’s a single tenant ID column, the naive shared-ontology approach plus a tenant filter is probably good enough.
There are also real costs to be honest about. Debugging gets harder: when you see a template in a tenant, you have to remember it might be a projection of something defined elsewhere, with edits that propagate from outside the tenant. Versioning the source ontology is also genuinely tricky: a change to a system template ripples to every tenant at once, which is powerful but unforgiving. The discipline that worked for us is to treat the source like a public API. Additive, backward-compatible changes (new optional properties, new relations) can ripple freely, because existing instances and queries keep working. Breaking changes don’t mutate in place; they cut a new version of the system template, and each tenant resolves against a pinned version until it’s migrated forward. That turns “every tenant at once” from a hazard into a deliberate rollout. (Instance-level propagation has its own, sharper failure modes, but that’s a separate story.)
The broader point
Metadata-driven and dynamic-schema platforms have been written about plenty. Salesforce’s architecture documentation is the canonical reference, and the custom-fields-on-shared-tables pattern is well-trodden ground. What gets less attention is what happens at the other end: when the platform team is the one adding entities, not the user. Here the choice from the opening comes back around. The temptation is to keep system features outside the platform, as code: the hardcoded stacks platform engineers are trained to reach for. The discipline is to move them inside, as data, and let your platform host its own features the same way it hosts everyone else’s.
When that works, you stop maintaining two stacks. You stop reimplementing access control for the system half. And the next system-wide feature becomes a new template rather than a sprint.
The thing I didn’t appreciate at the time was that the real win wasn’t reducing our own work. It was that the platform finally meant what it said.