📑

Claude Codeに自身の開発思想を憑依させる

に公開

はじめに

こんにちは、ログラスの小林です。
最近の開発では、私も世間の流れに乗り、コードはAIアシスタント(主にClaude)に書いてもらっています。
このアシスタントの出現により高速なアウトプットが可能になりましたが、そのままプロダクションに導入できるようなコードが一発で出てくることは稀で、結局は手直しすることが多いのが実情です。
なので、私が手で実装しているときや、Claude Codeに追加指示を与えるときに考えている事を言語化して、自分好みのコードを書いてもらおうという取り組みです。
大前提として、本記事にはプロジェクトでのルールや個人的な思想も含まれますので、あらかじめご了承ください。


1. RDB集約の外に外部キーを貼らない

RDBの集約の外に対して、外部キーを貼らないようにしています。

理由は単純で、ロックの範囲が意図せず拡大してアプリケーションのパフォーマンスに影響が出たり、マイグレーションが長時間に及んだりすることがあるからです。
また、PostgreSQLでは外部キーにインデックスが自動で作成されるわけではないため、外部キーによるスキャンが発生した際に性能が劣化する可能性もあります。

「ではインデックスを貼れば良いのでは?」となりますが、インデックスは更新処理の性能を劣化させる要因にもなるため、むやみに増やすべきではないと考えています。

余談ですが、同じ集約内であればON DELETE CASCADEも利用してもOKとしています。外部キーの扱い方に関しては、以下の記事が大変参考になります。
https://zenn.dev/praha/articles/2667cbb1ab7233

「集約ルートのテーブル削除は即時実行し、関連データはバッチで非同期に削除する」というアプローチも有効ですが、毎回バッチを作成する手間を考慮し、私たちのチームではこのようなルールにしています。


2. created_atupdated_at は絶対に入れる

これは必須ルールとして、すべてのテーブルに created_atupdated_attimestamp with time zone 型で定義します。
障害調査のとき、この情報があるだけで本当に助かります。
意外と忘れがちなので、CIなどでテーブル定義をチェックする仕組みを導入したいものです。
ちなみに、ただカラムを設置するだけでなく、生成タイミングをチーム内で統一しておくと、調査がよりスムーズになります。
方法としては、contextに共通の日時を持たせる、ORMのフック機能を利用する、DBの機能(トリガーなど)を利用するといった手法があり、どの手法でも良いとは思いますが、チーム内で「これらの日時はいつ設定されるか」という認識が揃っていることが重要です。

SQL (PostgreSQL) の例

-- updated_atを自動更新するためのトリガー関数を作成
CREATE OR REPLACE FUNCTION set_updated_at()
RETURNS TRIGGER AS $$
BEGIN NEW.updated_at = NOW(); RETURN NEW;
END;
$$ LANGUAGE plpgsql;

