Design handed us three mockups: admin-dashboard, manager-dashboard, viewer-dashboard. Same layout, slightly different buttons, slightly different columns. The folder structure said three templates and three endpoints, so the first estimate was for three of each.
We diffed the three mockups before writing anything. The whole difference between them was five things, and every one was "can this role see or do X."
The screens differ only by capability
Put side by side, the admin had a delete button the manager didn't. The viewer had no edit controls. The manager saw a cost column the viewer couldn't. That was the entire diff — five capabilities toggling five pieces on and off. The layout was byte-for-byte the same and the data was the same data.
That's exactly what a capability gate expresses, so it became one component:
function Dashboard() {
return (
<Layout>
<Roster />
<Can do="records.cost.view"><CostColumn /></Can>
<Can do="records.item.edit"><EditControls /></Can>
<Can do="records.item.delete"><DeleteButton /></Can>
</Layout>
);
}
One component. The role never appears in it. Each user renders their own version because <Can> checks their capabilities. Three mockups collapsed into one template, and when a fourth role showed up two months later it was a new set of capabilities, not a new file.
We wrote the capability matrix down before writing the component, because that's what proves it's one template. Five rows, three columns, one cell per role-capability pair:
capability admin manager viewer
records.cost.view yes yes no
records.item.edit yes yes no
records.item.delete yes no no
approvals.queue.view yes yes no
records.export yes no no
Every difference between the three mockups is a cell in that table. Nothing about the layout differs, nothing about the data source differs — the only variable is which capabilities the viewing user holds. When the matrix has this shape, "three screens" is a rendering artifact of three different capability sets against one template, not three implementations. Reading the matrix first is what stops you from estimating three of everything.
One endpoint, scope-projected
The same collapse happens on the API side, and this is the half people miss. We nearly missed it — the first branch had /admin/dashboard, /manager/dashboard, /viewer/dashboard, three endpoints returning almost-identical payloads. That's the same duplication, moved to the backend.
We made it one endpoint that projects its response to the caller's scope:
@requires("dashboard.view")
def get_dashboard(ctx):
data = build_dashboard(ctx.tenant_id)
if not ctx.can("records.cost.view"):
data = strip_costs(data) # viewer never receives cost fields
return data
The endpoint returns what the caller is allowed to see and only that. We tested it: a viewer hitting the endpoint got a response with no cost fields at all, not hidden by CSS but absent from the JSON. That's the difference between a hidden field and a secure one. The frontend <Can> decides layout; the endpoint decides what data even exists in the response.
The scope projection has to be subtractive from a full build, not additive per role, and we learned why the hard way. Our first cut built the response differently depending on the caller — a viewer branch, a manager branch, an admin branch, each assembling its own payload. Two problems. The branches drifted, so a field added to the admin payload was silently missing from the manager one for a sprint. And a new capability meant editing three branches. We flipped it: build_dashboard always builds the full payload, then a series of if not ctx.can(...) guards strip what the caller isn't allowed to see:
@requires("dashboard.view")
def get_dashboard(ctx):
data = build_dashboard(ctx.tenant_id) # full payload, one code path
if not ctx.can("records.cost.view"):
data = strip_costs(data)
if not ctx.can("records.export"):
data = strip_export_links(data)
return data
One build path, N strip steps. Adding a field means adding it once to the full build; whether a role sees it is one strip guard. The projection reads top-to-bottom as "here's everything, now remove what you can't see," which is both easier to audit and impossible to drift, because there's only one payload being built.
Params before new abstractions
The deeper version of this applies beyond roles. When the same shape shows up on multiple pages, the reflex is a new component or endpoint per page. Usually it's the same one with a parameter.
We had a team roster appear on the settings page, the org chart, and the approval flow. The first pass was three roster endpoints. It's one:
def get_people(ctx, *, fields="summary", filter=None):
...
Same on the client — one hook, cached, reused wherever the data appears:
function usePeople(opts) {
return useQuery(["people", opts], () => fetchPeople(opts));
}
Three pages, one network call's worth of infrastructure, variations passed as arguments. The cache meant the second page that needed people didn't refetch. We only add a new class or endpoint when a genuine behavior difference can't be a parameter — not when the same shape merely appears somewhere new.
The cache key is the part that has to be right, and getting it wrong reintroduces the duplication you just removed. The key is ["people", opts], so the settings page requesting fields="summary" and the org chart requesting fields="full" are two cache entries, correctly, because they're genuinely different responses. But early on we keyed only on "people" and the second page got the first page's summary payload, missing the fields it needed. The rule: the cache key includes every argument that changes the response. Get that right and three pages share one hook with no stale-data bugs; get it wrong and you either over-fetch or serve the wrong slice, and both look like the hook is broken when the key is.
The saving compounded. When a pagination bug showed up — the cursor skipped a row at page boundaries — the three-endpoint version would have needed the fix in three places with three chances to drift. The parameterized one was a single fix, one test suite, one source of truth.
The one place we did split was real, and it's worth naming so the rule doesn't read as "never split." The approval flow needed the people list ordered by pending-approval count, which is a genuinely different query — a join and an aggregate the other two pages don't want. That's not a parameter on the same query, it's a different query. We added a sort="pending" option that took a separate code path inside get_people, and drew the line there: same endpoint, same hook, but the query strategy forks when the ordering needs data the base query doesn't touch. A parameter that only changes a WHERE or a field set stays in the shared path. A parameter that changes the join is a fork, and pretending it isn't produces a base query carrying three sets of joins it mostly doesn't need.
We put a number on the collapse afterward, because the original estimate had been for three templates and three endpoints. The delivered thing was one template of about 90 lines, one endpoint of about 40, and a capability matrix of five rows. The three-of-each version we didn't build would have been roughly 270 lines of template and three endpoints drifting apart on every change. The diff that saved it took ten minutes.
The test for "is this really N things"
Before building N of anything — templates, endpoints, hooks — we diff the N candidates. If the differences are all "who can see or do this," it's one capability-gated thing. If they're all "same data, different slice," it's one parameterized thing. If there's a real behavioral fork the parameters can't express, then it's genuinely two, and that's the only case that justifies the split.
Most of the time the mockups lie about cardinality. Three files on disk implied three implementations; the diff showed one template, one endpoint, and a five-row capability matrix. We built that, and the fourth role was data, not code.