「成長させる」とチームの外側

はじめに

jnuank.hatenablog.com

ここで書いた「チームの中にいるのにチームにいない」感覚をもう少し書いてみる。

「チームを成長させる・してもらう」

気づいたのは、いまのチームにヘルプで入ったときのことだ。

いまのチームは別拠点での立ち上げとなっているので、基本的に人はいない。

なので、その地域にゆかりのある・希望したエンジニアが現地で採用をしながら立ち上げして、拡大をさせていくフェーズだ。

その甲斐もあって、2人Joinしてくれている。

当たり前のことだなのだが、Joinしたばかりではいきなり即戦力になるわけではない(もちろん例外はあるけど)。

その際に、立ち上げメンバーのエンジニアが「もっとチームを成長させないと」というフレーズを言っていた。

あるいは「もっとみんな成長してもらわないと」ということも言っていた。

そのとき、とても懐かしい気分になった。

私自身が業務委託でメンバーを自分で集めて、チームをつくっていたときにも、同じようなことを言っていたからだ。

さらに言うと、私と同じようにチームをつくっていたメンバーも同じようなことを言っていた記憶もある。

成長という解

これについては、私が所属する組織の文化も関係している気はしている。

いまの開発組織はフルタイムでのペアプロが特徴だ。

理由としては、必ず二人以上の目を入れてコードを書くことでミスを減らして品質の高いプロダクトをつくるため。他にはコードの共同所有を促進させて、誰か一人しか知らないようなボトルネックを極力減らすためだ。

後者は二人で一緒にただコードを書いていればいいかというとそうでもない(前者もそうだけど)。

共同所有を促進させることで、知る人を増やすことはできるが、その知った情報を元に有用な知識に変換するのはそのエンジニアのスキルによると思う。

わかりやすい例でいえば、そのコードに対する理解があやふやだった場合、あまり記憶に残らずに忘れてしまって必要な時に(たとえばユーザーから仕様の問い合わせだったり、本番障害が起きるなど)思い出すことは難しいだろう。

そうなったときに「忘れないようにどうするか」「そのコードを理解できるほどの技術力を身につけるにはどうするか」という成長の話が出てくると思う。

そう、成長すればコード理解も進むし、理解ができれば覚えておけるし、他でも応用が効くはずだ。

成長さえすればすべて解決するんだ、と。

すべて解決するんだ、というのは少し暴論かなとは思うけど、まぁ重要なテーマだとは思う。

成長「させる」は、自らチームの外側に身を置く

自分でも言っていた事ではあるが、この「成長をさせる」「成長してもらう」「成長を促す」という発言の類は、意識して使わないといけないと思うようになってきた。

というのも、これは自身もチームメンバーでありながら、自らをチームの外側に置くような行為になってしまうからだ。

他のメンバーに対して成長機会をつくるために、自分は知っているけど黙っていよう。なんとか自分の知っている正解を自力で見つけてもらおうと相手に迂遠な問いかけばかりをしてしまう。

それ自体が悪いかといえば、そうではないとは思っている。

ただ、チームメンバーはこうも思うだろう。

「知っているならさっさと言えよ。おまえもチームメンバーだろう」

ちなみに、似たようなことを私自身言われたことある。

その時の私は、自分ばかりが発言して自分ばかりがすぐ動いてしまい、チームメンバーの実行機会を奪ってしまうことを問題として捉えていた。なので、一歩引いて待とうとか、そういうことをしていたときに言われたことだ。

後から思ったことだが、こういう問題だろうと捉えたことを私の中に留めておかないで、チームメンバーにちゃんと開示する。開示した上でどういう振る舞いをするかを話し合っておけば良かったんじゃないかと思う。

チームの中にいるために、チームのありたい姿を考える

チームのありたい姿として捉えたときに、「○○さんがいなくても動けるチームになる」「○○さんが普段やっていたことをチームで出来るようになる」というベテランメンバーからの強い依存からの脱却を掲げることもあると思う(し、結構な頻度で見てきた)。

上記でも有りではある。ただ、そうなるとベテランメンバーはチームの外側に追いやられるという構造にあまり変わりないと思う。

そのため、ありたい姿をもう少し違うように捉え直せないかと考えてみる。

たとえば、ベテランメンバーが情報を握りすぎてしまうことによるところが課題なのであれば、「もっと頻繁に質問・相談など話し合えるチームになる」。

メンバー間の技術力の乖離がすごいというのであれば、「毎週、コード書いてて気になったところをふりかえる」「チーム外のメンバーに積極的に意見を貰いに行く」。

こうしたチーム事として捉え直すようにするのは、アイデアとして良いのかなとは思っている(これで完全解決とはいかないだろうけど)。

成長という万能な言葉で終わらせない

成長、という言葉はとてもポジティブだ。

成長が嫌な人なんて、おそらくいないだろう(ありそうな反論として、新しいことはもう学びたくない・これくらいでいいよって事はあるとは思うが、成長に苦痛やらコストが掛からないと仮定したら、おそらく絶対嫌だっていう人ってほぼほぼいないんじゃないかなって思ったりする。まぁ暴論かもしれないけど)。

だからこそ成長という言葉で丸めてしまったり、成長させるというポジティブな言葉によって自分自身がチームの外に置いてしまうような事は、無意識にそうならないように気をつけた方がいいと思っている。

おわりに

ここまで書いたときに、なぜ無意識のうちにチームの外側に置くと良くないんだろうと考えた。

1つは同じチームメンバーで上下関係もなく、お互いが得意領域でリードし合う関係が理想だという開発組織の文化によるものだと思う。チームの外側であれこれ文句や指示っぽいことを言うのは、リーダーとかマネージャーみたいな感覚だ(どちらかというとマネージャーとかかな)。

