開発チームで生成AI活用をするための取り組みやGoバックエンド開発環境の紹介

はじめに

ポケットサインでエンジニアをしている岸本です。普段はポケットサインの基盤バックエンドシステムの開発に携わっています。

弊社はまだまだ組織人数の少ないベンチャー企業ということもあり、少数精鋭のエンジニアで開発を加速・効率化していき、より事業を推進させていく必要があります。 エンジニアリング組織の目標としても「生成AIのフル活用」が掲げられており、ツールの導入だけでなく、それを前提にした開発スタイル・チーム運営に組織として本格的に踏み込み始めています。

そこで日々生成AIを導入・活用した開発をしやすくするための試行錯誤や開発環境の整備に取り組んでいますが、今回は私が所属しているチームの開発環境に導入しているものや工夫のうち、主にGoバックエンドに関わる部分について紹介します。

その前に、全社的な話

自チームの話をする前に、全社的な生成AIの活用についてを紹介します。

弊社では既にあらゆる職種のメンバーが生成AIを使って業務をしており、全社員にGeminiやNotebookLM、Circlebackなどのサービスの利用が開放されています。 情報のリサーチやスライド資料の作成、会議の議事録と要約の作成等に生成AIをすぐに使うことができる社内環境が用意されていますが、そこからさらに、全社員に導入されているツールとは別で、会社が用意した生成AIサービスのラインナップの中から、社員が自分の使いたいサービスをピックアップして使える 「生成AIビュッフェ制度」 が用意されています。

制度はシンプルで、

  • ラインナップ上の各生成AIサービス・プランごとに「必要ポイント」が定義されている
  • 職種・役職に応じて利用可能なポイントが割り振られる
  • 必要ポイントの合計が、自分の利用可能なポイント内に収まる範囲で、好きなサービスを自由に組み合わせて利用できる

というものです。

ラインナップとしてはChatGPT (Codex), Claude (Claude Code), Cursor, Gensparkなどがあります。

ピックアップしたサービスは後から変更可能にもなっており、世の中のサービスや活用術のトレンド変化が極めて速い中で、「会社が一括で1ツールを選定する」のではなく、現場の判断で柔軟に試せる仕組みになっているのが良いですね。

エンジニアが今主に使っている生成AIサービス・ツール

LLMの性能や開発体験のトレンドはとにかく変化が早いため、エンジニア内でも使うツールやLLMは現状あえて統一されていません。各々が使いたいもの・気になっているものを使い、知見をチームや組織全体でSlack等でシェアしながら活用を進めています。最近は特にトレンドがClaudeとGPTで行ったり来たりしてますし。

主に使われているコーディングエージェント・エディタと自分が思う社内での印象は以下です。

  • Claude (Claude Code)
    • Claude Opusのコーディング性能の高さや、最先端の開発体験・ワークフローに乗ることが出来るという印象
  • ChatGPT (Codex)
    • GPT-5の性能が高いことや、最近はCodex Appが機能充実してて開発に限らず使いやすくなっているという印象
  • Cursor
    • 高速かつ低コストなComposerモデルの使用やエディタとしての機能・使い勝手が評価されている印象

上記の3つは生成AIビュッフェ制度で会社がアカウントを用意してくれます。自分はポイントの関係でClaudeとCursorを会社で利用しており、Codexは個人で契約して触っています。

自分の Claude Code の使い方についても少し補足しておきます。自分は Claude Code をターミナルから CLI / TUI として直接使うよりも、Conductor というGUIアプリケーションを介して主に利用しています。Conductor は Claude Code のセッション管理に GUI をラップし、後述する git worktree を使った並列開発や、コード差分のレビュー・GitHub 連携などをひとつのアプリ内で完結させてくれるツールです。

ちょうどこの記事の執筆時期に Claude デスクトップアプリへの Claude Code の統合や機能追加が活発になっているので、もしかしたら状況が変わるかもしれません。


ここからは自分のチームの開発環境の話

ここまでは全社的な制度や、エンジニアの間で使われているツールの話でした。 ここからは、自分が所属しているチームのプロダクト開発環境において、生成AI活用を前提にどのような工夫をしているかを紹介します。

自分のチームは Go (バックエンド) + React/TypeScript (フロントエンド) を主軸にしたモノレポでプロダクトを開発しており、本記事ではこのうち主に Go バックエンド側の取り組みについて触れます。具体的には、

  • モノレポでのルール分割管理 (Skills) と、複数ツール間でのルール同期 (rulesync)
  • worktree を使った並列開発
  • クリーンアーキテクチャ + 自動生成スタック(sqlc, ConnectRPC, mocekry, google/wire)による「決定的なコード生成」の活用

