💸

技術的負債は「しゃあない」で積み上がる ── 実務コードで見るリアルな成れの果て

に公開

技術的負債は「しゃあない」で積み上がる ── 実務コードで見るリアルな成れの果て

みなさん、こんにちは!フロントエンドエンジニアの @nyaomaru です!

最近、AI と話す時間の方が、人間と話す時間より長くなった気がしています。
けれどもコードだけは、どっちと話しても結局こうなります。

技術的負債。

本記事は「技術的負債ってなんやねん?」の続編です。

https://zenn.dev/nyaomaru/articles/technical-debt-basics

この記事では、技術的負債がどう積み上がるか を、コードベースで実演します。

できるだけリアルに、「実際にありそうな(あった)改修地獄」 を再現しました。

最初はきれいなコード。
でも追加要件を受け入れ続けるうちに、どうなるか?

ほんなら、一緒に見てこな!

💸 技術的負債は、こうして静かに積み上がる

初めから dirty なコードなんて誰も書かへん。

review で品質を担保してれば、ある程度は防げると思っとる。

最近やったら、Copilot や Code Rabbit みたいな AI review もあるしな。

致命的なコードは、ほんまに減ってきた。

でも、“しゃあない”を言い訳にした小さな積み上げが、確実に未来を壊していくねん。

コードの 1 行 1 行よりも怖いのは、仕様の積み増し時間切れの判断ミスや。

  • 規模が肥大化してきたり
  • 機能追加による改修が重なったり
  • 既存コードが誤解されたり
  • 共通化・汎用化されなかったり

── こういうもんが積み重なって、技術的負債は静かに、けど確実に増殖していく。

今回はその中でも、

「機能追加による改修が重なる」 パターンを、React を使って見ていくで~!

Next.js + zod + RHF + shadcn で構成してる前提な!

🔧 「あと 1 個だけ」改修の連打が未来を壊す

フォーム画面作ってるときに、よくあるやろ?

「ステップ形式のフォームにしよや」

そこまではええ。

せやけど、そのあとや。

追加要件が来るたびに、「とりあえず動くようにしとこう」 で逃げ続けたコードの末路。

既存コードに無理やり条件ねじ込んで、回避策を連打しまくった地獄を想定してみよか。

まずは

「通常注文」のフォームを作ったんや。

これはまだ機能としても薄いし、全然読みやすい。

この時点では、リファクタするほどでもないな。

時間に余裕あったらしてもええけどな、ってレベルや。

ディレクトリ構成
features/
└── order-form/
    ├── components/
    │   └── OrderForm.tsx

🚨 最初はこんな感じやった(通常注文だけ)

OrderForm.tsx
// OrderForm 実装例
import { useState } from 'react';

type Form = {
  customerName: string; // Step1
  email: string; // Step1
  orderId: number; // Step2
  phone: string; // Step2
};

// 注文フォーム
export function OrderForm() {
  const [step, setStep] = useState(1);
  const [formData, setFormData] = useState<Form>({
    customerName: '',
    email: '',
    orderId: 0,
    phone: '',
  });

  const submit = async () => {
    try {
      await api.post('/orders', formData);
      alert('注文が完了しました!');
    } catch (error) {
      console.error(error);
      alert('注文に失敗しました。');
    }
  };

  return (
    <div>
      {step === 1 && (
        <StepOne
          data={formData}
          setFormData={setFormData}
          onNext={() => setStep(2)}
        />
      )}
      {step === 2 && (
        <StepTwo
          data={formData}
          setFormData={setFormData}
          onBack={() => setStep(1)}
          onSubmit={() => submit()}
        />
      )}
    </div>
  );
}

よっしゃ、ほんなら機能追加するで~

さて、次に。

「特別注文」に対応せなあかんようになったとしよか。

変な値いれて実行されたら困るから、バリデーションを追加したわ。

あと、肝心な注文 ID を入れてもらわなあかんから、Step も追加したで。

さっきまではシンプルやった実装が、ここで一気に見づらくなっていく。

いってみよか。

🔥 改修が重なるとこうなる(特別注文追加)