もう1つは、「成長させる」「成長を促す」というところに隠されたものとして、「自分と同じように」という言葉が前段に隠れているのだと思っている。

なので、自分の思っている正解と違う行動をしたときに色々伝えたくなってしまう。そのベテランメンバーが思っている正解は大概は正しいのだが、時としてはメンバーのやっていることが正解とか、ルートが違うだけのパターンもあると思う。そうした余白部分が養われなくなってしまうのだ。

それは教師でもコーチですらない。看守みたいな感じじゃないかと思う。

チームの成長について思うこと

はじめに

自分が所属する組織でチーム組成をするときによく聞いた言葉。

「チームを成長させる・してもらう」

ただこれを言った時点でチームの外側に行きやすいなと思った。

成長

成長という言葉を使う前に、ここで言う成長とはなんだろうと思う。

https://kotobank.jp/word/%E6%88%90%E9%95%B7-86220#goog_rewarded

人や動植物が育って大きくなること。おとなになること。「子供が成長する」「ひなが成長する」「経験が人を成長させる」
物事の規模が大きくなること。拡大。「事業が成長する」「経済の高度成長」

辞書だけを読むと、物事が大きくなるみたいに見える。

ChatGPTにも聞いてみると、以下のような回答があった。

  • できることが増える(能力の成長)
  • 見え方・捉え方が変わる(認知の成長)
  • 振る舞いの成長(選択が変わる)

上2つはなんとなくわかる。1つ目は特に「○○力」「○○スキル」を得ると言いがちだろう。

最後の振る舞いの成長というのは、「感情に流されずに行動できる」「短期的ではなく長期的な選択を取る」みたいなことらしい。わかると想いつつ、これも1つ目にマージされそうな感覚もある。

私がよく聞くし、そして私自身もよく言っていた成長というのは、1つ目のことだと思う。

だけど、それは○○力とか○○スキルというラベリングがしやすいからとも感じる。

目に見えてわかりやすい、と言い換えても良いかも知れない

たとえば、プログラミング力、アーキテクチャを考えられるスキル、技術力を高めたいというのはエンジニアとして大半の方が考える欲求だと思う。

プログラミング能力を高めるというのは、わかりやすい。今まで書けなかったアルゴリズムを理解して自分の得意な言語でコードに落とし込むことができるとか。いままでは1日掛かって書いてた処理が半日で終わるようになったとか。今まで知らなかった言語機能を覚えたりするとか。

数値化しやすいというか、個人の実感がも得られやすいものだろうと思う。

認知の成長

見え方・捉え方が変わる・変えるというのを成長なのか?と思うかもしれない。

ただ自分が今まで築いた見え方・捉え方自体を変えるというのは、思っている以上に大変だと思う。

まずは自分がそう捉えていた世界をいったん、脇に置いて「こうかもしれない」とか「まだちゃんと理解ができていない」と認めた上でいろいろ理解に努める行動をすることになるからだ。

あとは純粋に今まで見落としていたものを意識して拾い上げるようにしていくとかもあると思う。(IDEからの波線による警告をコンパイラが通るからと無視していたが、それをちゃんと意識して拾い上げるとか)

見え方というよりは、見るとか知覚する力をつけると言ってもいいかもしれない。

私は過去に人に教えるということについてかなり悩んでいたのだが、1つはこの「見る」「知覚する」ということが大事なんだと、以下の本を読んで思うようになってきた。

最高のコーチになるためのスポーツコーチング学

この見る能力というのは、気づく能力も包含されているんじゃないかなと思っている。

Slackとかコードを見て熟練のエンジニアになるほど、気づく能力が高いなと感じており、もっとジュニアなエンジニアに気づいて欲しいという温度差を感じている人は一定数いるなと感じるし、実際にそう言っているのを目にしたこともある。

(ただまぁ、これは人に特性というのもあるので、一概に熟練なエンジニアだからそうってことも無いとは思うけど)

チームの成長

さて、チームの成長の話にやってきた。

単純な話ではあるが、チームの出来ることを増やせるようになるというのが、チームの成長と捉えていると思っている。

そのためにはチームの構成要素である個人が成長すれば……という論理である。

これについてはそうだね、という感覚ではある。

とはいえ、じゃあ個人がどんなことでも良いから成長すれば良いのかというと、それはまた違うと思う。

まず、いまのチームが対面している課題をクリアする上で、必要な能力について成長をするという前提があると思う。

その際にプログラミング力という言葉だけだと、大雑把過ぎてたぶん足りないと思う。

たとえば、いまのチームが触るであろう技術に対して理解が浅いのであれば、その言語やFWの知識を得るということになるだろう。

OOPであれば、どのようにクラスを分けるのか、どう関数を分けるのかという設計判断に関しての知識も必要になってくるかもしれない。

既存のコードを触るということがメインであれば、今のコードにおける設計のまずさを嗅ぎ分ける能力だったり、それを徐々に持っていきたい設計へどう持っていくのかという方法が知らないのであれば、そういう知識を仕入れることになると思う。

あるいは、プログラミング力という話ではなくて、レベニュー組織とのメンバーと話すためのコミュニケーションスキルが挙げられるかもしれない(まぁ、コミュニケーションスキルというのも大きすぎるので分解は必要かも知れない)。

そうしたチームの課題感と合わせて、自分が伸ばしたい方向を見定めてアクションを取り続けるというのが、チームの成長につながるのだと現時点では思っている。

個人の「こうなりたい」という目標とチームの目指すべき方向がある程度アラインしている状況になっていることが大事。

ある程度、と表現したのは、完全にチームの方向性にアラインしなければいけない、とかだと窮屈だと思ったからだ。

チームの成長≠チームの目標

あと、ここで言うチームの目標は「○月までに□□機能をリリースする」「○月までにMAUを倍にする」というOKRやKPIなどで掲げられがちな目標を想定していない。