の3点を順番に紹介していきます。


モノレポでのルール分割管理と、複数ツール間での同期

自分のチームのプロダクトは、フロントエンド・バックエンド・E2Eテストなどを一貫して開発しやすいよう モノレポ で構成しています。

ただ、モノレポにすると CLAUDE.md / AGENTS.md のようなルールファイルが膨らみがちで、開発ルールを最初に全部読み込んでしまうとコンテキストを無駄に消費してしまうという問題があります。

Agent Skills でルールを分割し、必要なものだけロードする

そこで、Agent Skills を使って、フロントエンド開発ルール・バックエンド開発ルール・コーディング規約を分割し、必要なものだけを選択的にロードできるようにしています。

さらにバックエンドの規約は、後述するクリーンアーキテクチャの レイヤーごとにスキルを分割 しています。例えば「ConnectRPC ハンドラを書くときの規約」「Repository を書くときの規約」「ユースケース層を書くときの規約」というように、AI が実際に触る層に応じて必要な規約だけがロードされる構成です。これにより、細かいパターンレベルの指示まで盛り込んでも、コンテキストを浪費しません。

スキルは frontmatter にトリガー条件を書いておくことで、関連するタスクを始めたときに 自動的にロードされるようにしています。ただし、この自動ロードはコンテキストやモデルによっては期待したタイミングで効かないことがあるため、都度手動でトリガーしたり、先に触ることが分かっているレイヤーのスキルをまとめて手で読み込ませておくことが多いです。このあたりはまだ改善の余地があり、試行錯誤中です。

rulesync で複数ツール間にルールを同期する

このようにスキルでルールを分割して問題になるのが、前述の生成AIビュッフェ制度のもとでは、人によって Cursor / Claude Code / Codex というように使うツールが違ったり、複数組み合わせて使っていたりするということです。各ツールはそれぞれ独自のルールファイル形式・スキル形式を持っているため、同じ規約を維持するためにツールごとに手で書き分けるのは大変です。

そこで、ツール間でスキルやルールファイルの同期を取るために rulesync を使っています。設定はリポジトリのルートに rulesync.jsonc として配置するだけ。

{
  "$schema": "<https://raw.githubusercontent.com/dyoshikawa/rulesync/refs/heads/main/config-schema.json>",
  "targets": ["claudecode", "cursor", "codexcli"],
  "features": ["rules", "ignore", "commands", "subagents", "skills"],
  "baseDirs": ["."],
  ...
}

ルールをワンソースで管理しつつ各ツール用のフォーマットに展開できるため、ツール選択の自由度を保ったまま、コーディングルールを統一して読み込ませることができます。前項で紹介した Skills も rulesync の同期対象に含まれており、.rulesync/ 配下に1度書けば、各ツールが要求する形式に自動的に展開されます。


worktree 並列開発

自分が 開発スピードの向上に一番効いたと体感した のが、git worktree を使った AI エージェントによる並列開発です。

git worktree は、同じリポジトリの複数ブランチを別々のディレクトリで同時にチェックアウトできる Git 標準の機能です。これを使ってタスクごとに独立したディレクトリ・独立した実行環境を用意し、その中でそれぞれ AI エージェントを 同時に走らせる ことで、1人でも複数タスクを並走させられるようになります。

ただ、自分は普段 git worktree コマンドを直接叩いているわけではなく、Conductor / Codex / Cursor といった各 AI コーディングツール側に組み込まれている worktree 機能を介して利用しています。各ツールが worktree の作成・切り替え・後片付けまで面倒を見てくれるため、エージェントを起動する流れの中で自然に worktree が作成される形で運用しています。

自分の場合、Conductor 上で worktree を作成し、そこで AI エージェントにあるタスクの実装をプランさせながら、別の worktree では既に完了済みの別のタスクのプランや実装をレビューします。

また、開発環境改善やコードのリファクタリングの案が思いついた際も、とりあえず worktree を新たに作成し、改善計画の作成やリファクタリングを試しに実行させておいて、後から手の空いた時や気分転換にレビューしたり、さらに指示を追加して、また手が空くまで放置したりします。

worktree 間でコードがコンフリクトした場合も、基本的にエージェントに解決させています。

世の中で既に定番化しているAIコーディングのプラクティスですが、これで開発が体感的に何倍にも加速しました。

worktree ごとの環境分離

