技術ブログ

AIにコードを書かせる前に言語化すること


「このコード、もうちょっと綺麗に書いて」

GitHub CopilotやClaude Codeを使っていて、そう指示したことはないだろうか。ぼくは何度もある。そして、何度も失望してきた。

AIは指示に従おうとする。けれども「綺麗に」の定義がぼくの頭の中にしかないなら、AIがそれを再現できるはずがない。「もっと読みやすく」「保守性を上げて」「設計を改善して」。これらは全て、曖昧な指示だ。

ここで気づいたことがある。AIに良いコードを書かせたいなら、まずぼく自身が「良いコード」を言語化できていなければならない。

この記事では、ぼくが生涯をかけて言語化してきた「メンテナンスしやすいコードとは何か」について書く。これはClaude Code用のSkillとしてGitHubで公開しているリファクタリングルールだ。単なるルールの羅列ではなく、なぜそのルールにしたのか、どんな課題を解決するのかを、できる限り詳しく伝えたい。


「良いコード」は合意できない

そもそも、「良いコード」とは何だろうか。

エンジニアなら一度は議論したことがあるはずだ。コードレビューで「ここはこう書くべきでは」と指摘する。相手は「いや、これでいいと思う」と返す。どちらが正しいのか、明確な基準がない。

1999年、Martin Fowlerが『Refactoring』を書いた。2004年にはRobert C. Martinが『Clean Code』を書いた。1994年にはGang of Fourが『Design Patterns』を出版している。先人たちは「良いコード」を言語化しようとしてきた。

しかし、これらには共通の課題がある。原則は示されるが、具体的にどう適用するかは読者の解釈に委ねられる。

「単一責任の原則に従いましょう」と言われても、何が「単一」かは人によって違う。あるエンジニアは「この関数は一つのことしかしていない」と言い、別のエンジニアは「いや、二つのことをしている」と言う。

結果として、同じ本を読んだチームでも、コードの書き方はバラバラになる。


言語化しなければ、AIにも伝わらない

AI時代になって、この問題は深刻化した。

生成AIは人間が書いた膨大なコードを学習している。その中には、良いコードも悪いコードも混在している。AIは統計的に「よくあるパターン」を出力するが、それが「良いパターン」とは限らない。

AIに「良いコードを書いて」と頼んでも、AIは何をもって「良い」とするか分からない。だから、ぼくたちが教えなければならない。

しかし、教えるためには言語化が必要だ。

「なんとなく読みやすい」「経験的にこっちの方がいい」では伝わらない。具体的に、明確に、ルールとして示す必要がある。

これがぼくがSkillを作った理由だ。暗黙知を形式知に変え、AIに伝達可能な形にする。そうすれば、AIがルールに従ってコードを生成し、一貫性が保たれる。


クラス設計:オーケストレーションと引数爆発

関数だけで設計すると何が起きるか

まず、コードの基本単位について考えたい。関数だけでビジネスロジックを構成しようとすると、必ず「引数爆発」の問題に直面する。

注文処理を例に考えよう。注文を処理するには、バリデーション、価格計算、在庫確認、決済、通知といった複数のサブタスクが必要だ。関数型のアプローチでは、これらをそれぞれ関数として定義し、オーケストレーション関数から呼び出す。

def validate_order(order: Order, rules: list[ValidationRule]) -> ValidationResult:
    for rule in rules:
        if not rule.is_satisfied(order):
            return ValidationResult.failure(rule.error_message)
    return ValidationResult.success()

def calculate_total(
    items: list[OrderItem],
    tax_rate: Decimal,
    discount_rules: list[DiscountRule],
) -> Money:
    subtotal = sum(item.price * item.quantity for item in items)
    discount = sum(rule.calculate(subtotal) for rule in discount_rules)
    tax = (subtotal - discount) * tax_rate
    return Money(subtotal - discount + tax)

def check_inventory(items: list[OrderItem], inventory: InventoryService) -> bool:
    return all(inventory.is_available(item.product_id, item.quantity) for item in items)