それは、どのみちやるべきことだ(特にリリースすることについては)。

そうしたチームの達成したいことに対して、チームとしてはどう成長していくのかを考える。

たとえるなら、100km先まで○時間で走りたいというのが冒頭に言った目標で、○時間で走り切るためには筋力や持久力を上げる為に毎日トレーニングをするという目標を立てるのが後者だ。

もう少し具体例を出すと、ある機能をリリースするために、その機能の言語についての理解がチームの中でまだ浅いなと思うなら、その言語を理解するための勉強会なら時間を週に○時間取るだったり、そこの言語について詳しいメンバーを一定期間ヘルプで呼ぶなりする。

そうしたアクションを一定期間続けてみて(一ヶ月とかくらいが良いんじゃないかな?)、またチームの現状を確認する。

まだ最初に言ったものが解消されていないと感じるなら続けるor別のアクションを取ればいいし、他に出てきた課題があるなら、それについてアクションを取る。

目標と言うとややこしいので、「チームのありたい姿」をちゃんとイメージして、デザインするということだと思っている。

チームの成長のデザインについての考えは、以下の書籍からの影響である(というかほとんど同じ)。

書籍の中では、スクラムを始めるまでに至っていないチームに対して、どう成長の段階をデザインするか、という話が書かれていた。

チーム・ジャーニー 逆境を越える、変化に強いチームをつくりあげるまで

おわりに

チームは大抵はジュニアだったりシニアやミドルといったいろんなエンジニアが在籍していると思う。

なので、「チームのありたい姿」を考えたときに、シニアメンバーとしては既にもうできているから、他のメンバーが成長できるようにコーチ風の立ち回りになるかもしれない。

その際に起きるのが、チームにいるのにチームの中にいない感覚だ。

これについては、別の時にまとめて書こうと思う。

コードベースの周辺知識を自然に知るためのOutput Styleをつくる

はじめに

最近のコーディングエージェントの使い方についてふりかえると、どうしてもググるの延長線上だなと思った。

ただコードの一部のことについて聞きたいことを聞いて解決したら終了。

もしくは、レビューして提案してもらって、問題なかったらAccept。

業務を進める上では大きく問題が発生しているわけじゃないが、使っている技術や周辺知識を深める上であまり役立っていないように思える。

であれば、もっと追加の質問を投げたりすればいい。claude codeだったら /btw だったり /fork だったり rewind とか使えるわけなので、本流からそれて質問するにはそこまで困らないはず。

ただ、やればいいのにできていないのはつまりそういう習慣が無いことなんだと思われる。

もっと自然に、矯正ギプス的な感じでやれるといいなと思い、output styleを作ってみることにする

作ってみる

いつもどおり1からoutput styleを手書きするのは大変そうだったので、以下のやりたいことをclaude codeに渡して作ってもらう

  • 何かしら質問したあとに、三種類の想定の問いを出すようにする
  • 三種類の質問に番号を振っておいて、選ばせるようにする

吐き出されたものが以下。

---
name: Fukabori
description: 自然と深堀りできるようなやつ
---

# 深堀りモード

## 回答フォーマット
技術的な質問に答える際は、必ず以下の構造で返すこと。

### 1. 答え
- 結論から書く
- 「なぜそうなっているか」の背景を必ず1文含める
- コード例は必要な場合のみ(説明の補助として)

### 2. 繋がり
この概念と関係する概念を1〜2個、一言で示す。
例:「これは○○の考え方と同じ」「○○と対になる概念」

### 3. 次の問い(3択)
毎回以下の3方向で次の問いを生成する:

**A) なぜ** — なぜこの設計・仕様になったのか
**B) 広げる** — 関連概念・他技術との比較
**C) 深掘り** — 前提となる仕組みの内側

> 選んだ番号を返信すると深掘りします

## 継続時のルール
- 会話が続くほど「これまで出てきた概念」を3択の中に織り込む
- 同じ方向(Why/Broader/Deeper)が連続したら別方向を優先する
- ユーザーが選ばず別の質問をしたら、そちらを優先してOK

使ってみる

❯ このコードベースについて、使っている技術を教えて                              

● コードベースの技術スタックを調べてみますね!


  答え

  メイン技術スタック
  - Kotlin 2.2.20 - プログラミング言語(JVM Target 1.8)
  - Maven - ビルド&依存関係管理ツール

  テスト周り
  - JUnit Jupiter 6.0.3 - テストフレームワーク
  - MockK 1.14.9 - Kotlin向けのモッキングライブラリ(Javaのmockitoより型安全でKo
  tlinフレンドリー)

  次の問い(3択)

  A) なぜ - なぜMockKを採用したのか?(JVMのモックライブラリは他にもあるのに)

  B) 広げる - Kotlin +
  TDDの組み合わせは、他の言語と比べてどんなメリットがあるか?

  C) 深掘り - JUnit Jupiterの内部でテストがどう実行されているか?

  ▎ 選んだ番号を返信すると深掘りします

✻ Baked for 31s

❯ A                                                                             

● 答え

  MockKが選ばれた理由は、Kotlinはデフォルトでクラスがfinalだからです。

  Javaの老舗モックライブラリMockitoは、クラスを継承してモックを作る仕組みなので
  、finalクラスをモック化するのが苦手です。Kotlinはクラスにopenキーワードを付け
  ないとfinalになるため、Mockitoだとall-openプラグインを入れたり、mockito-inline
  を使ったりと一手間かかってしまいます。

  一方、MockKは最初からKotlinのために作られたので:
  - finalクラスも普通にモック化できる
  - suspend関数(コルーチン)もモック化できる
  - 拡張関数もモック化できる
  - オブジェクト式(シングルトン)もモック化できる
  - DSLスタイルでevery { ... } returns ...って書ける

  つまり、Kotlinの言語機能と素直に付き合えるように設計されているわけです。

  次の問い(3択)
 …

