株式会社Stackの2025年開発体制の振り返り

sonatard
·
公開:2026/1/5

エンジニアは17人。

2025年は、チームとして機能開発やデリバリーに集中することができた1年だった。Go や Cloud に関する技術基盤の変化が小さく安定していたことが大きい。

改善面は、主にサーバ費用の削減、AI と多人数開発を前提とした制約の追加、内部ライブラリの品質向上に取り組んだ。

2024年と比較すると技術基盤の大きな変更は少なく、基盤が安定してきたことがわかる。代わりに2025年は、完成度を上げるための詳細な改善が増加した。

改善の加速

Stack には、専門の Enabling チームや Platform チームを設けていないため、構造的には改善を進めにくい環境にある。それでも開発と並行して積極的に改善に取り組んでくれるメンバーが増え、新たな視点からの改善が進んだ。

ドキュメント

これまでは、ドキュメントの品質や効果を安定させることが難しいと考えており、チームでのドキュメント管理は最低限に留めていた。 具体的には「書いたが誰にも読まれず、気づかないうちに更新されなくなり古くなる」といった状況は起こりやすい。

しかし AI の登場により、ドキュメントは「人が気がついたら読むもの」から「AI が常に読むもの」へと変化した。 ドキュメントが更新されていなければ AI が正しく動かないため、自然と改善の動機付けが生まれる。また、AI がドキュメントレビューを行ってくれる点も大きい。

このようにドキュメントのフィードバックループが機能し始めたことで、適用範囲を少しずつ検討・拡大している。

コーディングエージェント

コーディングエージェント の普及によって、フロー効率が上がり、リードタイムが短くなったかは判断が難しいが、品質が向上したことは間違いない。

またドキュメントの話のように、これまで運用コストが高く実現できなかった施策が現実的になった。

また着手までの心理的ハードルが高かったタスクを着手しやすくなった。具体的には、CI や Lint の整備、一括置換では対応できない大規模なリファクタリングなどがある。

改善内容

Go 1.22

  • reflect.TypeOf から reflect.TypeFor[T] への移行

Go 1.24

  • t.Context()

  • go runからgo toolへ移行

Go 1.25

  • synctest で flaky なユニットテストを改善

Google Cloud の ID Token パッケージの移行

Spanner パーサの移行

xerrors パッケージの削除

xerrors を利用していたが独自実装に変更した。 インターフェースは Go 標準の errors パッケージとほぼ同等で %w でエラーをラップすることができる。差分は、スタックトレース取得用の関数がある点のみである。

多くの非公式 error 系パッケージはリッチな機能を持つが、それらの機能は errors パッケージではなく、独自の Error 型として実装する方針とした。これにより、errors パッケージとの差分を最小限に抑えつつ、様々な機能を使うことができる。

Cloud Trace へのエクスポート停止

サーバ費用が高いため、Cloud Trace へのエクスポートを停止した。 サンプリングも検討したが、メンバーへの確認の結果、TraceID を付与し Cloud Logging から追跡できれば十分という結論になった。

modernize の実行

https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/modernize

gqlgen / gqlgenc の omitzero 対応

GraphQL において null と undefined を区別できるようになった。

Shopify APIはnull と undefinedで挙動が異なるものが存在する。

  • null → nullで更新

  • undefined → 何もしない

gqlgenのgoField directiveのtypeによるfieldごとのtype binding

GraphQLはID型を利用するが、domainではテーブルごとにIDを用意している。この場合は、コード上でID型とdomainのテーブル固有のID型の間で変換が必要になるが、goFieldのtypeフィールドを利用することで不要になる。

AI 向け Go スニペット集の整備

AI が古い Go バージョンのコードを書くことがあるため、専用のスニペット集を整備した。

CI による Spanner マイグレーションの下位互換性の警告

マイグレーションに下位互換性がないことによる不具合は、E2E テストでは検知できないため、CI で警告を出すようにした。

Cloud Tasks の事前条件違反によるリトライ方法の変更

非同期処理において、他の処理が完了していないなどの事前条件違反が発生した場合、これまではエラーを返して Cloud Tasks にリトライさせていた。

しかし、大量にエラーが発生すると Cloud Tasks がキューのレート制限を行い、通常タスクの実行速度が低下する問題があった。そこで、事前条件違反時にはエラーを返さず、改めてタスクをキューに追加する方式に変更した。

非同期処理のシーケンス全体を表示するコマンドの用意

Pull Request の Body に、当該 Pull Request で変更したユースケースからの非同期シーケンス全体を自動的に記載するようにした。これにより、レビュー時に影響範囲を把握しやすくなった。