-- usersテーブルを作成
CREATE TABLE users ( id BIGSERIAL PRIMARY KEY, name VARCHAR(255) NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- usersテーブルにトリガーを設定
CREATE TRIGGER trigger_set_updated_at
BEFORE UPDATE ON users
FOR EACH ROW
EXECUTE FUNCTION set_updated_at();

JPA/Hibernateの例

@MappedSuperclass
abstract class BaseEntity {

    @CreationTimestamp
    @Column(nullable = false, updatable = false)
    lateinit var createdAt: Instant
        private set

    @UpdateTimestamp
    @Column(nullable = false)
    lateinit var updatedAt: Instant
        private set
}

3. フレームワークやライブラリの機能に頼りすぎない

フレームワークは便利な反面、用法・用量を守って使ったほうが良いと考えています。

  • ログラスではメンバーのバックグラウンドが多様なため、パッと見で挙動がわからないコードはキャッチアップコストが高くなる傾向にある
  • 意図しない挙動や、バージョンアップで挙動が変更になった場合の修正コストが高くなるリスクがある

例えばSpring Bootのトランザクションを例に挙げます。
オープンなクラスやメソッドに @Transactional をつけると、その処理はトランザクションの中で実行されます。
@Transactional には propagation というオプションがあり、 REQUIRES_NEW を設定すると、既存のトランザクションを一度サスペンドし、新しくトランザクションを開始するという挙動になります。
フレームワークやライブラリを利用しているにもかかわらず、全く恩恵を享受しないというのは難しいですが、個人的には全くわからない人が読んでも理解できるか?という事を意識したコードにします。
これもプロジェクト毎に共通認知が取れていればよいかと思います。

好ましくない例

@Service
class UserService(
    private val userRepository: UserRepository,
    private val auditLogService: AuditLogService
) {
    @Transactional
    fun createUser(name: String) {
        userRepository.save(User(name))
        // このlogメソッドが別トランザクションで実行されることは、
        // 呼び出し元のコードからは分からない。
        auditLogService.log("User created: $name")
    }
}

@Service
class AuditLogService(
    private val auditLogRepository: AuditLogRepository
) {
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    fun log(message: String) {
        auditLogRepository.save(AuditLog(message))
    }
}

これはフレームワークの知識がないと読めないコードになってしまうため、多少冗長でも手続き的にトランザクションを記述することを好みます。

好ましい例

@Service
class UserService(
    private val userRepository: UserRepository,
    private val auditLogRepository: AuditLogRepository,
    private val transactionManager: PlatformTransactionManager
) {
    fun createUser(name: String) {
        // メインのトランザクションを定義
        val mainTxTemplate = TransactionTemplate(transactionManager)
        mainTxTemplate.execute {
            userRepository.save(User(name))
        }

        // 監査ログ用のトランザクションを、新しいトランザクションで実行することを明示的に定義
        val auditTxTemplate = TransactionTemplate(transactionManager).apply {
            propagationBehavior = TransactionDefinition.PROPAGATION_REQUIRES_NEW
        }
        auditTxTemplate.execute {
            auditLogRepository.save(AuditLog("User created: $name"))
        }
    }
}

4. 副作用を減らす

コードの保守性やテストのしやすさを考えると、副作用は最小限に留めたいです。特に気をつけているのは、ロジックとI/O処理を混在させないことです。
詳しくは単体テストの考え方/使い方の6章に記載がありますが、ロジックは純粋関数として定義し、DBアクセスのような副作用を伴う処理は、そのメソッドに引数として渡すような実装をすることが多いです。
利用している言語にもよりますが、ログラスではJVM系の言語が多いため、例外の扱いは避けられません。
そのため、副作用は最大限に局所化することを意識します。具体的には、サービスを実行する関数を、処理を組み合わせる「オーケストレータ」のように実装します。

以下は、ユーザー作成時にメールアドレスとユーザーコードの重複バリデーションを行い、保存するサービスの例です。

好ましくない例

@Service
class UserServiceBad(private val userRepository: UserRepository) {
    fun createUser(name: String, email: String, userCode: String): User {
        // バリデーションロジックとI/O処理が混在
        if (name.isBlank()) {
            throw IllegalArgumentException("Name is required.")
        }
        if (userRepository.findByEmail(email) != null) {
            throw IllegalArgumentException("Email already exists.")
        }
        if (userRepository.findByUserCode(userCode) != null) {
            throw IllegalArgumentException("User code already exists.")
        }
        val user = User(name, email, userCode)
        return userRepository.save(user)
    }
}

このコードでは、ロジックとI/Oが混在しており、ユニットテストが書きづらくなっています。

好ましい例

以下はResult型を使いつつ、I/Oとロジックを分離するサンプルです。

class ValidationException(val errors: List<String>) : Exception("Validation failed")

class User private constructor(
    val name: String,
    val email: String,
    val userCode: String
) {
    companion object {
        fun create(
            name: String,
            email: String,
            userCode: String,
            findByEmail: (String) -> User?,
            findByUserCode: (String) -> User?
        ): Result<User> {
            val errors = mutableListOf<String>()
            if (findByEmail(email) != null) {
                errors.add("Email already exists.")
            }
            if (findByUserCode(userCode) != null) {
                errors.add("User code already exists.")
            }

            return if (errors.isNotEmpty()) {
                Result.failure(ValidationException(errors))
            } else {
                Result.success(User(name, email, userCode))
            }
        }
    }
}

@Service
class UserService(
    private val userRepository: UserRepository
) {
    fun createUser(name: String, email: String, userCode: String): Result<User> {
        val userResult = User.create(
            name,
            email,
            userCode,
            userRepository::findByEmail,
            userRepository::findByUserCode
        )

        return userResult.fold(
            onSuccess = { validUser -> Result.success(userRepository.save(validUser)) },
            onFailure = { exception -> Result.failure(exception) }
        )
    }
}

こうすることで副作用をサービス層の関数に集約させ、ドメインロジックを純粋に保ち、事故のリスクを減らすことができます。

関数渡しを活用した実装例については、こちらの記事も参考になります。
https://zenn.dev/loglass/articles/2e0fdbf5b0f7a9


5. スコープを意識して、private関数に切り出す

基本的なことかもしれませんが、変数のスコープはできるだけ小さく保つようにしています。
変数の有効期間を短くすることで、意図しない参照の保持によるメモリリークを防ぎやすくなります。
不要になったオブジェクトが速やかにGCの回収対象になりやすくなるなどもあるので、結果としてアプリケーションの安定性に繋がります。
しかし、それ以上にコードの可読性と保守性が向上するメリットの方が大きいです。
スコープが小さい関数は、何が行われているかを理解しやすく、修正も容易になります。


6. インテグレーションテストを必ず書く

ロジックの品質が単体テストで担保できているのであれば、残りは副作用を伴う部分をテストすることで、大部分のテストケースをカバーできます。
外部プロセス(DBなど)と連携するテストをインテグレーションテストと呼びます。
リポジトリ層のテストもこれに含みますが、データ投入などで1ケースあたりの実行時間が長くなりがちです。
そのため、基本的にはサービス層を一気通貫でテストし、主要なユースケースをカバーするようにしています。

@SpringBootTest
@ActiveProfiles("test")
internal class UserServiceIntegrationTest {

    @Autowired
    private lateinit var userService: UserService

    @Autowired
    private lateinit var userRepository: UserRepository

    @Test
    fun `createUserは新しいユーザーをDBに正しく保存できること`() {
        // Arrange
        val name = "John Doe"
        val email = "john.doe@example.com"
        val code = "john.doe.test"

        // Act
        val result = userService.createUser(name, email, code)

        // Assert
        assertThat(result.isSuccess).isTrue()
        val foundUser = userRepository.findByCode(code)
        assertThat(foundUser).isNotNull
        foundUser?.let {
            assertThat(it.name).isEqualTo(name)
            assertThat(it.userCode).isEqualTo(code)
        }
    }
}

7. モックをなるべく使わない

以上のことを忠実に守って実装すると、mockspyを使う機会はほとんどありません。自前で用意できない外部プロセスや、タイミングによって不安定になってしまう非同期処理などに限定して利用します。

最後に

CLAUDE.mdを作ってみます。

あなたはアプリケーション開発を行うシニアソフトウェアエンジニアです。
これは私の開発思想とコーディングスタイルをまとめたものです。
コードを生成する際は、常に以下の7つのルールを厳守してください。
これらのルールは、コードの保守性、可読性、安定性を最大限に高めるためのものです。

# DDLの記載方法
- 集約ルートに紐づくテーブルにのみ外部キーを定義し、delete on cascadeを付与してください
- created_at と updated_atはカラムとして必ず保持するようにし、timestamp with time zoneで定義してください

# 実装時
- 実装計画立案時、ライブラリを導入する場合は導入可否を質問してください
- ビジネスロジックは専用の関数を作成し、実装します。関数は純粋関数として定義します
- ビジネスロジック内でデータ取得が必要になる場合、データを取得する関数を引数で渡すようにします

fun create(
    email: String,
    findByEmail: (String) -> User?  // I/O処理を関数として受け取る
): Result<User, CreateError>

# テスト
- オープンなビジネスロジックに対しては必ずユニットテストを実装してください
- ロジックを実装する前に、期待する振る舞いをテストとして実装してください
- ユニットテストはgiven, when, then方式で実装します
- すでに存在するコードを編集する時、対象の関数に対してテストが足りているかをチェックし、足りていない場合はテストを追加で実装してください
- サービスに対して、DBと接続するテスト必ず1つ以上実装してください
- DB以外の外部プロセスが必要なサービスをテストする場合のみモックを利用してください

Loglassではリポジトリ内にCLAUDE.mdが存在しますが、試しに上記をアドオンで食わせて実装を走らせたところ、かなり自分好みのコードが生成されるようになりました。

特にIOとロジックの分離に関してはとても精度が良くなったように感じます。

まだ精度に改善の余地はありそうなので、他にもバッチの書き方やKotlin独自の指示などもアウトプットして、CLAUDE.mdを育てていこうと思いました。

GitHubで編集を提案
株式会社ログラス テックブログ

Discussion