def process_payment(total: Money, method: PaymentMethod, gateway: PaymentGateway) -> PaymentResult:
    return gateway.charge(method, total)

def send_notification(order: Order, customer: Customer, notifier: NotificationService) -> None:
    notifier.send(customer.email, f"Order {order.id} confirmed")

ここまでは綺麗に見える。問題はオーケストレーション関数だ。

def process_order(
    order: Order,
    customer: Customer,
    validation_rules: list[ValidationRule],
    tax_rate: Decimal,
    discount_rules: list[DiscountRule],
    inventory: InventoryService,
    payment_gateway: PaymentGateway,
    notifier: NotificationService,
) -> OrderResult:
    validation = validate_order(order, validation_rules)
    if not validation.is_success:
        return OrderResult.failure(validation.error)

    if not check_inventory(order.items, inventory):
        return OrderResult.failure("Out of stock")

    total = calculate_total(order.items, tax_rate, discount_rules)
    payment = process_payment(total, order.payment_method, payment_gateway)

    if not payment.is_success:
        return OrderResult.failure(payment.error)

    send_notification(order, customer, notifier)
    return OrderResult.success(order.id)

引数が8つ。オーケストレーション関数は、自分自身が使わないパラメータまで受け取っている。validation_rulesvalidate_orderに渡すためだけに存在し、tax_ratediscount_rulescalculate_totalに渡すためだけに存在する。

これが「引数爆発」だ。サブ関数が増えるたびに、オーケストレーション関数の引数も増える。Bartosz Sypytkowskiが指摘するように「これは当然、関数パラメータの爆発につながる」。

クラスはこの問題をどう解決するか

クラスを使えば、この問題は構造的に解消される。

class OrderProcessor:
    def __init__(self, config: OrderProcessorConfig):
        self._validator = OrderValidator(config.validation_rules)
        self._calculator = PriceCalculator(config.tax_rate, config.discount_rules)
        self._inventory = InventoryChecker(config.inventory_service)
        self._payment = PaymentProcessor(config.payment_gateway)
        self._notifier = OrderNotifier(config.notification_service)

    def process(self, order: Order, customer: Customer) -> OrderResult:
        validation = self._validator.validate(order)
        if not validation.is_success:
            return OrderResult.failure(validation.error)

        if not self._inventory.check(order.items):
            return OrderResult.failure("Out of stock")

        total = self._calculator.calculate(order.items)
        payment = self._payment.process(total, order.payment_method)

        if not payment.is_success:
            return OrderResult.failure(payment.error)

        self._notifier.send(order, customer)
        return OrderResult.success(order.id)

processメソッドの引数は2つだけ。なぜか。

クラスはコンストラクタで依存オブジェクトをインスタンス化して保持できる。関数は呼び出しのたびに全ての依存を渡す必要があるが、クラスは「一度設定すれば保持される」という状態を持てる。

これは単なるシンタックスの違いではない。設計上の本質的な違いだ。

関数のアプローチ: 全てのパラメータが呼び出し階層を「貫通」する。オーケストレーション関数は、自分が直接使わないパラメータも、サブ関数に渡すために受け取らなければならない。

クラスのアプローチ: 依存はコンストラクタで解決される。メソッドは「そのメソッド固有の入力」だけを受け取る。依存グラフとメソッドシグネチャが分離される。

呼び出し側も変わる

この違いは呼び出し側にも波及する。

# 関数アプローチ: 呼び出し側が全てを組み立てる
result = process_order(
    order=order,
    customer=customer,
    validation_rules=config.validation_rules,
    tax_rate=config.tax_rate,
    discount_rules=config.discount_rules,
    inventory=inventory_service,
    payment_gateway=payment_gateway,
    notifier=notification_service,
)

# クラスアプローチ: ProcessorはDIコンテナで解決済み
processor = container.resolve(OrderProcessor)
result = processor.process(order, customer)

クラスアプローチでは、依存の解決はアプリケーション起動時に一度だけ行われる。ビジネスロジックの呼び出し箇所は、依存グラフを意識しなくていい。


完全コンストラクタ:不変条件の保証

不完全なオブジェクトが生む問題