OrderForm.tsx
// 🔥 特別注文が入った後の OrderForm 実装例
import { useState, useEffect } from 'react';
import { stepOneSchema, stepTwoSchema, stepThreeSchema } from './formSchema';

type Form = {
  customerName: string; // Step1
  email: string; // Step1
  phone: string; // Step2
  orderId: number; // Step2 から Step3 に移動
  discountCode?: number; // Step3 (specialな場合だけ)
};

export function OrderForm({ isSpecial }: { isSpecial: boolean }) {
  const [step, setStep] = useState(1);
  const [formData, setFormData] = useState<Form>({
    customerName: '',
    email: '',
    phone: '',
    orderId: 0,
    discountCode: undefined,
  });

  // special な実装を追加
  useEffect(() => {
    if (isSpecial) {
      // 特別注文の場合だけ discountCode を初期化
      setFormData((prev) => ({
        ...prev,
        discountCode: 666, // 仮初期値をセット
      }));
    }
  }, [isSpecial]);

  const handleNextStep = async () => {
    try {
      if (step === 1) {
        stepOneSchema.parse(formData);
        setStep(2);
      } else if (step === 2) {
        stepTwoSchema.parse(formData);
        setStep(3);
      }
    } catch (e) {
      console.error(e);
    }
  };

  const handleSubmit = async () => {
    try {
      stepThreeSchema(isSpecial).parse(formData);
      await api.post('/orders', formData);
      alert('注文が完了しました!');
    } catch (e) {
      console.error(e);
      alert('注文に失敗しました。');
    }
  };

  return (
    <div>
      {step === 1 && (
        <StepOne
          data={formData}
          setFormData={setFormData}
          onNext={handleNextStep}
        />
      )}
      {step === 2 && (
        <StepTwo
          data={formData}
          setFormData={setFormData}
          onBack={() => setStep(1)}
          onNext={handleNextStep}
        />
      )}
      {step === 3 && (
        <StepThree
          data={formData}
          setFormData={setFormData}
          onBack={() => setStep(2)}
          onSubmit={handleSubmit}
          showDiscountField={isSpecial}
        />
      )}
      {isSpecial && <NoteForSpecialOrder />}
    </div>
  );
}

😇 次第に・・・

ほんでさらに。

「管理者注文」っちゅう、特別な注文ができるようになったとしよ。

機能をさらに足していくと、もう読みづらいどころの話ちゃう。

保守するたびに、心折れるレベルになってきてる。

特にヤバいんが、コンポーネント内に増殖する if 条件や。

見てみ。

OrderForm.tsx
// 🔥 特別注文に加えて、管理者注文が追加された OrderForm 実装例
import { useState, useEffect } from 'react';
import {
  stepOneSchema,
  stepTwoSchema,
  stepThreeSchema,
  stepFourSchema,
} from './formSchema';

type Form = {
  customerName: string; // Step1
  email: string; // Step1
  phone: string; // Step2
  orderId: number; // Step2 から Step3 に移動
  discountCode?: number; // Step3 (specialな場合だけ)
  remarks?: string; // Step4 (adminな場合だけ)
};