ローカルで並列に動かす以上、各 worktree が独立した状態で開発環境を立ち上げることが出来ないとすぐに衝突して、開発が止まります。自分のチームでは以下の仕組みで worktree のセットアップや分離を行っています。

  • タスクランナーでセットアップを1コマンド化: Taskfile を採用しており、リポジトリの初期セットアップは task init 一発で完了するようにしている
  • CLI ツールのバージョン管理: aqua を使って開発に使う CLI ツールのバージョンを独立管理。ツールバージョンアップの検証も worktree 単位で独立して行える
  • worktree 作成時のフックでセットアップを自動実行: AI コーディングツール側の worktree 作成フックで task init を呼び出し、worktree が新たに作成されるたびに開発環境のセットアップが自動で走るようにしている
  • ポート番号の worktree ごと分離: .env 内のポート番号を worktree 別にずらしておくことで、複数 worktree でローカル開発サーバーを同時に立ち上げてもポートが衝突しないようにしている

worktree 作成時のフックでセットアップを自動実行

worktree が作成されるたびに毎回手で task init を叩くのは面倒なので、各 AI コーディングツールが提供する worktree 作成時のフックを使って、自動的にセットアップが走るようにしています。

ただ、worktree 作成・破棄時のフックは Conductor / Codex / Cursor など各ツールで設定の書き方や挙動が統一されておらず、ツールごとに微妙に差分が入ったスクリプトを用意することになっています。ここはまだ運用上の課題感があります。

例えば執筆時点では、Claude Code の WorktreeCreate hook は単に task init を呼び出すだけでは足りず、worktree 自体の作成までオーバーライドして、決まった仕様の入出力をするスクリプトを書く必要がありました。さらに Claude Code Desktop 上の Worktree 機能では、Claude Code CLI の WorktreeCreate hook は実行されない、といった差分もあります。

ポート番号を worktree ごとにずらす

自分のチームの開発環境では、docker compose で立ち上げる DB などのミドルウェアと、ホスト上で動かす CLI ツールや開発サーバーが混在しているため、.env ファイルでそれぞれのコンポーネント単位でポート番号を設定できるようにしています。

worktree 環境下で task init を実行すると、引数で指定した番号から連番でポートが割り当てられるか、worktree 名からランダムにポートが割り当てられる仕組みにしています。

# worktree 環境下でない場合の init はデフォルトのポートが割り当て
# worktree 環境下の場合は worktree 名から決定論的にポートが割り当て
task init

# Conductor などは worktree ごとにポート範囲を割り当ててくれるので、
# その範囲の開始番号を引数で渡すと、コンポーネントごとに連番でポートが振られる
task init -- 32000

Taskfile 側で .env に記載した環境変数を自動でロードするようにしているため、task コマンド経由で実行する開発コマンドにも、ポート割り当てが自動的に反映されます。

Testcontainers による外部依存の使い捨て

テスト実行時の DB などの外部依存は、なるべく Testcontainers で都度起動・破棄する構成にしています。Testcontainers は Docker コンテナ上にテスト用の DB やミドルウェアを一時起動し、テスト終了時に自動で片付けてくれるライブラリです。

これにより、worktree ごとにポートを気にせずテストを並列実行でき、ローカル開発用の DB とも分離されます。さらにテストごとにクリーンな状態の DB が確保できるため、並列開発との相性も非常によいです。


クリーンアーキテクチャ + 自動生成スタックで「決定的なコード生成」を活用する

ここまでは Skills + rulesync によるルール管理、worktree 並列開発と、「LLM の周辺をどう整えるか」の話でした。最後は、コード生成そのものをどう設計しているか — つまり、LLM に書かせる範囲をどう絞っているか、という基盤の話です。

ここでいう「決定的なコード生成」とは、LLM ではなく SQL やスキーマ定義などから 確定的に生成されるコード のことを指します。LLM の生成範囲をビジネスロジックの本質に絞り、定型的な型変換やボイラープレートは決定的なジェネレータに任せる、という分け方です。

自分のチームのバックエンドでは、主に以下のスタックを組み合わせて構成しています。

  • クリーンアーキテクチャ (adapter / usecase / domain / interface / infrastructure)
  • sqlc: SQL から型安全な Go コードを生成
  • Protocol Buffers + ConnectRPC: API のスキーマと RPC ハンドラ周りを生成
  • google/wire: Go のコンパイル時 DI (依存性注入) ライブラリ
  • mockery: Go の interface からモック実装を自動生成するツール