Error Report の整備

状況によっては、エラーメッセージが正しくレポートされないことがあった。 Google Cloud の Error Report は機能面で不足があり、運用を安定して回すためには手間がかかる。Google Cloud には、Observability の延長として、Error Report にももう少し注力してもらいたいと感じている。

Dataflow Primeへ移行

SpannerのChange Streamを使っている関係でDataflowを使っている。リリース時に念の為強めのスペックでリリースしたが、費用がかかっていた。ある日Googleから動的にスペックを変更するDataflow Primeへの移行の案内が来たため、費用の問題も解決できるため移行を実施した。

これは株式会社DELTA様に検証と移行を実施して頂きました。

Cloud Run の設定管理を Terraform から YAML に変更

宣言的な Terraform と、命令的な Cloud Run のデプロイは相性が非常に悪いため、Terraform で Cloud Run を管理しない方針とした。

バックエンドの E2E テスト(API テスト)をより本番環境に近づける

これまでは、テストから直接 Usecase 関数を実行しているケースもあったが、API の呼び出しや Cloud Storage へのファイル配置など、本番と同等のトリガーを起点としてテストを実行するように変更した。

E2E テストごとの環境変数設定の改善

複数のテストケースが同一の Google Cloud プロジェクトを利用しているため、E2E テスト環境ではテストごとに環境変数を切り替える必要がある。

これまでは、テスト時の HTTP リクエストヘッダーに環境変数を設定し、context に埋め込んでいた。しかし、この方法では非同期処理の先まで値を伝播させる実装コストが高かった。

そこで、環境変数を DB に保存し、middleware で DB から読み込んだ環境変数を context に埋め込む方式に変更した。

GitHub Actions runner を blacksmith へ移行

https://x.com/sonatard/status/1945493761049673744

CI の自動リトライ

デプロイや Lint のタイムアウトなどは自動リトライするようにした。 また、E2E も一部 flaky であるため、自動リトライを導入した。

domain の最大数を定義

商品あたりのバリエーション数の最大値

const ProductVariantLimitPerProduct = 100

domain に最大値を定義することを徹底するため、Lint を作成した。この値を Spanner のクエリの LIMIT に設定する。

これにより、実装者がクエリ実行時に想定している最大レコード数をどのように考えているかが、コード上で表現される。その結果、レビュアーが実装者の意図をレビューしやすくなり、想定外のデータ量によってメモリが圧迫される問題を減らすことができる。また、実装から仕様としてリレーションが読み取れる効果も大きい。

一般的には、リレーション数の仕様は ER 図などで記載することが多いが、ER 図とは異なり、実装に情報があることで「必ずその実装で動いていること」が保証される点は大きなメリットである。

domain の GoDoc の記載ルール

引数、戻り値、事前条件、事後条件を記載する。

domain を宣言的に書くための Claude Code のルールを整備

基本的には、usecase も domain も、上から順番に関数を 1 つずつ実行するだけの実装になっていることが理想である。 for ループの中で複数の条件分岐や処理がある実装は黄色信号。

octocov によるカバレッジ計測

domain と lib の package は、カバレッジが低下すると CI が落ちるようにした。30% から開始し、現在は 60% まで向上している。

これも、AI により積極的に実施可能になった施策の1つである。テスト対象関数の GoDoc に事前条件や事後条件を書くことで、AI は人間よりも適切に境界値テストを実施してくれる。

ユニットテストの書き方を統一

テーブルドリブンテスト、want 構造体、fields 構造体、args 構造体、cmpopts.AnyError、cmp.Diff を利用する。 gotests によるテストのテンプレート化、CLAUDE.md の整備。

Usecase 関数の GoDoc に冪等性の有無を書くことをルール化

冪等性が保証されていない実装は、リリース後にバグが発覚しやすい。しかし、Usecase の冪等性をチームで意識して実装することは難しく、効果的な対応を見いだせずにいた。

AI の登場により状況が変わり、GoDoc に冪等性の有無とその理由を書くことで AI がレビューしてくれるようになった。また、実際には冪等性の有無も AI が記載するため、その過程で AI が問題点に気づいてくれる。冪等性の記載は必ず行わなければ効果が発揮されないため、Lint で記載を必須化している。

理想的には、API テストで同じパラメータを用いて 2 回実行するテストをすべての API に対して書くことだが、現時点では必須化までは考えていない。

Gemini Code Assist によるコードレビュー

ケアレスミスの大幅な削減と、コードレビューコストの大幅な低減につながった。 今年一番の改善は、間違いなく Gemini Code Assist だった。