export function OrderForm({
  isSpecial,
  isAdmin,
}: {
  isSpecial: boolean;
  isAdmin: boolean;
}) {
  const [step, setStep] = useState(1);
  const [formData, setFormData] = useState<Form>({
    customerName: '',
    email: '',
    phone: '',
    orderId: 0,
    discountCode: undefined,
    remarks: undefined,
  });

  useEffect(() => {
    if (isSpecial) {
      // 特別注文なら discountCode を初期化
      setFormData((prev) => ({
        ...prev,
        discountCode: 666,
      }));
    }
  }, [isSpecial]);

  const handleNextStep = async () => {
    try {
      if (step === 1) {
        stepOneSchema.parse(formData);
        setStep(2);
      } else if (step === 2) {
        stepTwoSchema.parse(formData);
        setStep(3);
      } else if (step === 3) {
        stepThreeSchema(isSpecial).parse(formData);
        // Adminユーザーなら Step4 へ、それ以外なら Submit
        if (isAdmin) {
          setStep(4);
        } else {
          await handleSubmit();
        }
      }
    } catch (e) {
      console.error(e);
    }
  };

  const handleSubmit = async () => {
    try {
      // Step4がある場合は、stepFourSchemaもバリデート
      if (isAdmin && step === 4) {
        stepFourSchema.parse(formData);
      }
      await api.post('/orders', formData);
      alert('注文が完了しました!');
    } catch (e) {
      console.error(e);
      alert('注文に失敗しました。');
    }
  };

  return (
    <div>
      {step === 1 && (
        <StepOne
          data={formData}
          setFormData={setFormData}
          onNext={handleNextStep}
        />
      )}
      {step === 2 && (
        <StepTwo
          data={formData}
          setFormData={setFormData}
          onBack={() => setStep(1)}
          onNext={handleNextStep}
        />
      )}
      {step === 3 && (
        <StepThree
          data={formData}
          setFormData={setFormData}
          onBack={() => setStep(2)}
          onNext={handleNextStep}
          showDiscountField={isSpecial}
        />
      )}
      {step === 4 && (
        <StepFour
          data={formData}
          setFormData={setFormData}
          onBack={() => setStep(3)}
          onSubmit={handleSubmit}
        />
      )}
      {isSpecial && <NoteForSpecialOrder />}
      {isAdmin && <NoteForSpecialOrder />}
    </div>
  );
}

☠️ さらに改修を続けていくと・・・

どんどん複雑化して、地獄の入り口に差し掛かる。

  • 🤯 分岐数=画面の複雑性のリトマス試験紙
    • 🔥 各ステップに if 条件が混在 → バグ修正時に見落としやすくなる
  • 🤯 一つのコンポーネント内に複数の状態が現れだしたら黄色信号
    • 🔥 StepOne 〜 StepFour が 1 ファイルにベタ書き → コンポーネント肥大化
  • 🤯 状態とロジックがどんどん絡み出す
    • 🔥 テストケースの爆発 → 特別注文、管理者注文、通常注文それぞれのテスト分岐地獄

つまり ──
「分岐するたびに死に近づいてる」

🧹 ここがリファクタのサイン

ここまできたら、

分岐を続けるんやなくて、コンポーネントを分けた方がええタイミングや。

✅ リファクタ戦略

ほんなら、リファクタしてみよか~。

この記事読んだあと、まるっと再現できるリポジトリも作っといたから、好きにいじり倒してな

https://github.com/nyaomaru/technical-debt-sample

🏗️ 構造の見直し

まずは構造を見直そか。

今の OrderForm、一人で背負いすぎやねん。

  • フォームの状態
  • 画面遷移
  • バリデーション
  • 特別注文か、管理者注文かの判定

── 全部 OrderForm に押し込んでたら、そら破裂するわ。

せやからまず、注文タイプ単位で分ける。

ディレクトリ構成
features/
└── order-form/
    ├── components/
    │   ├── AdminOrderForm.tsx
    │   ├── SpecialOrderForm.tsx
    │   ├── NormalOrderForm.tsx
    │   └── MultiStepForm.tsx
    ├── model/
    │   ├── schemas/
    │   |   ├── admin.ts
    │   |   ├── index.ts
    │   |   ├── normal.ts
    │   |   └── special.ts
    │   ├── context/
    │   |   └── FormContextProvider.tsx
  • 👉 各注文タイプごとにコンポーネントを分ける。
  • 👉 スキーマもストーリーごとに切り出して独立。
  • 👉 コンテキストも切って、どのステップからも formData にアクセスできるようにしとく。

💡 こうすることで、追加機能もバグも、タイプ単位で閉じ込められる。
💡 一箇所直したら全部バグるみたいな悪夢から抜け出せる。

切り出しておくことのメリット、たくさんあるなぁ!

詰め込み式のフラグ管理やめよ

ほんで、これ。

Before

<OrderForm isSpecial isAdmin />

注文のタイプに応じて、
内部で if (isSpecial) { ... } else if (isAdmin) { ... }
みたいな分岐してたやろ?

これ、実はな。

「未来の自分に地雷仕込んでる」 ようなもんやで。