クリーンアーキテクチャの各層は、大まかには次のような役割分担になっています。

  • adapter: ConnectRPC / HTTP ハンドラや Repository 実装などの外部接続点
  • usecase: ビジネスロジックのオーケストレーション
  • domain: ドメインモデル・純粋なビジネスルール
  • interface: Repository やサービスの抽象 (ポート) を定義
  • infrastructure: DB / 外部 API / メッセージング等のインフラ層実装

これらは AI コーディングを始める前から元々採用していたものですが、結果的に AI 生成コーディングの精度に大きく効いている と感じています。

決定的なコード生成の層を適切に挟むことで、

  • LLM によるコード編集の誤りを構造的に回避できる
  • コードがパターン化されることで人間によるレビュー負荷が下がる
  • LLM が書く範囲が「自動生成された層とドメイン層との間のパターン化された型変換」と「ビジネスロジック」に絞られるため、アウトプットトークンコストも抑えられる

というメリットがあります。

新規 API 実装手順がパターン化されている

新しい API を実装するときの手順は、ほぼ次の流れに固定化されています。

  1. Repository 層: 新規 SQL を記述 → sqlc でコード生成 → Repository インターフェース定義 → sqlc コードを呼び出す Repository 実装 (ほぼレイヤー間の型詰め替えだけで定型)
  2. ユースケース層: Repository 経由の DB クエリ呼び出しを組み合わせてビジネスロジックを実装
  3. ConnectRPC ハンドラ層: proto で RPC の入出力・エラーコードを定義 → buf gen → ハンドラ層実装 (proto 型 ↔ domain / usecase の型変換 + ユースケース呼び出しでほぼ定型)

Repository 層と RPC ハンドラ層の実装はパターン化されて基本的に同じ形に収束するので、レビュー時はこれらをサッと流し読みして、SQL 自体とユースケース層のロジックのレビューに集中 できます。同時に、定型化されている部分は AI 生成との相性が非常によく、機械化しやすいです。

イメージしやすいよう、Repository 層の実装フローを実際のコードで見てみます。

まず SQL を書きます。

-- name: CreateInvitationTicket :one
INSERT INTO app.invitation_tickets (
    ticket_id,
    user_id,
    issued_by,
    expires_at
)
VALUES ($1, $2, $3, $4)
RETURNING *;

-- name: GetInvitationTicketByID :one
SELECT *
FROM app.invitation_tickets
WHERE ticket_id = $1;

task gen を実行すると、リポジトリで使っている自動生成ツール (sqlc / buf / wire など) がまとめて走り、これらの生成コードが一括で更新されます。今回の例だと sqlc が SQL から型安全な Go コードを生成します。次に Repository インターフェースを定義します。

// server/internal/interface/repository/invitation_ticket.go
type CreateInvitationTicketArgs struct {
    TicketID  model.InvitationTicketID
    UserID    model.UserID
    IssuedBy  model.UserID
    ExpiresAt time.Time
}

type InvitationTicketRepository interface {
    CreateInvitationTicket(ctx context.Context, args CreateInvitationTicketArgs) (*model.InvitationTicket, error)
    GetInvitationTicketByID(ctx context.Context, ticketID model.InvitationTicketID) (*model.InvitationTicket, error)
    ConsumeInvitationTicket(ctx context.Context, ticketID model.InvitationTicketID) (*model.InvitationTicket, error)
}

最後に Repository 実装ですが、ここは sqlc が生成したコードを呼び、ドメイン型と sqlc 型を詰め替えるだけのほぼ定型コードになります。

// server/internal/adapter/repository/invitation_ticket.go
func (r *repositoryImpl) CreateInvitationTicket(ctx context.Context, args repository.CreateInvitationTicketArgs) (*model.InvitationTicket, error) {
    row, err := r.queries.CreateInvitationTicket(ctx, sqlc.CreateInvitationTicketParams{
        TicketID:  uuid.UUID(args.TicketID),
        UserID:    uuid.UUID(args.UserID),
        IssuedBy:  uuid.UUID(args.IssuedBy),
        ExpiresAt: args.ExpiresAt,
    })
    if err != nil {
        return nil, convertError(err, "failed to CreateInvitationTicket")
    }
    return toDomainInvitationTicket(row), nil
}

このように 「SQL を書く」「インターフェースを宣言する」「型を詰め替える」 の三段階に作業が固定化されているため、LLM への指示も「このパターンに従って実装して」で済みます。レビューでも、SQL 以外はほぼ目視チェックレベルで通せます。

ConnectRPC ハンドラ層も同様の発想で、proto 定義から自動生成されたリクエスト/レスポンス型を、ユースケース層の引数・戻り値の型に詰め替える定型的な実装に収束します。