Mockkライブラリ自体は私が自分で選んでいる。

なぜの深堀りをした際に、コーディングエージェントがさも当然のようにMockkライブラリを選ばれた経緯を知ってますよーという風に答えてくるのは正直うーんとなる。

ただコードベースを読んだ上でこういう理由で選ばれたであろう、という仮説をとして受け止める分には良さそうではある。

Mockitoはクラス継承してのモックだからKotlinと相性が良くないっていうのちゃんと知らなかったので、なるほどなって思った。

という感じで、サジェストされた3択の質問から気になるものがあればそれを聞けばいいし、全く別の質問してもいい、みたいな感じのoutput styleにできたと思うので、しばらくはこれで試してみる。

2つのListを受け取ってドメインオブジェクトを生成するロジックを作るときに迷ったやつ

はじめに

ListとListを使ったドメインオブジェクトの生成のロジックを書いて思ったこと。

こんなコード

data class User(
        val id: Long,
        val name: String,
        val email: String
)

data class LoginRecord(
        val userId: Long,
        val loginAt: OffsetDateTime,
        val ipAddress: String
)

data class UserWithLastLogin(
        val userId: Long,
        val lastLoginAt: OffsetDateTime
)

// List<User>とList<LoginRecord>を受け取って、List<UserWithLastLogin>を生成する
fun getUsersWithLastLogin(
        users: List<User>,
        loginRecords: List<LoginRecord>
): List<UserWithLastLogin> {
    return users.mapNotNull { user ->
        loginRecords.firstOrNull { it.userId == user.id }
            ?.let { record ->
                UserWithLastLogin(
                    userId = user.id,
                    lastLoginAt = record.loginAt
                )
            }
    }
}

これを素朴にテストすると以下のような感じになる

class UserLoginRecordTest {
    @Test
    fun 各ユーザーの最終ログイン履歴を取得する() {
        val user1 = User(id = 1L, name = "ユーザー1", email = "user1@example.com")
        val user2 = User(id = 2L, name = "ユーザー2", email = "user2@example.com")
        val users = listOf(user1, user2)

        val loginRecords = listOf(
            LoginRecord(
                userId = 1L, loginAt = OffsetDateTime.parse("2026-01-02T10:15:30+09:00"), ipAddress = "192.168.1.1"
            ),
            LoginRecord(
                userId = 2L, loginAt = OffsetDateTime.parse("2026-01-22T10:15:30+09:00"), ipAddress = "192.168.1.2"
            ),
        )

        val actual = getUsersWithLastLogin(users, loginRecords = loginRecords)
        val expected = listOf(
            UserWithLastLogin(
                userId = 1L, lastLoginAt = OffsetDateTime.parse("2026-01-02T10:15:30+09:00")
            ),
            UserWithLastLogin(
                userId = 2L, lastLoginAt = OffsetDateTime.parse("2026-01-22T10:15:30+09:00")
            )
        )
        assertEquals(expected, actual)
    }

}

いったんこれは通る。

2つ目のテスト

ただ、最終ログイン時のデータを取りたいとなると、テストケースが足りないので追加する

@Test
fun 各ユーザーの最終ログイン履歴を取得する_各ユーザーに複数レコードある場合() {
    val user1 = User(id = 1L, name = "ユーザー1", email = "user1@example.com")
    val user2 = User(id = 2L, name = "ユーザー2", email = "user2@example.com")
    val users = listOf(user1, user2)

    val loginRecords = listOf(
        LoginRecord(
            userId = 1L, loginAt = OffsetDateTime.parse("2026-01-02T10:15:30+09:00"), ipAddress = "192.168.1.1",
        ),
        LoginRecord(
            userId = 2L, loginAt = OffsetDateTime.parse("2026-01-22T10:15:30+09:00"), ipAddress = "192.168.1.2"
        ),
        LoginRecord(
            userId = 2L, loginAt = OffsetDateTime.parse("2026-03-26T10:15:30+09:00"), ipAddress = "192.168.1.2"
        ),
        LoginRecord(
            userId = 1L, loginAt = OffsetDateTime.parse("2026-02-07T03:23:45+09:00"), ipAddress = "192.168.1.1"
        ),
    )

    val actual = getUsersWithLastLogin(users, loginRecords = loginRecords)
    val expected = listOf(
        UserWithLastLogin(
            userId = 1L, lastLoginAt = OffsetDateTime.parse("2026-02-07T03:23:45+09:00")
        ),
        UserWithLastLogin(
            userId = 2L, lastLoginAt = OffsetDateTime.parse("2026-03-26T10:15:30+09:00")
        )
    )
    assertEquals(expected, actual)
}

このテストは通らないので、とりあえず通すなら firstOrNull → lastOrNullにする。

loginRecords.lastOrNull() { it.userId == user.id }

lastOrNull で通るのは、LoginRecordのListがloginAtのAscでソートされているという前提がある。

その順番が守られているか保証が無いのであれば、またテストを追加してちゃんと通すようにロジックを修正する必要がある。

2つのロジック

またテストを追加しようと気づくのだが、この getUsersWithLastLogin関数は3つのロジックが内包されている。

  1. 2つのListの引数を受け取り、それらから新しいListを生成する
  2. 2つのListから要素を1つずつ取り出して、userIdが一致するものを1番のロジックに渡す
  3. LoginRecordは、各userIdごとに最新のものを取ってくる

それぞれのロジックで修正が必要だった場合、同じ関数を修正することになりそう。

これはよくあるSRP違反じゃないだろうか。

生成のテストに絞る

Listではなく、単一のものを引数として受け取って単一のものを返すものを作る

