マルチテナントECを作っていて、地味に気になったのが fetch の場所です。

商品一覧や商品詳細は、そのページで取ればいい。

これは分かりやすいです。

でも、ヘッダーのメニューやショップ共通の情報はどうするのか。

全ページで使うから layout で取る。

最初はそれで良さそうに見えました。

ただ、テナントが増える前提だと、ここが少し怖い。

layout で取ると便利

例えば、全ページ共通のメニューを layout で取るとします。

export default async function RootLayout({ children }: Props) {
  const tenant = await getTenant();
  const menu = await fetchMenu(tenant);

  return (
    <TenantProvider tenant={tenant}>
      <Header menu={menu} />
      {children}
    </TenantProvider>
  );
}

これ自体はきれいです。

どのページでも Header が出る。

ページ側はメニューを気にしなくていい。

ただ、全リクエストで fetchMenu が走る可能性があります。

ショップごとに外部APIを叩くなら、ここはちゃんと考えないといけない。

何が怖いか

1店舗なら、多少雑でも気づけます。

でも複数ショップになると、

  • Aショップだけメニュー取得が遅い
  • Bショップだけ設定が足りない
  • Cショップだけ外部APIのレスポンスが違う
  • 開発環境だけドメイン解決が違う

みたいなことが起きます。

しかも layout で落ちると、どのページも落ちます。

これはなかなか痛い。

cache するか fallback するか

メニューみたいな情報は、毎回リアルタイムでなくても良いことが多いです。

なので、今なら最初から cache 前提で考えます。

async function getTenantMenu(tenant: TenantConfig): Promise<MenuItem[]> {
  const cached = await menuCache.get(tenant.id);
  if (cached) {
    return cached;
  }

  const menu = await fetchMenu(tenant);
  await menuCache.set(tenant.id, menu);
  return menu;
}

実装は Next.js の fetch cache でも、KV でも、アプリ内 cache でもいいと思います。

大事なのは、

「layout に置いた fetch は全ページに影響する」

と分かっておくこと。

あと、取得に失敗した時に全部落とすのか、最低限のメニューで出すのかも決めたい。

try {
  return await fetchMenu(tenant);
} catch (error) {
  console.error(error);
  return tenant.fallbackMenu;
}

ECなので、購入導線に関わるところは雑に fallback しない方がいいです。

でも、ヘッダーの補助メニューくらいなら fallback した方が良い場面もありそう。

ここは機能ごとに分けて考えたいです。

E2E が1テナントだけだと危ない

もうひとつ気になったのがテストです。

E2E が1つのショップだけを見ていると、

別のショップの設定ミスに気づけません。

test('top page renders', async ({ page }) => {
  await page.goto('/');
  await expect(page.getByRole('heading')).toBeVisible();
});

これだと、どの tenant で見ているかが曖昧です。

テナントを header で切り替えられるなら、最低限こうしたい。

for (const tenantDomain of tenantDomains) {
  test(`${tenantDomain} top page renders`, async ({ browser }) => {
    const context = await browser.newContext({
      extraHTTPHeaders: {
        'x-tenant-domain': tenantDomain
      }
    });

    const page = await context.newPage();
    await page.goto('/');
    await expect(page.getByRole('main')).toBeVisible();
  });
}

全部の画面を全部のテナントで見る必要はないと思います。

でも、

  • top が出る
  • menu が出る
  • 商品一覧が出る
  • unknown tenant は 404 になる

このくらいは全テナントで見たい。

テスト環境の罠

ローカルの E2E は、別のアプリが同じ port で起動していても通ったように見えることがあります。

これが地味に怖い。

localhost:3000 に何かがいる。

Playwright はそこを見に行く。

でも、それが今テストしたいアプリとは限らない。

ああ、そんなことあるんだなと。。。

なので、E2E の前にアプリの identity を確認するのは大事そうです。

await page.goto('/');
await expect(page.locator('[data-app="ec-app"]')).toBeVisible();

こういう目印でもいいし、

特定の tenant 名が出ることでもいい。

「ブラウザが開けた」だけでは信用しない。

おわり

複数ショップ対応は、画面を出すだけならそこまで難しくないです。

でも、増えてきた時に効いてくるのは、

  • 共通 layout で何を fetch するか
  • どこを cache するか
  • 失敗時に fallback するか
  • E2E が全テナントを最低限見ているか
  • そもそも正しいアプリをテストしているか

このあたりでした。

昔なら「表示できた、OK」で終わっていたと思います。

今は、表示できた後に、

「この fetch は全ページで走るけど大丈夫か」

「テナントが増えた時にテストで気づけるか」

まで見たい。

マルチテナントECは、実装よりも増えた時の確認方法が大事なのかもしれないです。