クラスを使うなら、もう一つ重要なルールがある。完全コンストラクタだ。

オブジェクトは、生成された時点で完全に有効な状態でなければならない。「後から設定する」「初期化メソッドを呼ぶ」は許さない。

class User:
    def __init__(self):
        self.name = None
        self.email = None

user = User()
user.name = "田中"
# ここでuserを使う関数を呼ぶと...
send_welcome_email(user)  # emailがNone → 実行時エラー

不完全なオブジェクトが存在できるということは、全ての使用箇所で「有効かどうか」をチェックする必要があるということだ。

def send_welcome_email(user: User):
    if user.email is None:
        raise ValueError("email is required")
    if user.name is None:
        raise ValueError("name is required")
    # やっと本題
    send_email(user.email, f"Welcome, {user.name}!")

このチェックがコードベース全体に散乱する。これは「防御的プログラミング」の悪い例だ。

型で不変条件を表現する

完全コンストラクタなら、オブジェクトの存在自体が有効性の証明になる。

@dataclass(frozen=True)
class User:
    name: str
    email: Email

    def __post_init__(self):
        if not self.name:
            raise ValueError("name must not be empty")

# Userが存在する → nameとemailは必ず有効
def send_welcome_email(user: User):
    send_email(user.email, f"Welcome, {user.name}!")

User型を受け取った時点で、nameemailが有効だと分かる。チェックは不要だ。

これは「契約による設計(Design by Contract)」の考え方だ。コンストラクタが契約を強制し、使用側はその契約を信頼できる。


ポリモーフィズム:変更の局所化

switch文がもたらす「散弾銃手術」

switch文(またはif-elif-else)は、コードを壊れやすくする。理由を具体的に見よう。

class ShippingCostCalculator:
    def calculate(self, method: str, weight: Decimal, distance: Decimal) -> Money:
        if method == "standard":
            return Money(weight * Decimal("10") + distance * Decimal("5"))
        elif method == "express":
            return Money(weight * Decimal("20") + distance * Decimal("10"))
        elif method == "overnight":
            return Money(weight * Decimal("50") + distance * Decimal("25"))
        else:
            raise ValueError(f"Unknown method: {method}")

一見シンプルだ。しかし、配送方法は他の場所でも分岐している。

class DeliveryTimeEstimator:
    def estimate(self, method: str, distance: Decimal) -> timedelta:
        if method == "standard":
            return timedelta(days=5)
        elif method == "express":
            return timedelta(days=2)
        elif method == "overnight":
            return timedelta(days=1)
        else:
            raise ValueError(f"Unknown method: {method}")

class ShippingLabelGenerator:
    def generate(self, method: str, order: Order) -> Label:
        if method == "standard":
            return Label(priority="LOW", carrier="Japan Post")
        elif method == "express":
            return Label(priority="HIGH", carrier="Yamato")
        elif method == "overnight":
            return Label(priority="URGENT", carrier="Sagawa")
        else:
            raise ValueError(f"Unknown method: {method}")

新しい配送方法「same_day」を追加するとき、何が起きるか。3つのクラス全てを修正しなければならない。これが「散弾銃手術(Shotgun Surgery)」だ。一つの変更が、コードベースの複数箇所に影響する。

Jeremy D. Millerは大規模な配送アプリケーションでの経験を語っている。「ルーティングロジックの全てが単一の巨大なswitch文でコーディングされていた。ルーティングロジックの変更は、既存機能を頻繁に壊した。このシステムを担当するチームは、リグレッションテストのコストに大きく足を引っ張られていた」。

ポリモーフィズムは変更を局所化する

ポリモーフィズムを使えば、各配送方法の知識は一箇所に集約される。

class ShippingMethod(Protocol):
    def calculate_cost(self, weight: Decimal, distance: Decimal) -> Money: ...
    def estimate_delivery_time(self, distance: Decimal) -> timedelta: ...
    def get_carrier(self) -> str: ...
    def get_priority(self) -> str: ...

