リファクタリングで1万行削除して気づいた、コードの本当の価値
ソフトウェアのライフサイクルコストのうち、70〜90%は保守に費やされるという古典的な研究結果は、Webサービスが複雑化した現在でも大きくは変わっていません¹。さらに、StripeのDeveloper Coefficientでは開発者が週あたり約13.5時間を技術的負債や待ち時間に奪われると報告されています²。私たちが増やしているのは価値か、それとも将来コストか。この記事では、1万行規模の削除を伴うリファクタリングを題材に、どこをどう減らせば価値が最大化されるのかを、実装・計測・運用の視点で具体化します。p95応答時間(全リクエストの95%が収まるレイテンシ)の短縮、障害起因バグの減少、デプロイ頻度の向上はしばしば観測される改善であり、サンプル構成ではp95が180msから120msへと短縮される例も確認できます。ここで見えてくるのは、コードの価値は行数ではなく、意思決定をどれだけ安全に圧縮して届けられるかという視点です。
コードの価値は行数ではない
行数は努力の量を示すことはあっても成果の質を保証しません。工学的にも、欠陥密度はKLOC(千行あたりの行数)に比例しやすく、複雑性が増すほど変更の局所性は崩れます³。つまり、増やすほどバグの母集団が膨らむ構造的な圧力が働く。価値は、顧客が体験する応答時間や可用性、機能の正確さ、変更の容易さ(変更にかかる時間や影響範囲)といったアウトカムで測るべきで、コードは価値を運ぶ容器でしかない。容器が小さくシンプルであるほど、移送は速く安全になります。
本稿ではこの前提に立ち、まず価値の定義を数値として固定します。例えば、ユーザー向けはp95・p99(99パーセンタイル)のレイテンシと課金ロジックの一致率、運用はMTTR(平均復旧時間)と変更失敗率、チームはPRリードタイムとレビュー通過率。ここに対して、削除がどう効くかを追跡します⁴。削除とは単に消す行為ではなく、設計レベルでは抽象の再整理、実装レベルでは分岐と依存の縮約です。複雑性を支えるのではなく、複雑性の源泉を狙って断つ。この姿勢が、のちの「1万行削除」を現実的な選択肢に変えます。
1万行削除プロジェクトの現場記録
対象はB2B向けのサブスクリプションSaaSを想定したサンプルです。TypeScriptのモノリスAPIとReactフロントエンド、バックエンドはNode.jsとPostgreSQL。拡張の歴史の中で、料金計算や割引、キャンペーンの分岐が雪だるま式に増え、同義の分岐が異なるレイヤに重複しているという、よくある構図から始めます。まずは動作を固定するためのキャラクタリゼーションテスト(現状の振る舞いを凍結し意図せぬ変化を検知するテスト)を追加します。
// tests/pricing.characterization.test.ts
import {describe, it, expect} from '@jest/globals';
import {calcPrice} from '../src/domain/pricing';
import fixtures from './fixtures/pricing.json';
describe('pricing (characterization)', () => {
for (const f of fixtures.cases) {
it(`sku=${f.sku} plan=${f.plan} promo=${f.promo}`, () => {
const out = calcPrice(f.input);
expect(out.total).toBe(f.output.total);
expect(out.breakdown).toMatchObject(f.output.breakdown);
});
}
});
重複していた料金ロジックは巨大なswitch文として現れていました。これを見通しの悪さの象徴として、まずは現状の一部を抜粋します。
// BEFORE: src/domain/pricing.ts (抜粋)
import {Db} from '../infra/db';
type Input = {sku: string; plan: 'basic'|'pro'|'ent'; promo?: string};
export function calcPrice(input: Input) {
let base = 0;
switch (input.plan) {
case 'basic': base = 1000; break;
case 'pro': base = 3000; break;
case 'ent': base = 10000; break;
}
if (input.sku.startsWith('legacy-')) base *= 0.9;
if (input.promo === 'BLACKFRIDAY') base *= 0.7;
// ここに季節・国・顧客属性などの分岐が延々と続く…
// さらにDB参照が散在し、I/Oでテストが不安定
return {total: Math.round(base), breakdown: {base}};
}
ロジックを宣言的なポリシーへ移し、関数合成で評価する方式に変えます。I/O(外部との入出力)は境界に寄せ、ドメインは純粋関数(副作用のない関数)でテスト負荷を下げます。
// AFTER: src/domain/pricing.ts
import {Policy, evaluate} from './pricing/policy';
import {getCatalog} from '../infra/catalog';
export type Input = {sku: string; plan: 'basic'|'pro'|'ent'; promo?: string; country?: string};
const policies: Policy<Input, number>[] = [
(i, acc) => acc + ({basic:1000, pro:3000, ent:10000}[i.plan]),
(i, acc) => i.sku.startsWith('legacy-') ? acc * 0.9 : acc,
(i, acc) => i.promo === 'BLACKFRIDAY' ? acc * 0.7 : acc,
(i, acc) => i.country === 'JP' ? Math.max(acc - 100, 0) : acc,
];
export async function calcPrice(input: Input) {
const cat = await getCatalog(input.sku); // I/Oは境界に限定
const start = cat.override ?? 0;
const total = Math.round(evaluate(policies, input, start));
return {total, breakdown: {start, policies: policies.length}};
}
// src/domain/pricing/policy.ts
export type Policy<I, A> = (i: I, acc: A) => A;
export const evaluate = <I, A>(ps: Policy<I, A>[], i: I, init: A) => ps.reduce((a, p) => p(i, a), init);
外部APIアクセスの失敗と再試行の重複も除去対象です。各所で素朴なfetchが書かれており、タイムアウトやサーキットブレーカー(連続失敗時に一時的に呼び出しを遮断する仕組み)がなく、尾を引く障害を誘発していました。ここは耐障害性を共通クライアントで包み直します。
// infra/http.ts
import fetch, {RequestInit} from 'node-fetch';
import CircuitBreaker from 'opossum';
class HttpError extends Error {status?: number;}
const breaker = new CircuitBreaker(async (url: string, init?: RequestInit) => {
const controller = new AbortController();
const id = setTimeout(() => controller.abort(), 5000);
try {
const res = await fetch(url, {...init, signal: controller.signal});
if (!res.ok) {
const e = new HttpError(`HTTP ${res.status}`);
e.status = res.status; throw e;
}
return res;
} finally {
clearTimeout(id);
}
}, {timeout: 6000, errorThresholdPercentage: 50, resetTimeout: 10000});
export async function httpGetJson<T>(url: string, init?: RequestInit): Promise<T> {
let lastErr: unknown;
for (let attempt = 1; attempt <= 3; attempt++) {
try {
const res = await breaker.fire(url, init);
return await (res as any).json();
} catch (err) {
lastErr = err;
if (attempt === 3) throw err;
await new Promise(r => setTimeout(r, attempt * 200));
}
}
throw lastErr as Error;
}
この差し替えだけでも、外部依存が不安定なときの遅延の尾部(p99など)が短縮されることが多い。サンプルでは約180msの改善が見られました。料金ロジックの純化は、テスト時間を短縮しつつ、同時に計算のばらつきを減らします。ベンチマークはローカルとステージングで段階的に測り、プロダクション相当の負荷ではダークローンチ(結果を公開せずに並走評価)で影響を記録する、という流れが安全です。
// tools/bench.ts
import {performance} from 'node:perf_hooks';
import {calcPrice} from '../src/domain/pricing';
async function main() {
const cases = Array.from({length: 1000}, (_, i) => ({sku: `sku-${i}`, plan: 'pro' as const}));
const t0 = performance.now();
for (const c of cases) await calcPrice(c);
const t1 = performance.now();
console.log(`avg=${((t1-t0)/cases.length).toFixed(2)}ms`);
}
main().catch(e => {console.error(e); process.exit(1);});
ステージングの一例として、料金APIのp95は180msから120msへ、ガーベジコレクション(GC)時間は約15%減という測定結果が得られました。重要なのは、削除が行数の削減に留まらず、遅延と失敗確率の低下として顧客体験に還元される点です。
安全に大規模削除を進める技術
削除の本質は、正しさの境界を見つけてずらすことです。私は「純粋なコアと命令的なシェル」を意識して、ドメイン関数からI/Oを剥がし、周辺に寄せます。すると、テストは速く堅くなり、安心して切り落とせる。堅牢性を担保するため、プロパティベーステスト(仕様を例ではなく性質で検証する手法)を導入します。不変条件で守るやり方です。
// tests/pricing.properties.test.ts
import {describe, it} from '@jest/globals';
import fc from 'fast-check';
import {calcPrice} from '../src/domain/pricing';
describe('pricing properties', () => {
it('non-negative total and monotonic by discount', async () => {
await fc.assert(fc.asyncProperty(
fc.record({
sku: fc.string(),
plan: fc.constantFrom('basic','pro','ent'),
promo: fc.option(fc.constant('BLACKFRIDAY')),
country: fc.option(fc.constant('JP'))
}),
async (input) => {
const out = await calcPrice(input as any);
if (out.total < 0) throw new Error('negative');
}
), {numRuns: 200});
});
});
サブシステム削除の足場として、観測とアラートも作り直します。可視化できないものは安全に壊せません。OpenTelemetryを用いて論理的なスパン(処理の区間)を一貫して付け、削除の影響と改善幅をメトリクスに結びます。
// infra/telemetry.ts
import {diag, DiagConsoleLogger, DiagLogLevel} from '@opentelemetry/api';
import {NodeTracerProvider} from '@opentelemetry/sdk-trace-node';
import {SimpleSpanProcessor} from '@opentelemetry/sdk-trace-base';
import {OTLPTraceExporter} from '@opentelemetry/exporter-trace-otlp-http';
diag.setLogger(new DiagConsoleLogger(), DiagLogLevel.ERROR);
const provider = new NodeTracerProvider();
provider.addSpanProcessor(new SimpleSpanProcessor(new OTLPTraceExporter({url: process.env.OTLP_URL})));
provider.register();
export const tracer = provider.getTracer('app');
// usage
// const span = tracer.startSpan('pricing.calc');
// try { await calc(); } finally { span.end(); }
また、古いフラグや分岐が温存されるリスクはCIで塞ぎます。90日以上未更新の機能フラグにビルド失敗を与え、削除の未着手を可視化する。こうすると「いつか消す」が「今すぐ直す」に変わります。
// tools/check-stale-flags.ts
import {readFileSync} from 'node:fs';
import path from 'node:path';
type Flag = {name: string; updatedAt: string};
const flags: Flag[] = JSON.parse(readFileSync(path.join(__dirname, '../config/flags.json'),'utf8'));
const now = Date.now();
const stale = flags.filter(f => now - new Date(f.updatedAt).getTime() > 1000*60*60*24*90);
if (stale.length) {
console.error(`stale flags: ${stale.map(s => s.name).join(', ')}`);
process.exit(1);
}
安全を支える順序も重要です。まず読みやすさを上げる小さな抽出から着手し、可視化とテストでベースラインを固定、次にI/Oの境界を後退させ、最後に機能フラグの廃止とデッドコードの削除に進む。この順にすることで、毎日のデプロイの中に削除を溶かし込み、削除をイベントではなく習慣にできます。
削除がもたらすビジネス効果を測り切る
削除の価値は、速度・品質・安定性の改善として現れます。評価軸としては、DORAメトリクス(デプロイ頻度・変更のリードタイム・変更失敗率・サービス復旧時間)が有効で、チームの変化を定量で追えます⁴。機能単位ではリードタイムとコードレビュー滞留時間、システム単位ではp95・p99のレイテンシとエラー率、組織単位では運用アラートのノイズ率とMTTRを追う、といったレイヤ別の見方が有効です。削除施策の期間に、デプロイ頻度が向上し、変更失敗率やMTTRが低下するケースは少なくありません。サンプルでは、レビューの待ち時間短縮によりPRリードタイムが2〜3割改善することも確認できます。
ROIの観点では、削除に投じた開発時間を、運用削減時間と売上影響で回収できているかをモデル化します。Stripeの調査が示すように生産性は待ち時間に敏感です²。PRリードタイムが20〜30%短縮すると、機能の市場投入が早まり、ABテストの学習サイクルが速く回り始めます。削除は開発速度を上げる最短経路であり、売上の学習速度を上げるレバーになり得ます。また、近年の開発者体験(DX)報告でも、DXを優先する組織ほど開発者のパフォーマンス向上が示唆されています⁵。関係者に説明するには、削除に伴うレイテンシ改善量とコンバージョンの弾性値を掛け合わせた概算利益をダッシュボードで可視化し、投資判断に耐える形に整えるのが有効です。
削除と同時に、将来の増加にブレーキを掛ける仕組みも導入します。新規コードは必ずドメイン境界で純粋関数に閉じ込め、インフラやI/Oはアダプタに集約する方針をコードレビューに明文化。トレーサビリティとオブザーバビリティの標準化により、次の削除がいつでも可能な状態を保ちます。削除の価値は一過性ではなく、継続的に複雑性のスロープを緩くする力として効き続けます。
まとめ:削除を恐れず、価値に集中する
行数は資産ではありません。価値は、顧客が体験する速さと正確さ、そして組織が変更できる自由度の中にあります。1万行削除という極端に見える取り組みも、テストと観測で挙動を固定し、I/Oを境界に押し出し、小さな削除を積み上げることで、安全に日常化できます。もし今、あなたのチームが遅さや不具合の増加を「忙しさ」で覆い隠しているなら、まず挙動を固定するテストと観測を用意し、目に見える小さな削除から始めてみてください。数週間後、p95のグラフとPRの滞留時間が、正しい方向へじわりと傾き始めるはずです。
コードの価値は、書いた瞬間ではなく、削ってもなお残る意思決定の純度で決まります。次に削除できる10行はどこか。今日のデプロイに、ひとかけらの軽さを混ぜ込んでいきましょう。
参考文献
- ZDNet Japan. IT部門の予算の約70%が既存インフラの保守に費やされる(記事). https://japan.zdnet.com/article/20413690/
- Lightspeed Venture Partners. The Developer Productivity Manifesto Part 3: Leaving Software on the Table. https://medium.com/lightspeed-venture-partners/the-developer-productivity-manifesto-part-3-leaving-software-on-the-table-fd2720c85b65
- DX. Lines of Code: Why LOC is a poor measure and how complexity grows with size. https://getdx.com/blog/lines-of-code/
- Open Practice Library. Accelerate metrics: software delivery performance measurement. https://openpracticelibrary.com/blog/accelerate-metrics-software-delivery-performance-measurement/
- Atlassian. Developer Experience Report 2024. https://www.atlassian.com/blog/developer/developer-experience-report-2024