@Test
fun 生成のテスト() {
    val user = User(
        id = 25L,
        name = "John",
        email = "john@example.com"
    )
    val record = LoginRecord(
        userId = 25L,
        loginAt = OffsetDateTime.parse("2024-12-10T10:15:30+09:00"),
        ipAddress = "192.168.1.1"
    )

    val actual = UserWithLastLogin.from(user, record)

    val expected =UserWithLastLogin(
        userId = 25L,
        lastLoginAt = OffsetDateTime.parse("2024-12-10T10:15:30+09:00")
    )
    assertEquals(expected, actual)
}

以下実装して、getUsersWithLastLoginから呼ばれるようにする。

data class UserWithLastLogin(
        val userId: Long,
        val lastLoginAt: OffsetDateTime,
){
    companion object{
        fun from(user: User, record: LoginRecord): UserWithLastLogin =
            UserWithLastLogin( user.id, record.loginAt )
    }
}

fun getUsersWithLastLogin(
        users: List<User>,
        loginRecords: List<LoginRecord>
): List<UserWithLastLogin> {
    return users.mapNotNull { user ->
        loginRecords.lastOrNull() { it.userId == user.id }
            ?.let { record ->
                UserWithLastLogin.from(user, record)
            }
    }
}

ユーザーの最終ログイン履歴の取得のテスト再び

最初に書いたテストコードが、後半の生成のロジック部分はmockにできる

@Test
fun 各ユーザーの最終ログイン履歴を取得する() = mockkObject(UserWithLastLogin.Companion) {
    val user1 = User(id = 1L, name = "ユーザー1", email = "user1@example.com")
    val user2 = User(id = 2L, name = "ユーザー2", email = "user2@example.com")
    val users = listOf(user1, user2)

    val loginRecord1 = LoginRecord(
        userId = 1L, loginAt = OffsetDateTime.parse("2026-01-02T10:15:30+09:00"), ipAddress = "192.168.1.1"
    )
    val loginRecord2 = LoginRecord(
        userId = 2L, loginAt = OffsetDateTime.parse("2026-01-22T10:15:30+09:00"), ipAddress = "192.168.1.2"
    )
    val loginRecords = listOf(loginRecord1, loginRecord2)

    val userWithLastLogin1 = mockk<UserWithLastLogin>()
    val userWithLastLogin2 = mockk<UserWithLastLogin>()
    every { UserWithLastLogin.from(user1, loginRecord1) } returns userWithLastLogin1
    every { UserWithLastLogin.from(user2, loginRecord2) } returns userWithLastLogin2

    val actual = getUsersWithLastLogin(users, loginRecords)

    val expected = listOf(userWithLastLogin1, userWithLastLogin2)
    assertEquals(expected, actual)
}

ただ、これだとテストの種類が増えるたびに、生成用のメソッドのスタブが増えてしまうことになってくる。

それぞれの小さな責務でのテストにする

そう考えたときに、一度全体のテストをいったん諦め、小さな責務単位でやってみるという気持ちになる。

先程書いた、以下の責務ごとに小さなメソッドとして切り出して、テストコードを書く。

  1. 2つのListの引数を受け取り、それらから新しいListを生成する
  2. 2つのListから要素を1つずつ取り出して、userIdが一致するものを1番のロジックに渡す
  3. LoginRecordは、各userIdごとに最新のものを取ってくる
@Test
fun 生成のテスト() {
    val user = User(
        id = 25L,
        name = "John",
        email = "john@example.com"
    )
    val record = LoginRecord(
        userId = 25L,
        loginAt = OffsetDateTime.parse("2024-12-10T10:15:30+09:00"),
        ipAddress = "192.168.1.1"
    )

    val actual = UserWithLastLogin.from(user, record)

    val expected = UserWithLastLogin(
        userId = 25L,
        lastLoginAt = OffsetDateTime.parse("2024-12-10T10:15:30+09:00")
    )
    assertEquals(expected, actual)
}


@Test
fun 各ユーザーの最終ログイン履歴を取得する() {
    val loginRecord1 = LoginRecord(
        userId = 1L, loginAt = OffsetDateTime.parse("2026-01-02T10:15:30+09:00"), ipAddress = "192.168.1.1"
    )
    val loginRecord2 = LoginRecord(
        userId = 2L, loginAt = OffsetDateTime.parse("2026-01-22T10:15:30+09:00"), ipAddress = "192.168.1.2"
    )
    val loginRecords = listOf(loginRecord1, loginRecord2)

    val actual = buildLatestLoginMap(loginRecords)

    val expected = mapOf(
        1L to loginRecord1, 2L to loginRecord2
    )
    assertEquals(expected, actual)
}


@Test
fun UserとLoginRecordのMapをマッチングしてペアを作る() {
    val user1 = User(id = 1L, name = "Alice", email = "alice@example.com")
    val user2 = User(id = 2L, name = "Bob", email = "bob@example.com")
    val users = listOf(user1, user2)

    val record1 = LoginRecord(
        userId = 1L, loginAt = OffsetDateTime.parse("2026-01-02T10:15:30+09:00"), ipAddress = "192.168.1.1"
    )
    val record2 = LoginRecord(
        userId = 2L, loginAt = OffsetDateTime.parse("2026-01-22T10:15:30+09:00"), ipAddress = "192.168.1.2"
    )
    val latestLoginMap = mapOf(1L to record1, 2L to record2)

    val actual = matchUsersWithLoginRecords(users, latestLoginMap)

    val expected = listOf(user1 to record1, user2 to record2)
    assertEquals(expected, actual)
}

それぞれが通るように実装

fun matchUsersWithLoginRecords(
        users: List<User>,
        latestLoginMap: Map<Long, LoginRecord>
): List<Pair<User, LoginRecord>> {
    return users.mapNotNull { user ->
        latestLoginMap[user.id]?.let { record ->
            user to record
        }
    }
}