class StandardShipping:
    def calculate_cost(self, weight: Decimal, distance: Decimal) -> Money:
        return Money(weight * Decimal("10") + distance * Decimal("5"))

    def estimate_delivery_time(self, distance: Decimal) -> timedelta:
        return timedelta(days=5)

    def get_carrier(self) -> str:
        return "Japan Post"

    def get_priority(self) -> str:
        return "LOW"

class ExpressShipping:
    def calculate_cost(self, weight: Decimal, distance: Decimal) -> Money:
        return Money(weight * Decimal("20") + distance * Decimal("10"))

    def estimate_delivery_time(self, distance: Decimal) -> timedelta:
        return timedelta(days=2)

    def get_carrier(self) -> str:
        return "Yamato"

    def get_priority(self) -> str:
        return "HIGH"

新しい配送方法を追加するとき、既存のコードは一切変更しない。新しいクラスを追加するだけだ。

class SameDayShipping:
    def calculate_cost(self, weight: Decimal, distance: Decimal) -> Money:
        return Money(weight * Decimal("100") + distance * Decimal("50"))

    def estimate_delivery_time(self, distance: Decimal) -> timedelta:
        return timedelta(hours=6)

    def get_carrier(self) -> str:
        return "Uber Direct"

    def get_priority(self) -> str:
        return "IMMEDIATE"

これがOpen-Closed Principle(開放閉鎖原則)だ。「拡張に対して開いており、修正に対して閉じている」。

Robert C. Martinはこう書いている。「switch文を多態性に置き換えることで、これらの外部依存を断ち切る。どの振る舞いを実行するかの決定は、オブジェクトインスタンスの生成時に一度だけ行われるべきだ」。


継承禁止:壊れやすい基底クラス問題

継承が引き起こす連鎖的破壊

オブジェクト指向といえば継承、というイメージがある。しかし、ぼくは継承を禁止している。

継承には「壊れやすい基底クラス問題(Fragile Base Class Problem)」がある。Wikipediaの定義を引用しよう。「基底クラス(スーパークラス)への変更が、たとえ一見安全に見えるものであっても、それを継承する派生クラス(サブクラス)の機能を損なう可能性がある」。

具体例で見よう。

class BaseRepository:
    def save(self, entity):
        self._validate(entity)
        self._before_save(entity)
        self._persist(entity)
        self._after_save(entity)

    def _validate(self, entity):
        pass

    def _before_save(self, entity):
        pass

    def _persist(self, entity):
        raise NotImplementedError

    def _after_save(self, entity):
        pass

class UserRepository(BaseRepository):
    def __init__(self, db: Database):
        self._db = db

    def _persist(self, entity):
        self._db.insert("users", entity)

    def _after_save(self, entity):
        print(f"User {entity.id} saved")

ある日、基底クラスに監査ログ機能を追加する要件が来た。

class BaseRepository:
    def __init__(self, audit_log: AuditLog):  # 新しい依存
        self._audit_log = audit_log

    def save(self, entity):
        self._validate(entity)
        self._before_save(entity)
        self._persist(entity)
        self._audit_log.record(f"Saved {entity}")  # 新機能
        self._after_save(entity)

UserRepositoryは壊れる。__init__super().__init__を呼んでいないからだ。呼んでいても、audit_logをどこから取得するかという問題が残る。

Joe Armstrongの有名な言葉がある。「あなたはバナナが欲しかっただけなのに、手に入れたのはバナナを持ったゴリラと、ジャングル全体だった」。継承は、欲しい機能だけでなく、基底クラスの全ての実装詳細を引き込む。

Compositionは変更を隔離する

Composition(合成)を使えば、この問題は起きない。

class Notifier(Protocol):
    def notify(self, message: str) -> None: ...

class EmailNotifier:
    def __init__(self, smtp: SmtpClient):
        self._smtp = smtp

    def notify(self, message: str) -> None:
        self._smtp.send(to="admin@example.com", body=message)

class SlackNotifier:
    def __init__(self, slack: SlackClient, channel: str):
        self._slack = slack
        self._channel = channel

    def notify(self, message: str) -> None:
        self._slack.post(channel=self._channel, text=message)

