One App, Two Shops — If the Second Shop Is a Code Change, You Have Lost

6 min
GoalTwo cafés share the same counter screen. Prices, hours and menus differ, and one quotes tax-inclusive while the other adds it at the counter. Not one character of either shop's name appears inside the bundle, and the verification protects that with a single grep.

This is not a specific business. We constructed the common shape of one person running two cafés. The shop names, prices and menus are invented; the code, screens and logs actually ran.

A second shop has opened. You want to use the first shop's app as it is.

Here is what usually happens.

if (shopName == 'Riverside') { ... } else { ... }

And again at the third shop, and again at the fourth. Two years later the file still holds a branch for a shop that closed, and nobody dares delete it.

The app must not know the shop

This sample has one rule.

/// The tempting shape is a `shopName` switch somewhere. Then the second shop
/// is a code change, the third shop is a code change, and eventually the file
/// has a branch for a shop that closed two years ago.
///
/// So nothing here knows any shop's name. The config file is an argument.

A shop is a config file, and the config file lives outside the app.

shop.mbd/          ← the app. shared by both shops
configs/
  riverside.json   ← shop 1
  hilltop.json     ← shop 2

Same files, different shops

RIVERSIDE · Counter · 07:00 - 20:00 — the shop name in the shop's own colour, the board, the basket and tax, DUE $8.10, and a button that says `Charge $8.10`

The same screen file, the other shop — HILLTOP · 09:00 - 18:00. Different menu, different prices, a different tax rule, DUE $10.45, and the footer reads "10% tax added at the counter"

shop.mbd — 3 files, sha256:f5f905958eac

[riverside.json] RIVERSIDE · 07:00 - 20:00 · 3 items on the menu
[hilltop.json]   HILLTOP   · 09:00 - 18:00 · 4 items on the menu
same screen file, two shops: RIVERSIDE due $8.10 vs HILLTOP due $10.45

The whole bundle is hashed. Whatever differs between the two shops, it is not in there.

The verification is one grep

This piece's claim reduces to a single check.

for NAME in Riverside Hilltop; do
  if grep -RIiq "$NAME" shop.mbd shop_server/bin; then
    echo "   \"$NAME\" appears inside the app — the difference has leaked in"; exit 1
  fi
done
   neither shop name appears in shop.mbd or shop_server/bin

If a shop name appears inside the app, it fails. This check goes red the moment somebody in a hurry adds one line of if (shop == 'Hilltop'). That is why it exists — that line always arrives when somebody is in a hurry.

Press something that is not on the menu

The screen file is the same, so the carrot cake button is on both shops' screens.

Riverside does not have carrot cake.

[riverside.json] add CK -> "CK is not on this menu"
[hilltop.json]   add CK -> "added Carrot cake"

The server refuses.

// A sku that is on one shop's menu and not the other's must be refused
// here, not hidden by the screen. The screen is the same file in both
// shops and cannot know.
if (item.isEmpty) {
  return _state(notice: '$sku is not on this menu');
}

This is the other face of the same story as Who May Press This. There, authorisation lived outside the screen; here, the catalogue lives outside the screen. What they share is that the screen is the same file, and the same file cannot decide anything.

The tax is quoted differently

This is the point that most often splits in practice. One shop includes tax in the price; the other adds it at the counter.

[riverside.json] tax $0.74 · due $8.10  · 10% tax included in the prices above
[hilltop.json]   tax $0.95 · due $10.45 · 10% tax added at the counter

Make two code paths and they will drift apart. So one expression reads the flag.

// Two shops, two ways of quoting. The arithmetic is one expression that
// reads the flag — not two code paths that drift apart.
final tax = included
    ? (gross * pct / (100 + pct)).round()
    : (gross * pct / 100).round();
final due = included ? gross : gross + tax;

$0.74 out of $8.10. Included tax is 8.10 × 10 / 110, not 8.10 × 0.1 = 0.81. There is a great deal of production code that gets this wrong.

The third shop

Opening a third shop in this structure looks like this.

configs/seaside.json

One file. No deployment, no build, no review. And that is the only reason to choose this structure.

The range of this sample

There is nobody editing the config. The file is edited by hand. In reality the owner has to change a price on a screen, and that needs a screen for editing the config. Outside this sample.

There is no config validation. Put a string in taxPercent and the server simply dies. With ten shops, one bad config should stop only that shop from opening — not done.

Both shops ran on one machine. The deployment story, where two devices in two different shops each receive their own config, was not covered. That leads into the delivery problem A Folder of JSON Is the App dealt with.

No syncing of shared menu items. To rename the americano in both shops you edit two files. A shared-config-plus-per-shop-override structure is needed, and was not built.

Why franchise tooling is expensive

Shop management SaaS is not expensive because it has many features. It is because keeping what differs per shop different is inherently hard.

What this sample showed is that the difficulty is a question of where you put it. Put it inside the app and branches grow with the number of shops; put it outside and files grow with the number of shops. Branches entangle each other; files do not.

That difference is invisible at the second shop and visible at the fifth.

Practice task

Send the same booking twice in a row. Show that the second one does not create a second booking, and explain which side made sure of it.

Related articleOne App, Two Shops — If the Second Shop Is a Code Change, You Have Lost