Making the App Moldable: Real Multi-Tenancy First, Then Four Businesses That Actually Look Different
Fourteen sprints of the beer app had proven out one specific idea well: a bar's paper "drink 200 beers, earn a mug" sheet, replaced by a phone and a bartender's PIN. The next question was bigger than any single sprint. If a coffee shop wanted to run "try 50 roasts," or a record store wanted "collect 200 records," could the same app do that without being rebuilt from scratch for each one? That's a different kind of work than shipping a feature — it's proving a pattern generalizes, and the only honest way to prove it is to actually build it for more than one business and see what breaks.
The naming question I didn't want to answer with a rewrite
The very first settings a business needs to configure — what it's collecting, how many, what to call one of them — immediately raised an uncomfortable question. The whole codebase, fourteen sprints' worth of models, controllers, routes, and tests, is named for the bar-specific domain: Tavern, Beer, BeerConfirmation. Do you rename all of that to Organization, Item, ItemConfirmation before you let a coffee shop touch it, or do you keep the internals as they are and only speak generically at the edges?
I chose the second option, on purpose, and wrote down why rather than just doing it and hoping nobody asked. A full rename across that much surface area is real, mechanical, breakage-prone work, and it would improve nothing about what a new business actually sees. So the decision was a soft pivot: the internal model stays Tavern/Beer for now, but every new API route and every piece of business-facing vocabulary — the settings endpoint, the item names, the industry type — speaks generically. It's an intentional, temporary mismatch, not an oversight, and it's written down as one with the conditions that would make the full rename worth doing later.
Rejecting my own formula for the badges
Once a business could set its own goal number, the milestone badges (fixed at 60 and 120 out of 200 beers) needed a new answer too. My first instinct was a formula: badge tiers as a fixed percentage of the goal, so a 50-goal coffee shop would automatically get tiers at 15 and 30. When I proposed that, the pushback was immediate and correct: the right shape of a badge ladder is different for every business, and a formula bakes in an assumption — that everyone wants the same climb — that doesn't hold. Some businesses might want a single early "welcome back" milestone; others might want five closely spaced tiers for a high-frequency loyalty program.
So tiers became a fully custom list per business instead, editable after the fact, with no formula deriving one number from another. That meant retiring a two-value enum (Sixty/OneTwenty) in favor of a plain threshold number, and hand-writing the migration so existing earned badges backfilled to their real thresholds instead of getting silently dropped by a default auto-generated migration.
The demo data that almost lied about being isolated
The last piece of the foundation was seeding a handful of demo businesses so the moldability could actually be shown, not just described. Before writing a single row of seed data, the real blocker showed up: the app had no multi-tenancy at all. Every confirmation, every progress calculation, every badge check was hardcoded to "the first business in the database, always." Seeding three more businesses as-is would have mixed every catalog together and resolved every customer's progress against the wrong one.
There were two ways to handle that: fake enough isolation to make a demo look convincing, or pull forward a real, minimal slice of the proper multi-tenancy work that was otherwise scheduled months out. I did the real one. That meant a genuine tenant id on the core tables, a single resolver service everything else calls through, and a PIN-matching bug closed along the way — the bartender PIN check had been comparing a candidate PIN against every active PIN in the whole database, business-blind. Along the way, a shared static array of test fixture data turned into its own bug once beers gained a real relationship to a business (a classic case of two tests silently sharing mutable state), fixed by making the fixture a function instead of a shared object. The whole existing test suite passed unchanged once that was sorted out, which was the actual proof the new tenant checks weren't breaking anything real — not just that the new tests passed.
With that in place, four demo businesses went in: a bar, a coffee shop, a brewery, and a record store, each with its own goal, its own badge tiers, its own staff PIN, and its own small roster of customers at different points in their progress — checked live against a running Postgres database, not just asserted in a test.
Teaching every screen the business's own words
With real, isolated data underneath, the frontend still said "beer" everywhere. Threading the business's own item name through the app touched the progress screen, the home page, the item list, and the confirmation flow, each picking up a name like "roast" or "record" instead of a hardcoded "beer." Most of that was mechanical once the pattern was set: pass the name down as a prop, use it in the right five or six places, keep the network calls exactly where they already were rather than adding new ones just to fetch a label.
One piece was deliberately left alone. The original plan floated renaming "Confirm with bartender" to something more generic, like "Log this." I didn't make that change, because it would have been wrong, not just unpolished — every business on this app still uses the same one-device model, where a staff member's PIN is what actually confirms an item, on the customer's own phone. Relabeling that button to sound like a self-report would misdescribe how the product actually works, for every industry, not just the bar it started with. Knowing which words are genuinely cosmetic and which ones encode a real product decision turned out to matter more than the renaming itself.
Making four businesses actually look different
Configuration and correct data are invisible if every business still looks identical. The last stretch wired up the branding fields that had existed in the database since the very first settings work but that nothing had ever read: a logo, a primary color, a secondary color. Once those flowed through to the progress bar, the badge display, and the header, logging into the coffee shop's account and the record store's account produced two visibly different-looking apps from the same code, not just two different numbers.
Writing up demo material for all four businesses caught two real bugs that a features-only pass would have missed entirely. One screen still had "beers" hardcoded even after the item-naming work landed everywhere else, because it had been touched last by an earlier, unrelated change. And a badge-earned push notification was still sending "15 beers! ...on the way to the mug" to a coffee shop customer who'd just earned their fifteenth roast. Testing across three mobile widths (no physical phone was on hand for that pass) also caught two real layout bugs, both the same shape: a row of content that didn't wrap at narrow widths and forced the whole page to scroll sideways instead.
The PM angle
The decision I'd defend most from this stretch is pulling forward a real slice of multi-tenancy instead of faking isolation for a demo. Faking it would have shipped something that looked done and wasn't, and the gap would have surfaced later, probably at a worse time, as a real bug instead of a known and scoped piece of future work. The two decisions I wrote down along the way (the naming soft-pivot, and the fully custom badge tiers) were worth the time each took, because both are decisions I'll be asked about again once more of this gets built, and "here's what we decided and why" beats reconstructing the reasoning from memory.
The badge-tier reversal is the one I'd point to on judgment specifically. I had a formula I liked. It was wrong, and the reason it was wrong only became obvious once someone with a stake in the outcome said so directly. Holding a plan loosely enough to actually update it, rather than defending it because it was already sketched out, is the whole job.
Where the project stands
Three weeks of work, no single numbered sprint this time, since it ran as its own tracked initiative alongside the main sprint sequence. Backend tests finished at 498 passing, frontend at 310, both up from the low-490s/high-280s baseline at the start. Four demo businesses run from one codebase with genuinely isolated data, their own goals and badge tiers, their own branding, and screens that speak their own vocabulary. What's still explicitly not built: a way for a signed-out visitor to land on the right business's branding before logging in, a themeable "reward" concept beyond the bar's mug, and per-industry item formats (a coffee roast doesn't come in "Draft/Can/Bottle"). Those are written down as known gaps, not silent ones.
The very next thing this work surfaced, almost immediately, was that the admin side of the app hadn't caught up to the same isolation the customer-facing side just got. That became its own close look, covered next.
Comments
Loading comments...