class UserRepository:
    def __init__(self, db: Database, notifier: Notifier):
        self._db = db
        self._notifier = notifier

    def save(self, user: User) -> None:
        self._db.insert("users", user)
        self._notifier.notify(f"User {user.id} saved")

UserRepositoryNotifierインターフェースにのみ依存している。EmailNotifierの実装詳細が変わっても、UserRepositoryには影響しない。

Eric Elliottはこう説明している。「Compositionを使えば、feat1の少し異なる振る舞いが必要になったとき、feat1自体を変更する必要はない。feat1のカスタマイズ版を作り、それを使えばいい。既存のfeat2やfeat4からの継承はそのまま維持される。クラス継承ではこれができない。クラス継承を使うと、既存のクラス分類全体を買い込むことになる」。


Early Return:認知負荷の軽減

人間の短期記憶には限界がある

1956年、心理学者のGeorge A. Millerは「The Magical Number Seven, Plus or Minus Two」という論文を発表した。人間の短期記憶は7±2個の項目しか保持できないという研究だ。

この限界はコードの読解にも適用される。ネストが深いコードは、複数の条件を同時に追跡する必要がある。

def approve_loan(application: LoanApplication) -> ApprovalResult:
    if application.applicant is not None:
        if application.applicant.age >= 20:
            if application.applicant.income >= Decimal("3000000"):
                if application.amount <= application.applicant.income * 5:
                    if application.applicant.credit_score >= 600:
                        if not application.applicant.has_bankruptcy_history:
                            return ApprovalResult.approved(
                                amount=application.amount,
                                rate=calculate_rate(application.applicant.credit_score),
                            )
                        else:
                            return ApprovalResult.rejected("Bankruptcy history")
                    else:
                        return ApprovalResult.rejected("Credit score too low")
                else:
                    return ApprovalResult.rejected("Amount exceeds limit")
            else:
                return ApprovalResult.rejected("Income too low")
        else:
            return ApprovalResult.rejected("Must be 20 or older")
    else:
        return ApprovalResult.rejected("Applicant required")

このコードを読むとき、6段目のネストでは5つの条件を同時に記憶しなければならない。「applicantがnullでなく、年齢が20以上で、収入が300万以上で、希望額が収入の5倍以下で、クレジットスコアが600以上」。これは7±2の限界に近い。

Mike Wolfeは「7つ以上の認知ボールを同時にジャグリングしようとするな」と書いている。「より良いプログラマーになるには、空中に保てるボールの数を増やそうとするのではなく、ソフトウェアのロジックを追うのに必要なボールの数を減らせ」。

Early Returnでフラットに

Early Returnを使えば、各条件は独立して処理される。

def approve_loan(application: LoanApplication) -> ApprovalResult:
    if application.applicant is None:
        return ApprovalResult.rejected("Applicant required")

    applicant = application.applicant

    if applicant.age < 20:
        return ApprovalResult.rejected("Must be 20 or older")

    if applicant.income < Decimal("3000000"):
        return ApprovalResult.rejected("Income too low")

    if application.amount > applicant.income * 5:
        return ApprovalResult.rejected("Amount exceeds limit")

    if applicant.credit_score < 600:
        return ApprovalResult.rejected("Credit score too low")

    if applicant.has_bankruptcy_history:
        return ApprovalResult.rejected("Bankruptcy history")

    return ApprovalResult.approved(
        amount=application.amount,
        rate=calculate_rate(applicant.credit_score),
    )

各ガード節で「この条件なら終わり」と明確に示す。残りのコードは「ここまで来たら、前の条件は全てクリアしている」と分かる。

同時に追跡する条件は常に1つだけ。認知負荷は大幅に軽減される。

SonarSourceの「Cognitive Complexity」メトリクスは、ネストに対して明示的にペナルティを課している。「ネストは、コードを理解するために必要な精神的な負担を増加させる。連続した同じ構造の列とは異なり、ネストされたフロー制御構造は追加の認知的要求を生む」。


条件式の明確化:暗黙のルールを名前にする

マジックナンバーは意図を隠す