後から踏み抜いて爆発するのは、間違いなく「君自身」や。

🚀 After

せやからこうする。

<AdminOrderForm />
<SpecialOrderForm />
<NormalOrderForm />
  • isSpecial, isAdmin とかいう props はもういらん
  • どういう注文なのか?は、ルーティングか親コンポーネントで決める
  • OrderForm 自体は、分岐せずシンプルなロジックだけ残る
  • テストも分けやすいし、Storybook も注文タイプごとに作れる

つまり、フォームコンポーネントは、そのストーリー専用にする。

✨ これでどうなる?

  • 🔥 特別注文だけ変更 → 通常注文には一切影響なし
  • 🔥 管理者注文だけ仕様追加 → 他のフローは触らんでええ
  • 🔥 仕様変更に強い、テストもしやすい、コードも見やすい
  • 🔥 未来の自分に感謝される

ホンマ、おおきにな。

分岐地獄を卒業するために、最初から「分けて」作ろ。

ほんで未来の自分に、爆速で「おおきに」言わせたれ。

せや、共通ロジックはまとめとこか

汎用的な実装を抽出すると、保守しやすなるで~。

どの注文タイプでも、ステップ管理はコレひとつで完結する。

これが「共通ロジック切り出し」の破壊力や。

MultiStepForm.tsx
// features/order-form/components/MultiStepForm.tsx
'use client';

import React, { useState } from 'react';
import { useRouter } from 'next/navigation';

import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui';

import type { StepProps, StepHandlers } from '../model/types/step';

type MultiStepFormProps = {
  getSteps: (handlers: StepHandlers) => React.ReactElement<StepProps>[];
};

export function MultiStepForm({ getSteps }: MultiStepFormProps) {
  const router = useRouter();
  const [step, setStep] = useState(0);

  const handleNext = () =>
    setStep((step) => Math.min(step + 1, steps.length - 1));
  const handleBack = () => setStep((step) => Math.max(step - 1, 0));
  const handleSubmit = () => {
    router.push('/thanks');
  };

  const steps = getSteps({
    onNext: handleNext,
    onBack: handleBack,
    onSubmit: handleSubmit,
  });

  if (!steps.length) {
    return (
      <Card className='w-full max-w-xl bg-neutral-800 border border-white/20 shadow-md rounded-xl'>
        <CardContent className='text-center text-white'>
          No steps available
        </CardContent>
      </Card>
    );
  }

  const CurrentStep = steps[step];

  return (
    <Card className='w-full max-w-xl bg-neutral-800 border border-white/20 shadow-md rounded-xl'>
      <CardHeader>
        <CardTitle className='text-white text-2xl'>
          Step {step + 1} of {steps.length}
        </CardTitle>
      </CardHeader>
      <CardContent className='space-y-6'>{CurrentStep}</CardContent>
    </Card>
  );
}

ほな、バリデーションを抽出

バリデーションがややこしいから、zod 使って、model に切り出してみるで~

バリデーションってロジックやから、UI コードとはちゃんと分けたほうがメンテしやすいんよな。

NormalOrderForm用のスキーマ(normal.ts)
// features/order-form/model/schemas/normal.ts
import { z } from 'zod';

export const normalOrderSchema = z.object({
  name: z.string().min(1, 'Name is required'),
  email: z.string().min(1, 'Email is required').email('Invalid email address'),
  phone: z
    .string()
    .min(10, 'Phone number must be at least 10 digits')
    .regex(/^\+?[0-9]{10,15}$/, 'Invalid phone number'),
  orderId: z.number().positive('OrderId must be positive'),
});

export type NormalOrderSchemaType = z.infer<typeof normalOrderSchema>;
SpecialOrderForm用のスキーマ(special.ts)
// features/order-form/model/schemas/special.ts
// 特別注文は通常注文に +α したやつやから、extend で再利用してるで
export const specialOrderSchema = normalOrderSchema.extend({
  discountCode: z.number().optional(),
});