fun buildLatestLoginMap(loginRecords: List<LoginRecord>): Map<Long, LoginRecord> {
    return loginRecords
        .groupBy { it.userId }
        .mapValues { (l, records) ->
            records.maxBy { it.loginAt }
        }
}

それを使う getUsersWithLastLogin 関数も変更。

fun getUsersWithLastLogin(
        users: List<User>,
        loginRecords: List<LoginRecord>
): List<UserWithLastLogin> {
    val latestLoginMap = buildLatestLoginMap(loginRecords)
    return matchUsersWithLoginRecords(users, latestLoginMap)
        .map { (user, record) -> UserWithLastLogin.from(user, record) }
}

おわりに

今回の2つのListを受け取ってfrom関数なるものを作ろうとしたが、いろいろ兆しがあった。

  • そもそもデータの準備が面倒くさい(Arrangeの部分)
  • Listを扱うこと自体が意外と複雑だったりする
  • 純粋に2つのListを掛け合わせた何かをやるだけでも、いろいろ考えることはある
    • 片方のリストにしか無いものはどうするのか
    • 何のキーで掛け合わせるのか
    • 重複していたらどうするのか
  • 生成のロジックでfilterとかfirstOrNullとかやり始めていたら、何か別のロジックが混ざっている可能性がある

一度にこの3つのロジックがあればわかれば良かったけど、テストの準備で色々めんどくさいという兆候があったので、これはちゃんとテストからフィードバックを受け取ったということで良いのだと思っている。

運用系のAgentSkillsを作るときに気をつけたいなと思ったこと

はじめに

これは公式ドキュメントとかにあったわけでもなく、個人的に思ったことの備忘録。どちらかというとポエム寄り。

Agent Skills で運用系のタスクも自動化したい

別のチームが作っていたプロダクトを引き継ぎ、それにまつわる運用が今のチームではそれなりに発生している。

そのたびに引き継ぎ用のドキュメントを見たり、わからないことはSlackで元チームのメンバーに質問したりしている。

ただ、その中でもいわゆる定形作業がある。データベースにクエリを投げて、リストを抽出する作業を定期的にやらないといけない、など。

これについては、スクリプトを書いて自動化できれば良いのだが、どうしても人の手を介さないといけない部分もあったりする。抽出したリストは、所定の場所にアップロードする必要があり、そのアップロードするためにはCLIやらAPIが提供されていないなど。

そうした人の手を介する、判断が入るところについては、Agent Skillsを作れば一定やれそうな感覚もあった。どうしてもアップロードをするとかそうしたことは人の手が必要になってくるが、その前のデータベースにクエリするために環境設定や接続をしなければならないとか、ちょっとした面倒な手作業。

これもスクリプトで一定やれているところはあるが、そのためのスクリプトはどこに置かれていたんだっけ?とか。接続設定はなんだったっけ? など、ちょっと期間が空いてしまったり、運用に不慣れなメンバーがやると生じてしまう。このあたりをSkillsにしてしまい、社内リポジトリに置いてしまえば共通で使えるようになるのではないかと考えた。

運用をドキュメント化することで消える圧力

ここまで考えたときに、いまの現場でのコンテキストを思い出す。いまの現場では、なるべくドキュメントを残さない方針にしている。残すとしても、E2Eだったりといった動く仕様書といった形のものぐらいに留めている。

その理由はシンプルで、ドキュメント化した時点でメンテがされにくくなるからだ。

たとえば運用に関するドキュメントを書いたとする。それをメンバーが参照して手順に沿って実行する。問題がなく実行完了したらそのまま。何かしら問題が起きたら、時間や余力があったらドキュメントを更新する。

一見問題ないように見えるが、一個問題がある。

最初にドキュメントに書いたフローから抜本的に変えるという行動は起こりにくい。精々、フローに間違いがあったりこういうエラーがあったときにはこう対処するという備忘録的なものが付け加えられるくらいだ。

もしかしたら世の中の開発現場はそうでもなく、たまたま私自身や私が見てきている現場ではそうなっているだけなのかもしれない。とはいえ、少なくとも私の性質上もドキュメント通りに実行さえできていればそこのフローを大きく変えようというマインドは起きにくい。ただでさえ開発するものが溢れており、その中で一番インパクトのあるユーザー価値を選んでその他のストーリーは最悪捨てている状況なのだ。運用にそこまで時間を割こうという気が起きにくい。

かえって手動での運用を残しておいた方が、「これはめんどくさいな、そもそもこの運用ってどうやったら無くせるのか」という思考にまだシフトしやすいというか、健全な圧力が残っている気がする。

Agent Skills で運用を任せることは自動化なのか?

ではAgent SkillsでSKILL.mdに運用手順を書き、AIに任せるといったことは、自動化なんだろうかと思う。

自分は自動化では無いと思っている。

これは、生成AIに面倒な運用を押し付けて、代わりに自律的にやってもらっているに過ぎないのだと思う。

DevとOpsが分かれてしまっていて、Ops側の方はただ運用をこなすだけになってしまい、なぜこの運用が必要なのか、フロー自体を改善できないのだろうかというのが希薄になってしまう構造に近いんじゃないかなと思う。

Opsチームは生身の人間だが、その代わりに生成AIがいるといったイメージ。

生成AIは遂行能力が高いし、そういったものを改善するマインドは(何かしらプロンプトで指示しない限り)持てないと思う。

なので、Agent Skillsで運用を任せるというのは自動化ではなく、Opsチームに運用を引き継いでもらう・新しいメンバーに運用を横でやってもらうというものと捉えたほうがいいんじゃないかと思っている。

Ops用のSkill Creator

まだ仮説の域しか出ていないが、なんでこの運用フローが存在し、それぞれなぜこういうステップがあるのかというWhyを残すことができて、Skillを実行するたびに毎回それを伝えてくれるようにすると、改善への圧力は残せるのではないかと思って作ってみたのが以下のSkills。