コードにはしばしばマジックナンバーが現れる。

if customer.total_purchases > 10000 and customer.days_since_registration > 365:
    discount = order.total * Decimal("0.1")

なぜ10000なのか。なぜ365なのか。なぜ10%なのか。これは「ロイヤル顧客割引」というビジネスルールだが、コードからはそれが読み取れない。

ルールクラスでビジネスロジックを明示する

ルールをクラスとして抽出すれば、意図が名前に現れる。

@dataclass(frozen=True)
class LoyalCustomerDiscountRule:
    _PURCHASE_THRESHOLD = Money(Decimal("10000"), Currency.JPY)
    _MEMBERSHIP_DAYS_THRESHOLD = 365
    _DISCOUNT_RATE = Decimal("0.1")

    def is_applicable(self, customer: Customer) -> bool:
        has_sufficient_purchases = customer.total_purchases >= self._PURCHASE_THRESHOLD
        is_long_term_member = customer.days_since_registration >= self._MEMBERSHIP_DAYS_THRESHOLD
        return has_sufficient_purchases and is_long_term_member

    def calculate_discount(self, order_total: Money) -> Money:
        return order_total * self._DISCOUNT_RATE

呼び出し側はルールの詳細を知らなくていい。

class DiscountCalculator:
    def __init__(self, rules: list[DiscountRule]):
        self._rules = rules

    def calculate(self, customer: Customer, order_total: Money) -> Money:
        total_discount = Money.zero()
        for rule in self._rules:
            if rule.is_applicable(customer):
                total_discount += rule.calculate_discount(order_total)
        return total_discount

このアプローチには三つのメリットがある。

1. 変更が局所化される。 閾値を10000から15000に変更するとき、LoyalCustomerDiscountRuleだけを修正すればいい。

2. テストが書きやすい。 ルールを単体でテストできる。

def test_loyal_customer_discount_applies_to_qualifying_customer():
    rule = LoyalCustomerDiscountRule()
    customer = Customer(
        total_purchases=Money(Decimal("50000"), Currency.JPY),
        days_since_registration=400,
    )
    assert rule.is_applicable(customer) is True

3. ルールの追加が容易。 新しい割引ルールを追加するとき、新しいクラスを作るだけでいい。既存のコードは変更しない。


関数の一貫性:Calculator / Executor / Orchestrator

副作用を分離する理由

関数は三種類に分類できる。一つの関数は、いずれか一種類の責務のみを持たせよ。

種類責務特徴
Calculator純粋な計算入力のみに依存、副作用なし、参照透過
Executor副作用の実行外部状態を変更、戻り値は成否のみ
Orchestrator調整Calculator/Executorを組み合わせる

なぜ分けるのか。

Calculatorは数学的に正しさを証明できる。 同じ入力には同じ出力。外部状態に依存しない。テストは入力と出力を検証するだけでいい。

class TaxCalculator:
    def __init__(self, rate: Decimal):
        self._rate = rate

    def calculate(self, amount: Money) -> Money:
        return amount * self._rate

def test_tax_calculator():
    calc = TaxCalculator(Decimal("0.1"))
    assert calc.calculate(Money(Decimal("1000"))) == Money(Decimal("100"))

Executorは副作用が明示される。 「何が変更されるか」がシグネチャから分かる。

class OrderRepository:
    def save(self, order: Order) -> None:  # 戻り値なし = 副作用目的
        self._db.insert("orders", order.to_dict())

Orchestratorは全体の流れを示す。 ビジネスプロセスの「何を、どの順序で」が一目で分かる。

class OrderProcessor:
    def process(self, order: Order, customer: Customer) -> OrderResult:
        # Calculator
        total = self._price_calculator.calculate(order.items)
        tax = self._tax_calculator.calculate(total)
        final_total = total + tax

        # Executor
        order_with_total = order.with_total(final_total)
        self._repository.save(order_with_total)
        self._notifier.send(customer, order_with_total)

        return OrderResult.success(order.id)

混ぜると何が起きるか。