export type SpecialOrderSchemaType = z.infer<typeof specialOrderSchema>;
AdminOrderForm用のスキーマ(admin.ts)
// features/order-form/model/schemas/admin.ts
export const adminOrderSchema = specialOrderSchema.extend({
  remarks: z
    .string()
    .min(1, 'Remarks is required')
    .max(500, 'Remarks cannot exceed 500 characters'),
});

export type AdminOrderSchemaType = z.infer<typeof adminOrderSchema>;

ほんで、FormProvider 作ろか

StepForm の入力値を利用できるように、Provider で注入してあげよ。

フォームデータって、ページ跨ぎやステップ遷移するから、ステートリフトアップ(親で持つ) するとバケツリレー地獄になるねん。

Context で注入しといたら、どのコンポーネントからもサクッとデータ取れる。

つまり、バケツリレーを爆速で回避できる最強ムーブってわけやな。

そうすると、一元管理できて保守もしやすいで~。

FormContextProvider.tsx
// features/order-form/model/context/FormContextProvider.tsx
'use client';

import React, { createContext, useContext, useState } from 'react';
import { z } from 'zod';

import { adminOrderSchema } from '@/features/order-form/model/schemas/admin';

export type FormData = z.infer<typeof adminOrderSchema>;

type FormContextType = {
  formData: FormData;
  setFormData: (data: Partial<FormData>) => void;
  resetForm: () => void;
};

const defaultFormData: FormData = {
  name: '',
  email: '',
  phone: '',
  orderId: 0,
  discountCode: undefined,
  remarks: '',
};
const FormContext = createContext<FormContextType | undefined>(undefined);

export function FormProvider({
  children,
  initialDiscountCode,
}: {
  children: React.ReactNode;
  initialDiscountCode?: number;
}) {
  const [formData, setFormDataState] = useState<FormData>({
    ...defaultFormData,
    ...(initialDiscountCode ? { discountCode: initialDiscountCode } : {}),
  });

  const setFormData = (data: Partial<FormData>) =>
    setFormDataState((prev) => ({ ...prev, ...data }));

  const resetForm = () => setFormDataState(defaultFormData);

  return (
    <FormContext.Provider value={{ formData, setFormData, resetForm }}>
      {children}
    </FormContext.Provider>
  );
}

export function useFormContext() {
  const context = useContext(FormContext);
  if (!context)
    throw new Error('useFormContext must be used within a FormProvider');
  return context;
}

ちなみに、FormProvider で囲ってへんのに useFormContext() 叩いたら、即クラッシュするで!

こういうガードは最初から仕込んどくんが鉄板や。

そうすると

各フォームは steps だけ指定すれば済む。

すっきりしててええ感じやなぁ~!

NormalOrderForm.tsx
// features/order-form/components/NormalOrderForm.tsx
'use client';

import { useForm, FormProvider } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';

import { MultiStepForm } from './MultiStepForm';
import { getNormalSteps } from './steps/normal/getSteps';
import { normalOrderSchema } from '../model/schemas/normal';
import type { NormalOrderSchemaType } from '../model/schemas/normal';

export function NormalOrderForm() {
  const methods = useForm<NormalOrderSchemaType>({
    resolver: zodResolver(normalOrderSchema),
    mode: 'onTouched',
    defaultValues: {
      name: '',
      email: '',
      phone: '',
      orderId: 0,
    },
  });

  return (
    <FormProvider {...methods}>
      <MultiStepForm getSteps={getNormalSteps} />
    </FormProvider>
  );
}

🛠️ さらにすっきりするで!

step を各シナリオ毎に分けると、さらにすっきりするし、追加改修があっても柔軟に対応できるで!

例えば:

  • 特別注文だけ Step3 に extra フィールド追加したい
  • 管理者注文だけ Step2 に認証チェック入れたい

こういうとき、step 単位で分かれてるとそこのコードだけ見れば済む

逆に、全部一緒くたになってたら:

  • if (isSpecial) { ... } else { ... }
  • if (isAdmin) { ... } else { ... }

みたいな分岐地獄がまた始まってまう。

→ せやから、step も分けるのが正義や。

ディレクトリ構成はこんな感じ