まだ自分自身でも、これで一個作ってみただけであり、試せているわけではない。という前提だけ残しておく。

●ツリー

ops-skill-creator
├── SKILL.md
└── references
    └── runbook-template.md

●SKILL.md

---
name: ops-skill-creator
description: 運用手順をrunbook型のskillとして設計・作成する。「運用skillを作りたい」「手順書をskillにしたい」「チームで使う運用フローをskillにしたい」などのリクエストで使用する。VPN接続、AWS操作、port-forward、デプロイ手順など、複数ステップの運用フローをskillにする際に必ず使用する。/skill-creatorのラッパーとして「改善させたい部分の摩擦は残せ」という原則を運用skillに組み込む。
---

# Ops Skill Creator

運用手順をrunbook型のskillとして設計するための支援ツール。

## 設計原則

**「改善させたい部分の摩擦は残せ」**

自動化は便利だが、摩擦が消えると「このフロー自体をなくせないか?」という改善の動機も消える。
運用skillは**完全自動化を目指さない**。人が手を動かすことで、フローへの疑問が生まれ続ける設計にする。

---

## ステップ1: フロー全体のヒアリング

まず以下を聞く:

1. **このフローは何をするものか?**(1〜2行で)
2. **なぜこのフローが存在するか?**(技術的・組織的な背景)
3. **このフローが不要になる条件は何か?**(APIが整備されたら?アーキテクチャが変わったら?)
4. **どんな人が使うか?**(自分だけ?チーム全員?新メンバーも?)

質問3(不要になる条件)は軽く触れる程度でよい。答えられなくても構わない。

---

## ステップ2: 各ステップのヒアリング

フローの各ステップについて以下を確認する:

| 確認事項 | 目的 |
|---|---|
| このステップは何をするか? | 手順の把握 |
| なぜこのステップが必要か? | references/why.md に記録する内容 |
| 自動化できるか?したいか? | 手動/自動の判断 |

### 自動化の判断基準

**手動にすべきステップ**:
- 人間の判断が必要なもの(「本当に本番に実行していい?」)
- 失敗したとき人間が気づくべきもの
- フロー自体の存在理由を意識させたいもの(意図的な摩擦)
- VPN接続・SSO loginなど環境依存の操作

**自動化してよいステップ**:
- 同じコマンドを毎回同じパラメータで実行するだけのもの
- スクリプト化されていて安全なもの
- 機械的なデータ変換や集計

---

## ステップ3: SKILL.md の生成

`references/runbook-template.md` を読み込み、テンプレートに従って以下を生成する:

- `SKILL.md`
- `references/why.md`
- `scripts/*.sh`(自動化ステップがある場合)

---

## ステップ4: レビュー

生成したSKILL.mdを見せてユーザーに確認する:

1. **手動/自動の判断は合っているか?**
2. **references/why.md の内容は正確か?**
3. **新メンバーが読んで手順を追えるか?**

## 既存skillの改善にも使える

既存の運用skillに「なぜこの手順が必要か」が欠けている場合、`references/why.md` を追加するだけでも価値がある。
ユーザーから既存skillの改善依頼があった場合はこのテンプレートに沿ってwhy.mdを作成する。

# Runbook Skill テンプレート

## ファイル構成

```
~/.claude/skills/<skill-name>/
├── SKILL.md
├── references/
│   └── why.md        ← このフローが存在する背景・文脈
└── scripts/
    └── *.sh          ← 自動化ステップのスクリプト(あれば)
```

---

## SKILL.md テンプレート

```markdown

---
name: <skill-name>
description: <説明>
allowed-tools: AskUserQuestion, Bash, Read
---

# <スキル名>

## このフローについて

<フロー全体の概要。1〜3行。>

背景・文脈については `references/why.md` を参照。

---

## 事前確認(手動)

実行前に以下を確認してください:

- [ ] <前提条件1>
- [ ] <前提条件2>

確認できたら続行してください。

---

## Step 1: <ステップ名>(手動)

**なぜ手動か**: <理由。このステップで人が考えるべきこと。>

以下を実行してください:

\```bash
<コマンド例>
\```

完了したら続行してください。

---

## Step 2: <ステップ名>(自動)

<何を実行するかの説明>

\```bash
~/.claude/skills/<skill-name>/scripts/<script.sh>
\```

---

## Step N: 結果確認(手動)

<出力結果の確認方法>

次のステップ: <手動で行うこと>
```

---

## references/why.md テンプレート

```markdown
# なぜこのフローが存在するか

## 背景

<技術的・組織的な背景>

## 現状の制約

<なぜ自動化しきれないのか、なぜこのフローが必要なのか>

## 将来的な改善の方向性

<このフローをなくすために何が必要か。APIが整備されたら?アーキテクチャが変わったら?>
```

おわりに

そもそも運用系をAgent Skillsで作るものではないのかもしれない、という気持ちもある。

まあ色々やりすぎてみて、ダメだったら戻ってくるでちょうどいいかも。

思考をしむける

最近しむける言葉について考える。

しむける言葉は、自身が抱いているイメージを思考してもらうようにしむける言葉。いざなう言葉という感じかもしれない。

しむける言葉については、「わざ言語」の中で創作太鼓奏者のインタビューを読んで知った。とても興味深い。

太鼓は叩くのではなく、弾くという感じの言葉になっていて、「そもそも叩くと弾くの違いってなんだ?」みたいに思考することが生まれる。

日々のエンジニアの仕事には、そういう思考にしむける言葉があっていいんじゃないかと考える。

いま目の前でやっていることを聴く。相手から言葉を引き出す。その過程で思考してもらう。

それは端的な言葉でいいような気がしている。