def process_order(order: Order, db: Database, smtp: SmtpClient) -> Money:
    total = sum(item.price for item in order.items)  # 計算
    db.insert("orders", order)  # 副作用
    smtp.send(order.customer.email, "Order confirmed")  # 副作用
    tax = total * Decimal("0.1")  # 計算
    return total + tax  # 計算

このコードをテストするには、DBとSMTPのモックが必要だ。しかも、計算ロジックが正しいかどうかと、副作用が正しく実行されるかどうかが、同じテストで検証される。責任が混在している。


型設計:コンパイラを味方につける

プリミティブ型は型安全性を破壊する

def transfer(from_account: str, to_account: str, amount: float, memo: str):
    ...

transfer("ACC001", "ACC002", 1000.0, "Payment")
transfer("Payment", "ACC002", 1000.0, "ACC001")  # 引数の順番を間違えた。動く。

全ての引数がstrfloatなので、順番を間違えてもコンパイルエラーにならない。実行時に、誤ったデータがDBに書き込まれる。

値オブジェクトで型を味方につける

@dataclass(frozen=True)
class AccountId:
    value: str

    def __post_init__(self):
        if not self.value.startswith("ACC"):
            raise ValueError(f"Invalid account ID format: {self.value}")

@dataclass(frozen=True)
class Money:
    amount: Decimal
    currency: Currency

    def __post_init__(self):
        if self.amount < 0:
            raise ValueError("Amount must be non-negative")

    def __add__(self, other: "Money") -> "Money":
        if self.currency != other.currency:
            raise ValueError(f"Cannot add {self.currency} and {other.currency}")
        return Money(self.amount + other.amount, self.currency)

@dataclass(frozen=True)
class Memo:
    value: str

    def __post_init__(self):
        if len(self.value) > 100:
            raise ValueError("Memo must be 100 characters or less")

def transfer(from_account: AccountId, to_account: AccountId, amount: Money, memo: Memo):
    ...

引数の順番を間違えれば、型エラーになる。"Payment"AccountIdではないので、コンストラクタで弾かれる。

さらに、値オブジェクトはドメインの語彙をコードに導入する。strではなくAccountIdfloatではなくMoney。コードがビジネスの言葉で語られる。


ディレクトリ構造:叫ぶアーキテクチャ

技術レイヤーは「何のシステムか」を隠す

多くのプロジェクトは、技術レイヤーでディレクトリを分けている。

src/
├── controllers/
├── services/
├── repositories/
└── models/

Robert C. Martinはこう問いかけている。「あなたのアプリケーションのアーキテクチャは何を叫んでいるか?トップレベルのディレクトリ構造を見たとき、それは『ヘルスケアシステム』『会計システム』『在庫管理システム』と叫んでいるか?それとも『Rails』『Spring/Hibernate』『ASP』と叫んでいるか?」

技術レイヤーで分けたディレクトリは、フレームワークの名前を叫んでいる。何のビジネスを扱うシステムかは分からない。

概念ベースの構造は意図を叫ぶ

src/
├── ordering/
│   ├── order.py
│   ├── order_repository.py
│   ├── order_processor.py
│   └── tests/
├── customers/
│   ├── customer.py
│   ├── customer_repository.py
│   └── tests/
├── payments/
│   └── ...
└── shipping/
    └── ...

これを見れば、ECサイトだと一目で分かる。これがScreaming Architecture(叫ぶアーキテクチャ)だ。

Martin は続けている。「良いアーキテクチャがユースケースを中心に構築されるのは、アーキテクトがフレームワーク、ツール、環境にコミットすることなく、ユースケースをサポートする構造を安全に記述できるようにするためだ」。

関連するコードが近くに配置される利点もある。「注文」の機能を理解するには、ordering/を見ればいい。4つのディレクトリを横断する必要はない。これがコロケーション(関連するものを近くに置く)の原則だ。


反論への応答

「過度な抽象化では?」

「小さなクラスを大量に作ると、かえって全体が把握しにくくなる」という懸念があるだろう。

確かにその懸念はある。しかし、巨大なクラスが「把握しやすい」わけではない。1000行のクラスを読むのと、50行のクラスを20個読むのと、どちらが理解しやすいか。

