02判断5 min

ゼロから始めることはありません: いつも持っていくものと、いつも削るもの

同じボイラープレートの上に十のプロジェクト。それぞれの最初のコミットは、新規プロジェクトというより前のプロジェクトの最後のコミットに似ていて、二つめのコミットはたいてい何かを消しています。

著者: Salva Castells

カバー画像: Park taeho, CC BY-SA 4.0.
この記事の内容

自分のプロジェクトの最初のコミットは "Initial commit" という名前で、そこにはもう認証、データベース、決済、i18n、管理画面が入っています。二つめはよく "remove useless things" という名前になります。この二つのコミットの組み合わせが、自分の働き方をいちばんよく要約しています。ゼロから始めるのではなく、テンプレートをクローンして削るところから始めるのです。

そういうプロジェクトが十もあれば、何が核で何がノイズかについてかなり鋭い判断が得られます。意見ではありません。履歴に残っていますし、同じ削除が繰り返される場所に表れています。

こう始めることで得られるもの

運送会社向けの障害管理 SaaS は、ゼロから本番まで三日でした。一日目、クローンして余分を外す。二日目、障害のスキーマ、画像アップロード、詳細のドロワー、一覧のキャッシュ。三日目、ランディング。三日とも丸ごとドメインの作業でした。誰もログインには触れていません。

画像生成の SaaS も同じ始まり方でした。"Initial commit" の日にはすでに生成画像のスキーマ、S3 のヘルパー、ブランディングとテーマがありました。翌日には生成のパイプラインができていました。認証、Drizzle を使った Postgres、Stripe、i18n は最初から入っていました。

節約できるのはログインを書く手間ではありません。それが引き連れてくる一か月ぶんの小さな決定です。セッションはどこに住むのか、新しいルートをどう守るのか、Cookie が切れたら何が起きるのか、言語をどう宣言するのか。

いつも残るもの

  • 入り口がひとつの認証。 各ページが呼び出すのを覚えておかなければならない requireSession() 関数ではなく、プライベートなツリーに置く layout.tsx です。新しいページは、チェックのコピーを覚えているかどうかではなく、どこに置いたかで守られます。
  • 型付き ORM とバージョン管理されたマイグレーションを備えた Postgres。 ここには気づくのに時間のかかる細部があります。drizzle のスナップショットがずれると、drizzle-kit generate は空のデータベースに対して差分を取り、スキーマ全体の CREATE TABLE を再発行します。generate が「変更なし」と言うまで最初のスナップショットを作り直すことで直ります。
  • 初日からの i18n。 あとから入れると、プロジェクト中の文字列を全部触ることになります。先に入れれば一晩です。
  • 最小限の管理画面。 ユーザー一覧と指標が四つ。それだけです。

いつも削るもの

Stripe がわかりやすい例です。勤怠アプリでは、checkout、portal、webhook のルートが初回コミットと同じ日に丸ごと削除されました。338 行が外へ。社内ビューアのテンプレートでもまったく同じ。別のプロジェクト、一年の差、同じ削除。

結論は Stripe をテンプレートから外すことではなく、フラグの後ろに置くことでした。

export const USE_STRIPE = process.env.USE_STRIPE === "true";

export async function POST(request: NextRequest) {
  if (!USE_STRIPE) {
    return NextResponse.json({ error: "Stripe is not enabled" }, { status: 501 });
  }

決済のないプロジェクトは USE_STRIPE=false と書いて先に進みます。ひとつ、気づくのに苦労した点があります。フラグがオフのとき Stripe のクライアントは null as unknown as Stripe でした。だからうっかり使うと、何が起きたかを言わずに不透明な TypeError が出ます。いまは明示的なエラーを投げる Proxy になっています。

もうひとつ削ったのは RLS です。テーブルは drizzle-orm/neon の crudPolicy({ role: authenticatedRole, read: authUid(table.userId) }) を持って生まれました。全部外しました。理由は RLS が悪いからではありません。アプリがすでにサーバーから管理者用の接続で全アクセスをしていたので、ポリシーは一度も評価されず、存在しない安心感だけを与えていたからです。二層ある認可のうち片方しか生きていない状態は、一層だけよりも悪い。どちらが効いているのか誰にもわからないからです。

そして肥大した管理画面。チームのテンプレートは数か月ぶんの管理画面とユーザー管理を溜め込み、最後はそれを丸ごと消すコミットに至りました。子プロジェクト全部にコストを継承させるのに、使う子はほとんどいません。

遅れてくるコスト

テンプレートの上で働くことについて、誰も語らないのがこれです。親のバグは、すでに全部の子に複製された状態で生まれます。

いちばん高くついたのは root layout の一行、alternates.canonical: BASE_URL でした。canonical を上書きしないページはすべて自分をホームに canonical 化し、インデックスから落ちます。何か月ものあいだ、すべてのクローンにありました。そこから得た規則。canonical がないことは無害だが、間違った canonical はそうではない。以来、共有 layout は canonical を置かず、各ルートが自分のものを置きます。

同じ種類のものがあと二つ。

  • .gitignore が .env.local しか無視していなかった。 クローンして最初にやる自然な一歩は cp .env.example .env です。最初のコミットが鍵を巻き込んでいきました。
  • BASE_URL がテンプレートのドメインにハードコードされていた。 そこから metadataBase、canonical、hreflang、OG の URL、サイトマップ、Stripe の checkout の同一オリジン検証がぶら下がっていました。どのクローンでも静かに全部間違っていて、コンソールにはエラーひとつ出ません。

言語の判定は acceptLanguage.includes(locale) でした。クライアントの優先順位を無視し、部分文字列の一致で当ててしまいます。"sl-SI" は "sl" を含むからです。その一行を継承したプロジェクトの数だけ掛け算されます。

親テンプレートはどう学ぶか

修正は逆向きに流れます。子から親へ。あるプロジェクトが何かを見つけたら、その修正をテンプレートに上げ、次のプロジェクトはそれを持って生まれます。これが機能するのは、修正に「その不具合を再導入したら落ちるテスト」が添えられている場合だけです。でないと、次のリファクタがその規則をバグへと「単純化」して戻します。

自分のテンプレートに対する直近の大きな堅牢化は、39 項目の PR でした。processed_stripe_events テーブルによる Stripe webhook の冪等化、サーバーアクションでは identity を常にセッションから取り引数では受け取らないこと、フィードバックの各読み取りの WHERE に userId を入れること、checkout の戻り先 URL の同一オリジン検証。マイナス 11,421 行、プラス 7,268 行。消したものはほとんどが、もう存在しない構造を説明するチュートリアルでした。

そして次のプロジェクトは chore: inicializa desde la plantilla endurecida で始まりました。要はそこに尽きます。

テンプレートはタイプ量の節約ではなく、すでに学んだことを置いておく場所です。役に立つかどうかを決めるのは何が入っているかではなく、見つけた子にとどめずに修正を毎回そこへ上げる規律です。