GitHub の Pull Request に背景の記載をルール化

「現状」「現状のままでは困ること」「実装内容」の記載を必須にした。背景の記載がない場合は CI が落ちるようにしている。

これにより、レビュアーが内容を理解しやすくなり、より適切なレビューが可能になった。この情報は AI のレビュー精度向上にもつながる。

また1つのPull Requestで異なる複数の変更をすることの低減にも繋がる。背景には1つの変更の説明だけを書くスペースがあるため、もしコード上で複数の変更をしていると背景の説明と関係ない変更となり矛盾が生まれる。

E2E テストの作成単位をルール化

これまではテストサイズに関するルールがなかったが「1 つのテスト観点につき 1 つのテストを作成する」方針にした。

SQのE2E テストは 757 ケースとなった。

Claude Code の導入

Rules、Skills を整備した。

Lint の作成

  • Uasecase関数からUsecase関数を呼ぶことを禁止

  • Usecaseから環境変数にアクセスすることを禁止

  • UsecaseからPubSubをPublishすることを禁止

  • domainのID周りの実装ルール

  • 冗長な Type Parameter を検出 (gopls を利用)

  • Cloud TasksのQueue名の長さ上限

  • GraphQL の description の記載確認

  • Webとバックエンドの整合性

  • Spanner関連の実装制約 9個

諦めたもの

GOCACHEPROG を設定し、Cloud Storage に Go のビルドキャッシュを保存

単純にこれを実施するだけでは高速化しなかった。

Cloud Run Worker Pool を利用した GitHub Actions self-hosted runner

スケールダウンで苦戦した。今後、self-hosted runner に新しいリリースがあるようなので期待している。

反省編

SaaS 部分の API サーバの DB と企業依存 API サーバの DB を分離

StackのSQ はエンタープライズ向けサービスであり、企業の既存システムとの接続は必須となる。そのため、SaaS 部分の API サーバと企業固有の API サーバを用意しているが、これまで DB は分離していなかった。

基本的には、DB に限らずシステムは安易に分けるべきではないという思想で運用してきたが、サーバを分けた時点で DB も分けておくべきだったと後悔している。これは過去で一番大きな自分の意思決定の失敗と言えるだろう。

現在分けないことで問題があるというわけではないが、新たな企業のサーバを作るときには、DBを分離するという意思決定をした。そうなると既存の分離していないDBだけがシステム構成として歪になるため、保守コストの増加が予測できる。とうい点でミスだったと言える。

結果としては、開発と並行しながら 2 か月で分離を完了してもらえたため、大きなダメージにはならず、本当に助かった。

decimalのOptional対応

Goでdecimalを扱うために https://github.com/shopspring/decimal を利用している。しかしこのパッケージが持つDecimal型はSpannerに対応していないため、Decimal型をラップする独自のDecimal型を定義していた。

その後DecimalをOptionalで保存するために、decimal型にIsNullフィールドを追加する変更が行われた。

また年月が経ち、enumをOptionalで保存するケースがでてきたが、SpannerのGo SDKの問題で独自定義したstringをポインタとしてSpannerに保存できない。ここでもDecimalと同じ対応が行われた。

しかし今の実装だと、型からOptionalなのかどうかが判定できない問題がある。これは別のタスクをしていて気がついた。

この時点で、修正するべきだと判断して、合計4つの型のOptional対応とType Paramaterを利用した汎用的なOptionalの実装を2週間かけて終えた。

Decimal型の実装は以前から見ていたはずで、それでも「型からOptionalなのかどうかが判定できないから治すべきだ」と判断できなかったことが最大のミスだ。

また修正作業中に気がついたことだが、Optionalな箇所はとても少なくその箇所だけOptional対応していれば影響範囲の小さい変更だったはずだが、根本的な型がIsNullフィールドを持っているため、広範囲に影響してしまう変更になってしまったことも反省点の1つだ。

課題

ユニットテスト、E2E テスト、ドキュメントの拡充により、最新状態でのバックエンドの品質は高い状態を維持している。

一方で、現在でも障害が発生することはあり、以下のような課題が残っている。

  • 本番データを考慮した下位互換性、適切なDBスキーママイグレーション

    • CIによる警告である程度改善を期待したい

  • 本番データを考慮した下位互換性、適切なDBデータマイグレーション

    • バッチの実装ミスを気がつくことが困難

    • 想定外の副作用がある

  • Web やアプリの動作確認、テスト

    • アプリのテストは進めてもらっているため状況改善に期待