ディレクトリ構成
features/
└── order-form/
    └── components/
        └── steps
            ├── admin
            ├── normal
            │   ├── getStep.tsx
            │   ├── StepOne.tsx
            │   ├── StepTwo.tsx
            │   └── StepThree.tsx
            ├── special
            ├── guest/      # ゲスト注文用フォーム(未来)
            └── enterprise/ # エンタープライズ注文用フォーム(未来)

未来にシナリオが増えても、この形なら耐えられる。

getSteps.tsx
// features/order-form/components/steps/normal/getSteps.tsx
'use client';

import type { ReactElement } from 'react';

import { StepOne } from './StepOne';
import { StepTwo } from './StepTwo';
import { StepThree } from './StepThree';
import type { StepProps, StepHandlers } from '../../../model/types/step';

export function getNormalSteps(
  handlers: StepHandlers
): ReactElement<StepProps>[] {
  return [
    <StepOne key='step1' onNext={handlers.onNext} />,
    <StepTwo key='step2' onNext={handlers.onNext} onBack={handlers.onBack} />,
    <StepThree
      key='step3'
      onBack={handlers.onBack}
      onSubmit={handlers.onSubmit}
    />,
  ];
}

Special / Normal も同じ要領で切り出していけるはずや!

getSteps パターンのうまみ

getSteps をただの配列にせず、関数型にしてるのもミソや。

これのおかげで:

  • 実行時にステップ構成を変えられる
  • 必要なときだけステップを生成できる(Lazy 評価)

例えば、管理者だけ extra step 出したいときも:

getSteps({ isAdmin: true });

みたいに柔軟にできるわけやな。

利用側はシュッとこうなる

import { NormalOrderForm } from '@/features/order-form';

export default function NormalOrderPage() {
  return <NormalOrderForm />;
}
import { SpecialOrderForm } from '@/features/order-form';

export default function SpecialOrderPage() {
  return <SpecialOrderForm />;
}
import { AdminOrderForm } from '@/features/order-form';

export default function AdminOrderPage() {
  return <AdminOrderForm />;
}

シュッてしててかっこええわ~、惚れてまうなぁ!

🎯 リファクタのメリット

  • 🔥 注文タイプが増えても、他に波及しない
    • → 未来で「法人注文」とか「ゲスト注文」とか増えても、「通常注文」が壊れへん。
  • 🔥 Storybook テストが圧倒的に楽になる
    • NormalOrderForm.stories.tsx とかで フローごとに個別管理できる。
  • 🔥 運用フェーズで改修が怖くない
    • → 「このボタン押したら他も壊れるかも……」みたいなビビり改修がなくなる。
  • 🔥 パフォーマンスも地味に良くなる
    • → 不要なロジック・ステート持たないから、コンポーネントのリレンダリングも減る。
  • 🔥 オンボーディングが楽になる
    • → 新人でも、NormalOrderForm だけ見ればその注文の全体像がわかる。読みやすい。

📌 終わりに

技術的負債は “しゃあない” で生まれる。
けど、「未来の自分たちが耐えられる “しゃあない” かどうか?」を判断基準にすれば、致命傷にはならん。

フォームが複雑化しそうなとき、今日紹介したリファクタ戦略を思い出してほしい。

ほんのちょっとコンポーネントを分けるだけで、未来のバグと戦う時間は確実に減らせる。future-proof な実装になる。

未来の自分に 「おおきに」 言わせるリファクタ。

それができるエンジニアが、現場ではほんまに重宝されるねん。

AI 時代でも生き残るエンジニアっちゅうのは、そういう目の前の事を大切にできる人なんちゃうかな?知らんけど。

おまけ

ファイルの命名やけど、小さなパーツ(shadcn)は kebab-case、自作の画面コンポーネントはPascalCaseで統一するとスッキリするで!

今回の実装例は React + RHF + zod やけど、Vue + VeeValidate とかでも同じ地獄になるで!構造化が命や!

技術的負債について、他の記事もあるから読んでってな~!

👉 技術的負債ってなんやねん?編

https://zenn.dev/nyaomaru/articles/technical-debt-basics

👉 技術的負債の芽は「構造」にある編

https://zenn.dev/nyaomaru/articles/technical-debt-structure

Discussion