// protobuf/.../invitation_ticket.proto
service InvitationTicketService {
  rpc CreateInvitationTicket(CreateInvitationTicketRequest)
    returns (CreateInvitationTicketResponse);
}

message CreateInvitationTicketRequest {
  string user_id = 1;
  string issued_by = 2;
  google.protobuf.Timestamp expires_at = 3;
}

message CreateInvitationTicketResponse {
  InvitationTicket ticket = 1;
}
// server/internal/adapter/connectrpc/.../invitation_ticket.go
func (h *Handler) CreateInvitationTicket(
    ctx context.Context,
    req *connect.Request[v1.CreateInvitationTicketRequest],
) (*connect.Response[v1.CreateInvitationTicketResponse], error) {
    args := toUsecaseCreateInvitationTicketArgs(req.Msg)
    result, err := h.usecase.CreateInvitationTicket(ctx, args)
    if err != nil {
        return nil, convertError(err)
    }
    return connect.NewResponse(toProtoCreateInvitationTicketResponse(result)), nil
}

proto → domain / usecase 間の型変換は type_converter.gotoUsecaseXxx / toProtoXxx 関数に集約しているため、ハンドラ本体は「変換 → ユースケース呼び出し → 変換」の3行に近づきます。

wire で依存関係を宣言的に管理

各層の依存関係は google/wire を使った DI で解決しています。各層の実装は、依存するインターフェースや設定値をフィールドとして持つ構造体として定義し、wire.NewSet でその組み立てルールを宣言します。

// server/internal/usecase/invitation/usecase.go
type UseCaseImpl struct {
    Repo               repository.Repository
    ServerOrigin       domain.ServerOrigin
    UserSessionManager usersession.Manager
    TaskManager        asynctask.Manager
    // ...
}

var UseCaseSet = wire.NewSet(
    wire.Struct(new(UseCaseImpl), "*"),
    wire.Bind(new(UseCase), new(*UseCaseImpl)),
)

サーバーのルート wire.go ではこれらの wire.ProviderSet をまとめて wire.Build するだけで、全体の依存関係が解決されます。新しい依存を増やしたいときも、構造体のフィールドに1行追加して go generate するだけで済みます。

この構成によって、

  • LLM が各層の間で構造体やインターフェースを「運び回す」コードを書く必要がない (書き忘れもない)
  • 依存関係を静的に解決するため、解決できない依存関係があれば go generate がエラーで止まり、原因もエラーメッセージから明確
  • pre-commit hook と CI のどちらでも task gen を実行して git diff が空であることを検証しているため、AI が wire 生成漏れのままコミットや PR を出してきても気づける仕組みになっている

各層ごとにテスト、外側依存はモックに差し替える

各層のメソッドは interface 型として定義しており、ユニットテストでは外側のアーキテクチャの依存は mockery が生成するモックに差し替え ます。LLM がモック実装を手書きすることはありません。

// ユースケースのテストは、Repository などの依存をモックに差し替えて単体で検証できる
mockRepo := mockrepository.NewMockRepository(t)
mockRepo.EXPECT().
    GetInvitationTicketByID(mock.Anything, ticketID).
    Return(&model.InvitationTicket{ /* ... */ }, nil)

uc := &invitation.UseCaseImpl{Repo: mockRepo /* ... */}
result, err := uc.ConsumeInvitationTicket(ctx, args)
require.NoError(t, err)

他にもいろいろ取り組んでいます

ここまでで触れた以外にも、以下のような取り組みや実験をチームで進めており、これらは別記事で順次紹介していく予定です。

  • フロントエンドの操作も含めたプロダクト全体の機能テスト・シナリオテスト計画の LLM による生成
  • フロントエンド E2E にスクリーンショット撮影を埋め込み、ntn CLI を用いて Notion DB やタスクページにアップロードして、画面カタログ生成・レビューまで持っていく Skill の作成
  • Notion で管理されているプロジェクトやタスクカードとの連携、チーム運営での Claude 活用

おわりに

本記事では、自分のチームで取り組んでいる生成AI活用と Go バックエンド開発環境の工夫を、

  • モノレポでのルール分割管理 (Skills) と、複数ツール間でのルール同期 (rulesync)
  • worktree を使った並列開発
  • クリーンアーキテクチャ + 自動生成スタックによる「決定的なコード生成」の活用

の3点を中心に紹介しました。

少人数のエンジニアチームで事業推進を加速していくうえで、生成AIフル活用は前提条件になりつつあります。今期はエンジニアリング組織の目標としても掲げられており、ここで紹介した取り組みも引き続きアップデートしていく予定です。続編にもご期待ください。