🏰

単純なロジックを組み合わせて複雑なシステムを作る

に公開

単純なロジックを組み合わせていこう

ソフトウェアを書くとき、私の軸がある。「小さくて単純なロジックを組み合わせて、大きく複雑なシステムに育てる」ことだ。

単純な部品だけを相手にすれば、書くのも読むのも楽になる。必要なのは小さな関数と単体テスト。この二つさえ丁寧に積み上げれば、最後に部品をつなげるだけで高度なしくみが完成する。

  • 書きやすい
  • 読みやすい
  • 単体テストが増やしやすい
  • 後から手直ししやすい
  • 複数人で同時に作業しやすい
  • バグの発生源をすぐ突き止められる

こうした利点のおかげで、大規模でも保守費用を抑えられる。

欠点もある。部品を細かく分けると、関数宣言が増えて見た目の行数が伸びる。「function(){}」が何度も出てくるので、一瞬「冗長かな」と感じる人もいるだろう。

対極にあるのは「複雑なロジックをあちこちで組み合わせて巨大な一塊を作る」やり方だ。意識しないとすぐそちらへ流れる。そうなると読みにくく、テストは書きづらく、リファクタリングでは問題が起き、コンフリクトもしやすく、バグの原因もぼやける。

具体例

商品の価格計算ロジックを例にとる。まずデータ型を定義する。

type Item = {
  name: string
  price: number
}

type Coupon = {
  type: 'percentage' | 'fixed'
  value: number
}

何も考えず一つの関数に詰め込むと次のようになる。

function calculateItemPrice(item: Item, options: { coupon?: Coupon, taxRate: number }): number {
  const price = item.price;
  const { coupon, taxRate } = options

  // クーポンの適用
  if (coupon !== undefined) {
    if (coupon.type === 'percentage') {
      price = price - (price * coupon.value) / 100;
    } else if (coupon.type === 'fixed') {
      price = price - coupon.value;
      if (price < 0) {
        price = 0;
      }
    }
  }

  // 税の適用
  let taxedPrice = price + price * taxRate;

  // 端数の処理
  const rounded = parseFloat(taxedPrice.toFixed(2));

  return rounded;
}

行数はほどほどだが、条件分岐が入り組み、テストケースも膨大になる。もし期待と違う値が返ったら、どこで壊れたか探すのは骨が折れる。実務ではこの数倍の長さの関数が平気で現れ、しかもテストは無い、という光景がよくある。

では小さくて単純なロジックを組み合わせるアプローチではどうなるだろう:

// クーポン
function applyPercentageCoupon(price: number, value: number): number {
  return price - (price * value) / 100
}

function applyFixedCoupon(price: number, value: number): number {
  return Math.max(price - value, 0)
}

function applyCoupon(price: number, coupon?: Coupon): number {
  if (!coupon) return price

  switch (coupon.type) {
    case 'percentage':
      return applyPercentageCoupon(price, coupon.value)
    case 'fixed':
      return applyFixedCoupon(price, coupon.value)
    default:
      throw new Error(`Invalid coupon type: ${coupon.type}`)
  }
}
// 税
function applyTax(price: number, taxRate: number): number {
  return price + price * taxRate
}
// 端数
function roundToTwo(price: number): number {
  return parseFloat(price.toFixed(2))
}
// 統合
function calculateItemPrice(
  item: Item,
  options: { coupon?: Coupon; taxRate: number }
): number {
  let price = applyCoupon(item.price, options.coupon)
  price = applyTax(price, options.taxRate)
  return roundToTwo(price)
}

関数合成を使えば、より宣言的になり、読みやすくなる。

function calculateItemPrice(
  item: Item,
  options: { coupon?: Coupon; taxRate: number }
): number {
  return pipe<number>(
    price => applyCoupon(price, options.coupon),
    price => applyTax(price, options.taxRate),
    roundToTwo
  )(item.price)
}

applyTaxapplyCoupon の順を入れ替えれば、税込価格にクーポンを適用するロジックに変更も可能。

フロントエンドにも応用可能

フロントエンドはロジックが散らばりやすい。宣言的 UI フレームワークを使っていても、複雑なロジックを各コンポーネントに押し込めると保守がつらくなる。次の例で問題点を確認しよう。

<template>
  <Product :product-item="productItem" />
  
  <!-- クーポンの選択肢がコンポーネント内部にある -->
  <CouponSelect v-model="coupon" />
  
  <!-- 価格計算ロジックがコンポーネント内部にある -->
  <Price :product-item="productItem" :coupon="coupon" />
</template>

<script setup lang="ts">
const productItem = { name: 'product', price: 100 }
const coupon = ref<Coupon | undefined>()
</script>

CouponSelect の内部にクーポンのデータを抱え、Price の内部に価格計算ロジックを抱える。どちらも大きな UI コンポーネントになり、テストが難しくなる。

構造を変えてみる。

<template>
  <Card :title="productItem.name" :description="productItem.price" />
  
  <!-- テスト済みの基本UIコンポーネントを使い回しつつ、データはページコンポーネントから注入する -->
  <Select v-model="coupon" :options="coupons" />
  
  <!-- 関数はページコンポーネントから注入する -->
  <div>{{ calculateItemPrice(productItem, { coupon, taxRate }) }}</div>
</template>

<script setup lang="ts">
import { calculateItemPrice } from '../productItem'
import { taxRate } from '../tax'

const productItem = { name: 'product', price: 100 }
const coupon = ref<Coupon | undefined>()
const coupons = [
  { type: 'percentage', value: 10 },
  { type: 'fixed', value: 100 }
]
</script>

UI はカードやセレクトのような小さな部品で再利用し、状態とロジックはページコンポーネントが受け持つ。テスト対象が小さくなるので単体テストを追加しやすく、安心して改修できる。

状態が増えたら

  • ページコンポーネントを分ける
  • 関連する状態と関数をオブジェクトやクラスにまとめる

どちらを採っても「単純なロジックを一箇所に集める」という原則が守れていれば問題ない。スクリプトならメイン関数、バックエンドならユースケースやワークフロー、フロントエンドならページコンポーネントがその「一箇所」になる。

小さくて単純なロジックを大量に作り、単体テストで保証し、最後に組み立てる。これが複雑なソフトウェアを低コストで育てる王道だ。

補足

小さなアプリなら、複雑なロジックを塊で書いても読める範囲に収まるかもしれないし、それで十分だろう。ただ、規模が大きくなるほど今回のやり方の価値は大きくなる。

Discussion