アプリひとつで二店舗 — 二軒目がコード修正になったら負けだ

6 分
目標同じカウンター画面を二軒のカフェが使う。価格も営業時間もメニューも違い、一方は税込表記、もう一方は税別だ。バンドルの中には二店舗の名前が一文字もなく、検証がそれを grep 一行で守る。

二軒目が開いた。一軒目のアプリをそのまま使いたい。

ここでたいていこうなる。

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

そして三軒目でもう一度、四軒目でもう一度。二年後にはすでに閉めた店舗の分岐がファイルに残り、誰も消せない。

アプリは店舗を知ってはならない

このサンプルの規則はひとつだ。

/// 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.

店舗は設定ファイルであり、設定ファイルはアプリの外にある。

shop.mbd/          ← アプリ。二店舗共用
configs/
  riverside.json   ← 店舗1
  hilltop.json     ← 店舗2

同じファイル、違う店舗

RIVERSIDE · Counter · 07:00 - 20:00 — 店名はその店の色で。メニューの下にかごと税、DUE $8.10、ボタンは `Charge $8.10`

同じ画面ファイル、別の店 — HILLTOP · 09:00 - 18:00。メニューも価格も税の扱いも違い、DUE $10.45、下段は "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

バンドル全体をハッシュで焼いた。二店舗の間で何が違おうと、あの中にあるのではない。

検証は grep 一行だ

この編の主張は検査ひとつに要約される。

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 (shop == 'Hilltop') を一行入れた瞬間に赤くなる。それがこの検査の存在理由だ — その一行はいつも急いでいるときに入る。

メニューにないものを押したら

画面ファイルが同じなのでキャロットケーキのボタンは二店舗の画面に同じようにある。

リバーサイドにキャロットケーキはない。

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

拒否はサーバーがする。

// 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');
}

これはこれは誰が押していいのかと同じ話の別の顔だ。あちらでは権限が画面の外にあり、ここではカタログが画面の外にある。共通点は画面が同じファイルだということであり、同じファイルは何も決定できない。

税の表記が違う

これが実務で最もよく分かれる地点だ。一方の店舗は価格に税が含まれ、もう一方はカウンターで足す。

[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

コードの経路を二つ作れば必ず分かれる。だから式ひとつがフラグを読む。

// 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;

$8.10 のうち $0.74。内税は 8.10 × 10 / 110 であり、8.10 × 0.1 = 0.81 ではない。これを間違えるコードが実務に本当に多い。

三軒目の店舗

この構造で三軒目を開く仕事はこうだ。

configs/seaside.json

ファイルひとつ。配布もなく、ビルドもなく、レビューもない。そしてそれがこの構造を選んだ唯一の理由だ。

このサンプルの範囲

設定を誰が直すかがない。 ファイルを手で直す。実際には店主が画面で価格を変えなければならず、そうすると設定を編集する画面が必要になる。それはこのサンプルの外だ。

設定の検証がない。 taxPercent に文字列を入れればサーバーはただ死ぬ。店舗が十軒になれば、間違った設定ひとつはその店舗だけ開けなくするべきなのに、やっていない。

二店舗が一台で回った。 それぞれ別の店舗にある二台の端末が各自の設定を受け取るという配布の話はしていない。それはフォルダひとつがアプリだが扱った配達の問題につながる。

共通メニューの同期もない。 アメリカーノの名前を二店舗で一緒に変えるにはファイルを二つ直さなければならない。共有設定+店舗別上書きの構造が必要で、作っていない。

フランチャイズの道具が高い理由

店舗管理SaaSが高いのは機能が多いからではない。店舗ごとに違うものを違うまま保つ仕事がそもそも難しいからだ。

このサンプルが見せたのは、その難しさがどこに置くかの問題だということだ。アプリの中に置けば店舗数だけ分岐が増え、外に置けば店舗数だけファイルが増える。分岐は互いに絡み、ファイルは絡まない。

その差は二軒目では見えず、五軒目で見える。

練習課題

同じ予約を続けて二回送ってください。二回目で予約がもうひとつ作られないことを示し、どちら側がそれを保証したかを説明してください。

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