I've spent a long stretch of my career building the unglamorous half of a product: the internal admin platform. The part customers never see, where operations, finance & support actually run the business. Over time it grew past eighty modules — Product, Category, Order, Inventory, Vendor, Warehouse, Ledger, HR & a long tail of others.
Here's the thing nobody warns you about. The first five modules are genuinely different & building them is fun. Modules six through eighty are, almost without exception, the same shape wearing different clothes: a list you can filter and paginate, a detail view, a create/edit form, some permissions, some validation. And that sameness is exactly where the danger lives.
The rot you don't notice until module 30
The obvious way to build the second module is to copy the first and change the fields. It works. It's fast. It feels productive. So you do it again for the third & the fourth & by the time you look up you have thirty files that are 80% identical and 20% subtly divergent.
The cost isn't the duplication itself — disk is cheap. The cost is that a single bug now has thirty homes. Someone fixes pagination in the Orders list. Nobody remembers the Products list has its own copy of the same off-by-one. A permission check gets tightened in one place and silently forgotten in twelve others. Every improvement has to be applied by hand, everywhere & "everywhere" is a number you can no longer hold in your head.
At that scale, copy-paste isn't a shortcut anymore. It's a slow-motion outage.
Treating a module as data, not code
The move that saved the platform was to stop thinking of each module as a component and start thinking of it as a description. A module isn't code — it's a small config: what fields it has, which columns show in the list, how it validates, who's allowed to touch it. The standard list/detail/edit machinery is written once and reads that description.
export const orderModule = defineModule({
name: "order",
label: "Orders",
fields: {
code: { type: "string", readonly: true },
customer: { type: "reference", to: "user" },
total: { type: "currency" },
status: {
type: "enum",
options: ["pending", "packed", "shipped", "delivered"],
},
},
list: {
columns: ["code", "customer", "total", "status"],
filters: ["status", "customer"],
},
permissions: { view: "order.read", edit: "order.write" },
});Adding a module becomes an act of description, not construction. There's no new list component, no new form wiring, no fresh copy of the pagination bug. The generic renderer already knows how to turn that object into a working screen.
The payoff compounds. When you fix pagination now, you fix it in one place and all eighty modules inherit it. When you add a global feature — column visibility, CSV export, an audit trail on edits — you add it once to the engine & every module lights up. That's the leverage that makes eighty modules maintainable by a small team instead of a large one.
The part everyone skips: the escape hatch
Here's where most "just make it config-driven" advice quietly falls apart. It's true right up until you hit the module that doesn't fit — and you always hit it.
One module needs a custom action button that kicks off a background job. Another has a field whose options depend on the value of a different field. A third needs a completely bespoke detail view because it's really a dashboard, not a record. If your abstraction has no answer for these, one of two bad things happens: either you contort the config into something unreadable to force the exception through, or you bail out and hand-write the whole module — and now you're back to the copy-paste world you were trying to escape.
The abstraction is only as good as its escape hatches. The rule I landed on: the config handles the common 90% & every extension point is a named override, not a fork.
export const shipmentModule = defineModule({
name: "shipment",
// ...standard field + list config...
overrides: {
// Replace only the detail view; list and edit stay generic.
DetailView: ShipmentTrackingBoard,
// Add a custom action without touching the toolbar internals.
rowActions: [{ label: "Reprint label", run: reprintLabel }],
},
});The distinction matters more than it looks. An override says "this module is 90% standard and 10% special & here is exactly which 10%." A fork says "this module is now its own universe." The first keeps the shared fixes flowing in. The second cuts the module off from them forever. A newcomer reading the config can see at a glance where a module bends the rules, because the bending is explicit and localized instead of smeared across a hand-written file.
Where I deliberately did not abstract
The mirror-image mistake is over-eager unification & I made it too. Two modules looked identical, so I merged them into one shared definition with a flag to tell them apart. It felt clever for about a month.
Then their requirements drifted, the way real business requirements always do. Each new difference meant another branch inside the shared code — if (variant === 'a') accreting until the "shared" module was harder to read than two separate ones would have been. The lesson: two things that look the same today aren't necessarily the same thing. Superficial similarity is not a reason to couple. I now wait until the third occurrence of a genuine pattern before pulling it into the engine & I'm quick to split a shared abstraction back apart the moment it sprouts its second if variant.
The honest trade-off
Config-driven screens are not free. A brand-new engineer can read a hand-written module top to bottom and understand it in an afternoon. To understand a config-driven one, they first have to learn the engine — the indirection is real & debugging means stepping through a generic renderer instead of the module's own code. You're trading immediate readability of any single screen for the maintainability of all of them at once.
At three screens, that trade is a bad one — just write the three screens. At eighty, it's the only thing that keeps the platform alive. The whole skill is noticing which side of that line you're on before the copies pile up, not after.
If you're staring at your fifth near-identical CRUD screen and feeling the urge to copy the fourth, that's the signal. Not to build a framework — please don't build a framework on screen five — but to start describing your screens instead of constructing them & to leave yourself clean, named exits for the day one of them refuses to conform.