問題は「適切な粒度」であり、それは単一責任の原則で判断する。1つのクラスが1つの理由で変更されるなら、その粒度は適切だ。

また、クラス数が多くても、ディレクトリ構造(概念ベースのMECE Tree)で整理すれば、探しやすい。「たくさんある」ことと「見つけにくい」ことは別問題だ。

「パフォーマンスが犠牲になる」

オブジェクト生成のオーバーヘッドを気にする人もいるだろう。

「まず正しく書き、計測して問題があれば最適化する」が原則だ。多くの場合、ボトルネックは別のところにある。DBクエリ、ネットワーク通信、アルゴリズムの計算量。オブジェクト生成のコストは、これらに比べれば微小だ。

ただし、ホットパス(10万回ループなど)では注意が必要。その場合はインスタンス再利用などの最適化を検討する。しかし、最初から最適化を優先してコードを複雑にするのは本末転倒だ。

「既存コードには適用できない」

レガシーコードベースでは、これらのルールを適用するのは現実的ではない、という意見もあるだろう。

その通りだ。だから「新規コードに適用、変更箇所のみリファクタ」という方針を明記している。ボーイスカウトルール(来たときより美しく)の考え方だ。

全てを一度に変えようとすると破綻する。しかし、新しく書くコードでルールに従い、変更するコードで少しずつ改善すれば、時間とともにコードベースは良くなっていく。


導入の最初の一歩

いきなり全てを変えようとしないこと。以下の順序を推奨する。

  1. まずEarly Returnから始める — 認知負荷の軽減効果がすぐに実感でき、既存コードへの影響が少ない

  2. 次に値オブジェクトを導入 — 新規で追加する型から始める。既存のstrを置き換えるのではなく、新しい概念に型を与える

  3. その後でPolymorphismに挑戦 — 新規機能追加時に、switch文ではなくクラス階層で設計する

チームで導入するなら、週1回のルール輪読会がおすすめだ。全員が同じ理解を持つことで、コードレビューの観点が揃う。


まとめ

AIにコードを書かせる時代になった。しかし、AIに「良いコードを書いて」と頼んでも、ぼくたちが「良い」の定義を持っていなければ、評価も改善もできない。

この記事で紹介したルールは、ぼくが生涯をかけて言語化してきた「メンテナンスしやすいコードとは何か」の答えだ。

  • クラスはオーケストレーションの引数爆発を解決する — 依存はコンストラクタで一度だけ解決され、メソッドの引数はそのメソッド固有の入力だけになる

  • 完全コンストラクタは不変条件を型で保証する — オブジェクトの存在自体が有効性の証明になる

  • ポリモーフィズムは変更を局所化する — 新しい種類を追加するとき、既存コードを修正しない

  • Compositionは壊れやすい基底クラス問題を回避する — インターフェースへの依存は実装詳細から隔離する

  • Early Returnは認知負荷を軽減する — 同時に追跡する条件を最小化する

  • ルールクラスはビジネスロジックを明示する — 暗黙の条件に名前を与え、テスト可能にする

  • Calculator/Executor/Orchestratorは副作用を分離する — 純粋な計算と状態変更を混ぜない

  • 値オブジェクトはコンパイラを味方につける — 型エラーでバグを実行前に検出する

  • 概念ベースのディレクトリ構造はシステムの意図を叫ぶ — フレームワークではなくビジネスドメインを表現する

これらは新しい発明ではない。SOLID原則の具体化であり、先人たちの知恵の現代的な適用だ。

この記事で解説したルールは、GitHubでClaude Code用のSkillとして公開している。自分のプロジェクトに導入したい方は、ぜひ参照してほしい。

言語化することに意味がある。言語化して初めて、AIに教えられる。チームで共有できる。一貫性を保てる。

コードは書く時間より読まれる時間の方が長い。未来の誰かが読むコードを、理解しやすいものにする。その「誰か」には、数ヶ月後の自分も含まれている。

AIに任せる前に、まず言語化しよう。それがぼくたちの仕事だ。