「なんでいまこれをやってるのか」「これはなにか」など。なんか聞かれたら怖い気がするけど。

あとは答えを直接教える・伝える欲求をどうにか抑える。つまりこれってこうじゃない? みたいな投げ方をすると、「あ、そうです」みたいになる。

できれば言葉を引き出す。引き出せなかったら待つ。そういえば、「本当に?」というブレーキを踏む言葉もありだなと思った。

そういう感じでいくと、スタンスを示すというか。なんか正解を持ってそうだけど、試されている感覚に見える、という。そんなFBをもらうことにもなりそうである。

答えがわかっているなら言ってほしい、ということは若手に何度か言われる。そりゃそうか。

わかっているのに、答えを待つというのも、チームとして参加していないという感覚に陥る。

だから参加はちゃんと示さないといけない。

最後に意見を出す。「私はこう思うよ」という感じで。

あとはあえて、ありえない感じの答えを出す、というのもありなのか。マクドナルド理論的なやつ。いやいやそれは…みたいなやつ。

お手本としてどうかとは思うけど、一応対案を引き出すことはできそうだなって思う。

C#のレコード型のプロパティにListなどの参照型がある場合に等価比較できない件

はじめに

record型で作ったドメインクラスの比較をするテストを書いた時に、なぜか失敗する。

record型だったらEqualsメソッドが自動的に生成されると思っていたのになぜ? と思っていたら以下の公式ドキュメントに答えが書かれていた。

learn.microsoft.com

参照等価を使用するメンバーがレコードに含まれている場合、レコードの自動値等価動作は期待どおりに機能しません。 これは、値ベースの等価性を実装していない System.Collections.Generic.List、配列、その他の参照型などのコレクションに適用されます (値の等価性を実装する System.Stringの注目すべき例外を除く)。

引用されているとおりだが、以下のようなプロパティがListなどのコレクション型になっていると各メンバーの Object.Equals が呼び出されて参照比較になってしまうらしい。

public record TodoItems(SequenceEquatableList<TodoItem> Items)

そのため、自前でEqualsメソッドをoverrideして実装しないといけないとのこと。

public record TodoItems(ImmutableList<TodoItem> Items)
{
    public virtual bool Equals(TodoItems? other)
    {
        if (other == null) return false;
        if (ReferenceEquals(this, other)) return true;
        return Items.SequenceEqual(other.Items);
    }

    public override int GetHashCode() {
        var hash = new HashCode();
        foreach (var item in Items) {
            hash.Add(item.GetHashCode());
        }
        return hash.ToHashCode();
    }

}

これは非常に面倒だなぁと思ってしまう。せっかくrecord型使っているのに。

結局回避策は無いので、せめての対応として参照比較しないようなListクラスの実装をすることにした。

using System.Collections;
using System.Collections.Immutable;
using System.Runtime.CompilerServices;

namespace Api.Domain.Base;

[CollectionBuilder(typeof(SequenceEquatableList), nameof(SequenceEquatableList.Create))]
public readonly struct SequenceEquatableList<T>(ImmutableList<T> items) : IEnumerable<T> where T : IEquatable<T>
{
    private ImmutableList<T> Items { get; } = items ?? [];

    public bool Equals(SequenceEquatableList<T> other) => Items.SequenceEqual(other.Items);

    public override bool Equals(object? obj) => obj is SequenceEquatableList<T> other && Equals(other);

    public override int GetHashCode()
    {
        var hash = new HashCode();
        foreach (var item in Items)
        {
            hash.Add(item.GetHashCode());
        }
        return hash.ToHashCode();
    }

    public IEnumerator<T> GetEnumerator() => Items.GetEnumerator();

    IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();

    public static implicit operator SequenceEquatableList<T>(ImmutableList<T> list) => new(list);

    public override string ToString() => $"[{string.Join(", ", Items)}]";
    public static bool operator ==(SequenceEquatableList<T> left, SequenceEquatableList<T> right)
     => left.Equals(right);

    public static bool operator !=(SequenceEquatableList<T> left, SequenceEquatableList<T> right)
     => !(left == right);
}

public static class SequenceEquatableList
{
    public static SequenceEquatableList<T> Create<T>(ReadOnlySpan<T> items) where T : IEquatable<T> => new(ImmutableList.Create(items));

}

使用用途としては、以下みたいな感じ。

var todoItem = new TodoItem(null, request.Name, new TodoItemEvents([]));

コーディングAIとかに聞きながらやってみたので、今回自分が知らない機能がいくつかあったので書いておく。

implicit operator

暗黙的な型変換ができるように宣言ができる。

public static implicit operator SequenceEquatableList<T>(ImmutableList<T> list) => new(list);
// implicit operator がある場合
ImmutableList<int> immutableList = [1, 2, 3];
SequenceEquatableList<int> seqList = immutableList;  // 暗黙的に変換される

// implicit operator がない場合
SequenceEquatableList<int> seqList = new SequenceEquatableList<int>(immutableList);  // 明示的にコンストラクタを呼ぶ

learn.microsoft.com

CollectionBuilder属性

コレクション式 ( [1, 2, 3] )でカスタムコレクションを初期化できるように作れるやつ。

public static class SequenceEquatableList
{
    public static SequenceEquatableList<T> Create<T>(ReadOnlySpan<T> items) where T : IEquatable<T> => new(ImmutableList.Create(items));

}

structに属性を与える。

[CollectionBuilder(typeof(SequenceEquatableList), "Create")]
public readonly struct SequenceEquatableList<T>(ImmutableList<T> items) : IEnumerable<T> where T : IEquatable<T>

こんな感じでコレクション式で書ける。

new TodoItems([.. Items.Where(v => v.Status != Status.Done)])

コード

github.com

おわりに

久々に触るC#の機能、知らないことだらけである。