every Tech Blog

株式会社エブリーのTech Blogです。

Go の database/sql における MySQL セッション変数の挙動とコネクション固定

はじめに

こんにちは、デリッシュキッチンのバックエンドエンジニアの鈴木です。

先日、プロダクトのGoのバージョンを 1.25.4 から 1.26.0 へアップデートしたところ、CI上の自動テストが一部落ちる(失敗する)問題に直面しました。 原因を調べてみると、テストデータの初期化で使っている TRUNCATE 処理において、これまで発生していなかった外部キー制約(Foreign Key Constraint)のエラーが頻発していることがわかりました。

コード自体はいじっていないにもかかわらず、なぜGoのバージョンを上げただけでデータベース操作が失敗するようになったのか。本記事では、このエラーの調査を通して改めて気付かされた、Goの database/sql パッケージにおけるコネクションプールの仕様と、安全なコネクション管理について共有します。

概要

  • Goの database/sql は内部でコネクションプーリングを行っており、db.Exec() などのクエリ実行ごとに、プールからアイドル状態のコネクションを動的に取得・返却する仕組みになっている。
  • 一方、MySQLの SET foreign_key_checks = 0 のような設定は、同一セッション(コネクション)内でのみ有効。
  • そのため、db.Exec() を連続して呼んでも、同じコネクションで実行される保証はなく、別のコネクションが割り当てられると設定が反映されずにエラーになる。
  • 解決策として、db.Conn() を使ってコネクションを明示的に取得(占有)し、一連の処理が終わるまで同じコネクションを使い回す必要がある。

Go 1.26.0 へのアップデートと TRUNCATE の失敗

Goを 1.26.0 に上げたタイミングで、テストのクリーンアップ処理(テーブルのTRUNCATE)において、MySQLから以下のエラーが返るようになりました。

Error 1701: Cannot truncate a table referenced in a foreign key constraint ...

MySQLで外部キー制約が張られているテーブルを TRUNCATE する場合、一時的に SET foreign_key_checks = 0 を実行して制約を無効化するのが一般的です。私たちのコードでもこの処理を入れていたはずですが、なぜか制約違反のエラーが発生していました。

問題となった実装

エラーが発生していた箇所のコードです。標準の *sql.DB を使って、3つのクエリを順番に実行していました。

// 外部キー制約を一時的に無効化してTRUNCATEを実行する実装
func (e *DBEngine) TruncateTable(tableName string) error {
    // 1. 制約チェックの無効化
    if _, err := e.db.Exec("SET foreign_key_checks = 0"); err != nil {
        return err
    }

    // 2. TRUNCATEの実行(ここで Error 1701 が発生)
    if _, err := e.db.Exec("TRUNCATE TABLE " + tableName); err != nil {
        return err
    }

    // 3. 制約チェックの有効化
    if _, err := e.db.Exec("SET foreign_key_checks = 1"); err != nil {
        return err
    }

    return nil
}

一見すると上から順番に実行されるため問題なさそうに見えますが、この実装は各クエリが別々のコネクションで実行される可能性を考慮できていませんでした。

原因:コネクションプールとセッション変数の仕様の違い

今回の問題は、MySQLのセッション変数の仕様と、Goのコネクションプールの挙動のミスマッチが原因でした。

MySQLのセッション変数

MySQLの SET foreign_key_checks はセッション変数であり、その設定は現在のセッション(コネクション)内でのみ有効です。別のコネクションから繋ぎ直した場合、デフォルトの設定(通常は有効)に戻ってしまいます。

Goの database/sql の挙動

Goの db.Exec() は、実行されるたびにコネクションプールから空いているコネクションを1つ取得し、クエリを実行し終えるとすぐにプールへ返却します。 つまり、コード上で連続して db.Exec() を書いても、同じコネクションで実行される保証はどこにもありません。

内部では以下のように、コネクションのすれ違いが発生していました。

fig.1: コネクションが切り替わることで設定が引き継がれずエラーになるフロー

なぜ今までエラーにならなかったのか?

これまでの環境(Go 1.25.4)では、このコードでも特にエラーは起きていませんでした。しかしこれは仕様として保証されていたわけではなく、単なる実行タイミングの偶然でした。

これまでは、以下の流れがたまたま成立していました。

  1. SET foreign_key_checks = 0 を実行。
  2. 使い終わったコネクションが即座にプールへ返却される。
  3. 直後の TRUNCATE でプールからコネクションを取得する際、たった今返却されたばかりのコネクション(設定変更済み)がそのまま使い回される。

このように、他に並行して走っているクエリがない限り、実質的に同じコネクションが連続して割り当てられやすい状態になっていたに過ぎません。

なぜ Go 1.26.0 で顕在化したのか?

Go 1.26.0 では、ランタイムのパフォーマンスが大きく向上しています。特に、デフォルトで有効化された新しいガベージコレクタ Green Tea GCによるスキャン待ち時間の削減や、メモリアロケーションの高速化などが含まれています。

こうしたランタイムの最適化によって、プログラム全体の実行速度やゴルーチンの切り替わりといった、マイクロ秒単位のスケジュールタイミングが微妙に変化しました。

その結果、SET クエリを実行してコネクションが完全にプールへ戻る前に、次の TRUNCATE の処理が走り出し、プール側がいま空いている別のコネクション(設定変更されていないもの)を割り当ててしまうケースが増加したと考えられます。

つまり、Goのバージョンアップによるバグではなく、Goのランタイムが高速化・効率化されたことで、アプリケーション側に潜んでいた実装上の不備が表面化したというのが真相です。

解決策:sql.Conn を使ってコネクションを固定する

同じコネクションを使って一連のクエリを確実に実行するには、Go 1.9から導入された db.Conn(ctx) を使用します。これを使うことで、プールから特定のコネクションを明示的に取得(占有)できます。

修正後の実装

func (e *DBEngine) TruncateTable(ctx context.Context, tableName string) error {
    // 1. コネクションを明示的に取得(チェックアウト)する
    conn, err := e.db.Conn(ctx)
    if err != nil {
        return err
    }
    // 使い終わったら必ずプールへ返却する
    defer conn.Close()

    // 2. 確保した同一のコネクション(conn)に対してクエリを実行する
    if _, err := conn.ExecContext(ctx, "SET foreign_key_checks = 0"); err != nil {
        return err
    }

    // 同じコネクションなので、設定が反映された状態で実行できる
    if _, err := conn.ExecContext(ctx, "TRUNCATE TABLE " + tableName); err != nil {
        return err
    }

    if _, err := conn.ExecContext(ctx, "SET foreign_key_checks = 1"); err != nil {
        return err
    }

    return nil
}

sql.Conn オブジェクトに対してメソッドを呼び出し、処理が終わった後に Close() を呼ぶことで、セッション変数の設定を維持したまま安全にクエリを実行できるようになります。

まとめ

MySQLの SET 構文のようなセッション依存の設定を行う場合、単純な db.Exec() の連続呼び出し(コネクションプール任せ)にしてはいけません。必ず sql.Conn などを使い、明示的にコネクションを占有して処理を行う必要があります。

今回のケースのように、言語やランタイムのパフォーマンスが向上した結果、これまでたまたま動いていたコードの潜在的なバグが顕在化することがあるため、仕様を正しく理解して実装することの重要性を再認識しました。

デリッシュキッチンiOSアプリにおけるSwiftUI段階的移行戦略

目次

  • はじめに
  • 2つの課題と、目指すアーキテクチャ
  • 手法1 — UIKit の中に SwiftUI を埋め込む
  • 手法2 — ViewModel の Protocol と実装の分離
  • 手法3 — UIKit 依存の画面遷移を列挙型で集約する
  • 手法4 — SwiftUI から UIKit の画面を呼ぶ
  • 手法5 — 本体プロジェクトに依存する View コンポーネントを外から差し込む
  • まとめ — 制約の中で前に進む

はじめに

デリッシュキッチンで iOS エンジニアをしている谷口恭一です。

デリッシュキッチンは今年で10年目を迎えるアプリです。この約1年間、2つの取り組みを並行して進めています。

  1. SwiftUI 化 — UIKit で書かれた既存画面を SwiftUI に置き換える
  2. マルチパッケージ化 — 本体プロジェクトからコードを SPM パッケージに切り出す

どちらも一括でやれるものではなく、通常の機能開発と並行しながら少しずつ進めるしかありません。本記事では、この2つの取り組みを同時に進めるために実践している5つの手法を紹介します。

2つの課題と、目指すアーキテクチャ

具体的な手法の話に入る前に、それぞれの課題と目指す方向を整理します。

SwiftUI 化の課題

多くの既存画面は UIKit と RxSwift で構成されています。新規の画面は SwiftUI で作っていますが、既存画面の SwiftUI 化はまだ道半ばです。

SwiftUI 化を進めるうえで最大の障壁は、ナビゲーションです。アプリ全体の画面遷移は UINavigationController の push/pop で成り立っています。SwiftUI には NavigationStack がありますが、アプリ全体のナビゲーションを一気に置き換えるのは現実的ではありません。画面数が多く、各画面の遷移ロジックが UINavigationController に深く依存しているためです。

また、各画面の ViewModel は API クライアント、永続化層、広告 SDK など、本体プロジェクトの様々なサービスに依存しています。UIKit の画面を単純に SwiftUI に書き換えるだけでは済まず、こうした依存関係をどう扱うかという設計上の判断が必要になります。

マルチパッケージ化の課題

一部のパッケージ化は進んでいるものの、まだ多くのソースコードが本体プロジェクト(.xcodeproj)のメインターゲットに含まれている状態です。依存管理は CocoaPods と SPM を併用しています。

本体プロジェクトのメインターゲットにコードが集中している構成には、チーム開発で厄介な問題があります。ファイルを追加・削除するたびに .xcodeproj 内の project.pbxproj に差分が出て、ブランチ間のコンフリクトの原因になることです。

Xcode 16 で導入されたフォルダベースのグループ管理を使えば、ファイルシステムとプロジェクト構造が自動同期されるため、この問題は解消できます。しかし、CocoaPods で管理されている古い依存がフォルダベースのグループに対応しておらず、現時点ではフォルダへの移行がまだ行えません。

方針:SwiftUI 化のタイミングでパッケージにも切り出す

この2つの課題は別々のものですが、同時に取り組むことで互いを補い合えます。

具体的には、UIKit の画面を SwiftUI に書き換えるタイミングで、書き換えた SwiftUI のコードを本体プロジェクトに残すのではなく、SPM(Swift Package Manager) の Feature パッケージに切り出すという方針を取っています。

こうすることで、SwiftUI 化によってコードが新しくなると同時に、パッケージへの移動によって本体プロジェクトのメインターゲットからコードが減っていきます。SPM パッケージ内のファイル操作は .xcodeproj に影響しないため、コンフリクト問題も根本的に回避できます。

本記事ではこの2つの場所を以下の用語で呼び分けます。

本体プロジェクト.xcodeproj のメインアプリターゲット) - UIKit の ViewController - ViewModel の実装クラス(各種サービスに依存) - Networking / Services / Repository

Feature パッケージ(SPM で管理する独立したパッケージ) - SwiftUI の View - ViewModel の Protocol - Action の列挙型 - UI コンポーネント

依存の方向は当然 本体プロジェクト → Feature パッケージ の一方向です。

理想像:本体プロジェクトをエントリーポイントにする

最終的には、UI 層だけでなく、責務ごとに適切なパッケージへコードを隠蔽し、本体プロジェクトはそれらを組み合わせるエントリーポイントとしての役割に留めるのがゴールです。今回の Feature パッケージへの UI 層切り出しは、その最初の一歩にあたります。

前提:開発リソースの制約

なお、SwiftUI 化やパッケージ化だけに専念できる時期はありません。通常の機能開発・改善と並行して、できるところから少しずつ進めるしかないのが現実です。だからこそ、1画面ずつ、1コンポーネントずつ着実に移行していける手法が必要になります。

以下、5つの考え方とそれに対応する手法を順に紹介します。

手法1 — UIKit の中に SwiftUI を埋め込む

最も基本的な手法です。アプリ全体の画面遷移を一気に NavigationStack に置き換えるのは現実的ではありません。そこで、画面遷移は UIKit のまま諦めて、1画面ずつ中身だけを SwiftUI に置き換えていくという考え方を取ります。

Feature パッケージ側では、純粋な SwiftUI View を定義するだけです。

public struct FeatureView<VM: FeatureViewModel>: View {
    @ObservedObject var viewModel: VM
    var body: some View { ... }
}

本体プロジェクト側では、この SwiftUI View を UIHostingController 経由で既存の UIKit 画面に埋め込みます。

let hosting = UIHostingController(rootView: FeatureView(viewModel: viewModel))
addChild(hosting)
view.addSubview(hosting.view)
hosting.didMove(toParent: self)

ナビゲーション階層には一切手を加えないため、影響範囲がその画面だけに限定されます。既存のナビゲーションバーの設定や画面遷移ロジックをそのまま活用できます。

なお、シートやフルスクリーンカバーによるモーダル表示は、ナビゲーションの push/pop 階層とは独立しています。したがって、ホスティングされた SwiftUI View の中で .sheet()) や .fullScreenCover()) を使ったモーダル遷移は、完全に SwiftUI 内で完結できます。

手法2 — ViewModel の Protocol と実装の分離

SwiftUI View を Feature パッケージに移すとき、最初にぶつかるのが ViewModel の依存関係です。ViewModel の実装クラスは API クライアントや永続化層など、本体プロジェクトの様々なサービスに依存しています。これをそのままパッケージに持っていくことはできません。

解決策は、ViewModel を Protocol(インターフェース)と実装に分離することです。

Feature パッケージには Protocol だけを置きます。

@MainActor
public protocol FeatureViewModel: ObservableObject {
    var items: [Item] { get }
    var isLoading: Bool { get }
    func fetch() async
}

public struct FeatureView<VM: FeatureViewModel>: View {
    @ObservedObject var viewModel: VM
}

SwiftUI View はジェネリクスで ViewModel Protocol に依存し、具象型を知りません。

実装は本体プロジェクトに残します。

final class FeatureViewModelImpl: FeatureViewModel {
    @Published private(set) var items: [Item] = []
    @Published private(set) var isLoading = false
    private let service: ItemService

    func fetch() async {
        isLoading = true
        items = (try? await service.fetchItems()) ?? []
        isLoading = false
    }
}

サービス層への依存は本体プロジェクトの実装クラスに閉じ込められ、Feature パッケージは Protocol が定義するインターフェースだけを相手にします。

そして、この2つを繋ぐのが本体プロジェクトの UIKit ViewController です。手法1と組み合わせて、実装クラスを生成し SwiftUI View に渡します。

final class FeatureViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        let viewModel = FeatureViewModelImpl(service: .shared)
        let hosting = UIHostingController(rootView: FeatureView(viewModel: viewModel))
        addChild(hosting)
        view.addSubview(hosting.view)
        hosting.didMove(toParent: self)
    }
}

Feature パッケージの FeatureViewFeatureViewModel Protocol しか知りませんが、本体プロジェクトが具象型 FeatureViewModelImpl を生成して渡すことで、依存関係が解決されます。Protocol の定義はパッケージに、実装の生成は本体プロジェクトに — この役割分担がパッケージ境界を成立させます。

この分離のもう一つの利点は、将来の拡張性です。Service 層や Repository 層のパッケージ化が進めば、ViewModel の実装クラスもいずれ Feature パッケージに移すことができます。今の時点では Protocol と実装を分けておくことで、将来その選択肢を確保しておけるということです。

手法3 — UIKit 依存の画面遷移を列挙型で集約する

UI 層が Feature パッケージに移ると、次の問題が浮上します。SwiftUI の画面からユーザーが「詳細を見る」「検索画面を開く」といった操作をしたとき、その遷移先がまだ本体プロジェクトに UIKit で実装されたままのケースです。依存の方向は本体プロジェクト → Feature パッケージの一方向なので、Feature パッケージから本体プロジェクトの画面を直接呼ぶことはできません。

このギャップを埋めるために、Feature パッケージで Action 列挙型を定義し、本体プロジェクトのクロージャで処理します。

public enum FeatureAction {
    case showDetail(Item)
    case showSearch
    case showSettings
}

public protocol FeatureViewModel: ObservableObject {
    var actionHandler: ((FeatureAction) -> Void)? { get set }
}

Feature パッケージが知っているのは「こういうアクションが起きうる」という列挙型の定義だけです。それをどう処理するかは、本体プロジェクトに委ねます。

viewModel.actionHandler = { [weak self] action in
    switch action {
    case .showDetail(let item):
        self?.navigationController?.pushViewController(DetailVC(item: item), animated: true)
    case .showSearch:
        SearchVC.present(from: self)
    case .showSettings:
        SettingsVC.push(from: self)
    }
}

UIKit に依存する処理はこの switch 文の中に集約されます。新しい遷移が増えたら enum にケースを追加し、switch にハンドリングを書くだけです。遷移先が SwiftUI 化されたら、対応する case の処理を SwiftUI 内の .navigationDestination) 等に移せばいい。enum の associated values によって遷移に必要なパラメータが型安全に保証されるため、実行時エラーのリスクも低くなります。

なお、このアクションハンドリングの処理を ViewController に直接書くのではなく、Coordinator に切り出して責務を閉じ込めるという選択肢もあります。画面遷移のパターンが多い画面では、そちらのほうが見通しが良くなるかもしれません。

手法4 — SwiftUI から UIKit の画面を呼ぶ

手法3は、主にナビゲーション(push)ベースの画面遷移で機能します。一方で、SwiftUI の .fullScreenCover().sheet() によるモーダル遷移は事情が異なります。

シートやフルスクリーンカバーによるモーダル遷移は、ナビゲーションの push/pop 階層から独立しています。つまり、理想的にはモーダルで表示する画面とその先をまるごと SwiftUI 化できる領域です。しかし現実には、.fullScreenCover() の遷移先にまだ UIKit の画面が残っていることがあります。

ここでの考え方は、遷移先が UIKit のままでも、呼び出し元の SwiftUI 化を止めないということです。UIKit の画面を UIViewControllerRepresentable でラップして SwiftUI から呼べるようにし、UIKit → SwiftUI → UIKit という「サンドイッチ」構造を移行の過渡期として許容します。

具体的な手法は3つのステップで構成されます。

UIKit 画面を Representable でラップする

まだ SwiftUI 化されていない UIKit の画面を、UIViewControllerRepresentable で薄くラップします。

extension LegacyDetailViewController {
    struct Representable: UIViewControllerRepresentable {
        let item: Item
        func makeUIViewController(context: Context) -> LegacyDetailViewController {
            .init(item: item)
        }
        func updateUIViewController(_ vc: LegacyDetailViewController, context: Context) {}
    }
}

このラッパーは本体プロジェクトに置きます。

Feature パッケージは遷移先を外から受け取る

ここが最も重要なポイントです。Feature パッケージの SwiftUI View は、遷移先の具体的な画面を自分では持たず、ジェネリクスの @ViewBuilder クロージャとして外部から受け取ります。

public struct FeatureRootView<Destination: View>: View {
    @ViewBuilder let detailDestination: (Item) -> Destination

    var body: some View {
        content
            .fullScreenCover(isPresented: $viewModel.showDetail) {
                if let item = viewModel.selectedItem {
                    NavigationStack { detailDestination(item) }
                }
            }
    }
}

Destination: View というジェネリクス制約だけがあり、具体的にどんな View(あるいは Representable)が来るかは知りません。Feature パッケージは本体プロジェクトのレガシー画面に一切依存していません。

本体プロジェクトで Representable を注入する

組み立ては本体プロジェクトが担当します。

let rootView = FeatureRootView(
    viewModel: viewModel,
    detailDestination: { item in
        LegacyDetailViewController.Representable(item: item)
    }
)
present(UIHostingController(rootView: rootView), animated: true)

レガシーな UIKit 画面への依存があるのは、この注入の一箇所だけです。

この構造の大きな利点は、遷移先が SwiftUI 化されたときの変更が最小限で済むことです。注入するクロージャの中身を差し替えるだけで、Feature パッケージのコードには一切触れる必要がありません。

detailDestination: { item in
    NewDetailView(item: item)
}

サンドイッチ構造はあくまで移行の過渡期のものであり、最終的には UIKit の層が消えて自然な SwiftUI のコードになります。

いつこの手法を使うか

この手法を使わず「遷移先もすべて SwiftUI 化してからでないと手を付けられない」と考えてしまうと、SwiftUI 化できる範囲がなかなか広がりません。たとえば画面 A から .fullScreenCover() で画面 B を表示していて、画面 B がまだ UIKit だとします。画面 A の SwiftUI 化は画面 B の完了を待つことになり、画面 B にも UIKit の遷移先があれば、さらにその先を待つ…と連鎖してしまいます。

ただし、すべてのモーダル遷移にこの手法を適用すべきというわけではありません。遷移先の UIKit 画面が少数であればラップして先に進めるメリットがありますが、遷移先の大半が UIKit であれば、ラップのコストが見合わないのでその画面の SwiftUI 化自体を後回しにする判断もありえます。ラップにかかるコストと、SwiftUI 化を先に進められるメリットを天秤にかけて判断することが重要です。

手法5 — 本体プロジェクトに依存する View コンポーネントを外から差し込む

手法1〜4で多くの UI を Feature パッケージに移せますが、もう一つ厄介なケースがあります。画面の一部に、本体プロジェクトの依存がないと成立しないコンポーネントが含まれている場合です。

典型的な例がインフィード広告です。広告の表示には広告 SDK への依存が必要ですが、これは本体プロジェクトにしかありません。だからといって、広告を含む画面全体を Feature パッケージに移せないとなると、移行が大きく停滞してしまいます。

考え方は手法4と同じです。Feature パッケージ側では「ここに何かの View が入る」というジェネリクスの枠だけを定義し、具体的な実装は本体プロジェクトから差し込みます。

Feature パッケージの View は、広告コンポーネントの表示位置をジェネリクスの @ViewBuilder パラメータとして受け取ります。

public struct FeatureTabView<VM: FeatureTabViewModel, AdContent: View>: View {
    @ObservedObject var viewModel: VM
    private let adContent: () -> AdContent

    public init(
        viewModel: VM,
        @ViewBuilder adContent: @escaping () -> AdContent
    ) {
        self.viewModel = viewModel
        self.adContent = adContent
    }

    public var body: some View {
        ScrollView {
            LazyVStack(spacing: 8) {
                SomeSection(viewModel: viewModel)
                adContent()
                AnotherSection(viewModel: viewModel)
            }
        }
    }
}

Feature パッケージはこの AdContent が何であるかを一切知りません。広告でもプレースホルダーでも EmptyView でも構わないという設計です。

本体プロジェクト側では、広告 SDK に依存する具体的な View を差し込みます。

let view = FeatureTabView(viewModel: viewModel) {
    InFeedAdSectionView(adType: .infeed)
}
let hosting = UIHostingController(rootView: view)

InFeedAdSectionView は本体プロジェクトにあり、内部で広告 SDK を使って広告をロード・表示します。Feature パッケージにはこの View の存在も広告 SDK の存在も見えていません。

この手法のポイントは、1つのコンポーネントが Feature パッケージに移せないからといって、画面全体のパッケージ移行を諦めないということです。移せない部分だけを抽象化して外から差し込めば、画面の大部分は Feature パッケージに移すことができます。

まとめ — 制約の中で前に進む

本記事で紹介した5つの手法を整理します。

手法 主に解決する課題 いつ不要になるか
手法1: UIKit に SwiftUI を埋め込む SwiftUI 化 NavigationStack 全面移行時
手法2: ViewModel の Protocol / 実装分離 マルチパッケージ化 Service 層のパッケージ化完了時
手法3: UIKit 依存処理の列挙型集約 マルチパッケージ化 UIKit 画面の SwiftUI 化完了時
手法4: SwiftUI から UIKit を呼ぶ SwiftUI 化 遷移先の SwiftUI 化完了時
手法5: 依存のあるコンポーネントを外から差し込む マルチパッケージ化 依存のパッケージ化完了時

重要なのは、すべての手法に「不要になる日」があるということです。これらは最終的なアーキテクチャではなく、移行期を乗り越えるための手法です。

これらの手法を組み合わせることで、通常の機能開発と並行しながら少しずつ SwiftUI 化とマルチパッケージ化を進められています。一気に大きな時間を確保しなくても、1画面ずつ、1コンポーネントずつ着実に移行を進めていける実感があります。

大規模アプリの SwiftUI 移行は、短距離走ではなくマラソンです。今日の制約の中で最善の一歩を選び、長い戦略スパンで着実に前に進めていく。本記事がその一助になれば幸いです。

Go 1.26で追加されたnew(expr)はなぜこの形なのか

Go 1.26で追加されたnew(expr)はなぜこの形なのか

こんにちは、開発1部の@uho-wqです。

本記事ではGo 1.26で追加されたnew(expr)がどのような議論の末にこの形に落ち着いたのかを説明しようと思います。

go.dev

new(expr)

Go 1.26で、組み込み関数newが式(expression)を受け取れるようになりました。

p := new(42)       // *int, 値は42
s := new("hello")  // *string, 値は"hello"
b := new(true)     // *bool, 値はtrue

とてもシンプルな構文追加に思えますが、実はこの結論に至るまで2014年から2025年までの11年もかかりました。

この記事では、以下の2つのissueをもとに議論の流れを追っていきます。

github.com

github.com

※ この記事を作成するにあたり、これらのissueに付いたコメントすべてに目を通しました。11年分の議論は非常に膨大なため本記事では要点を絞って紹介しており、解釈の違いや抜け漏れがある可能性がありますがご了承ください。

そもそも何が問題だったのか

Goではcomposite literalは直接ポインタを取得できますが、プリミティブ型は宣言時にポインタを得ることができません。

p := &Point{X: 1, Y: 2} // OK: composite literalは&を取れる

p := &42      // コンパイルエラー: cannot take address of 42

よって従来では以下のように一度変数に代入してポインタを得る書き方をするか、ヘルパー関数を定義するしかありませんでした。

v := 42
p := &v

// ヘルパー関数
func IntPtr(v int) *int {
    return &v
}

例えば、AWS SDK for Goではaws.String()aws.Int64()といったヘルパー関数が大量に定義されています。構造体の値をaws.String()で囲むといった作業はAWS SDK for Goを使ったことがある方は経験済みなのかなと思います。

Go 1.18でGenericsが導入されたことによって、ヘルパー関数を汎用的に記述することができるようになりました。

func Ptr[T any](v T) *T {
    return &v
}

しかし、これもcomposite literalのみ直接ポインタを取れるという問題の回避策にはなりましたが、根本解決には至りませんでした。


こうした背景から、言語レベルでの解決策が長年にわたって議論されてきました。以降では、その議論がなぜ最終的にnew(expr)という形に落ち着いたのかを時系列で追っていきます。

proposal: spec: add &T(v) to allocate variable of type T, set to v, and return address #9097

2014年11月にchai2010氏により最初の提案が行われました。

提案は、以下の2つの構文を追加する、というものでした。

  1. new関数の拡張: func new(Type, value ...Type) *Type
  2. &Type(value)構文の追加

例:

px := new(int, 9527)
px := &int(9527)

当初は大きな反響もなくissueは放置されていましたが、2018年にIan Lance Taylor氏が提案に再度言及しました。

&int(5)を許すならnew(int, 5)は不要であり、newを完全に削除することすら検討すべきだと述べました。そして任意の式に&を適用する際の問題点を2つ指摘しています。

  • 1つ目は任意の式vに対して&vを取れるとした場合、論理的にはアドレスのアドレス&&vを取れるべきだが、&&は異なる意味を持つ演算子なので動作しない
  • 2つ目は&varはループ内で呼び出しても毎回同じ値に解決されるが、&exprは毎回新しいインスタンスを確保するので異なる値に解決される

また2020年には、Ian Lance Taylor氏自身が「ジェネリクスが入れば新しい言語機能を必要としないので、ジェネリクスを得るまで待って、そのようなアプローチが十分かどうかを見たいと思う」とも述べています

結局#9097は2023年8月に#45624を優先する形でクローズされました。9年間で40件のコメントが寄せられ、Ian Lance Taylor氏が提示した論点は#45624でも継続して議論されます。

spec: expression to create pointer to simple types #45624

2021年4月にRob Pike氏によってissueが立てられました。

Pike氏はissueを再オープンする代わりに、新たに2つの選択肢を提示しました。

Option 1: newに第2引数を追加する

p1 := new(int, 3)
p2 := new(rune, 10)
p3 := new(Weekday, Tuesday)

Option 2: 型変換の結果をアドレス可能にする

p1 := &int(3)
p2 := &rune(10)
p3 := &Weekday(Tuesday)

Pike氏は「両方入れてもいいかもしれない」とも述べています。

注目すべきは、この時点では最終形となるnew(expr)はまだ提案されていなかったということです。Pike氏の提案はあくまでnew(T, v)(型と値の2引数)と&T(v)の2択でした。

new(1)の提案 (2021年4月)

Pike氏の提案から数日後、Go Teamの Russ Cox氏のコメント が多くの賛同を得ました。

The overloading of & for "take address of existing value" and "allocate copy of composite literal" has always been unfortunate. An alternative to expanding the overloading of & would be to overload new instead, so that it is the generic ptrTo function as well as the original new(T), as in new(1). Then &T{...} can be explained retroactively as mere syntactic sugar for new(T{...}).

&演算子は既に「既存の値のアドレスを取得する&v」と「composite literalのコピーを割り当てる&T{...}」という2つの異なる意味を持っています。ここにさらに意味を追加するのではなく、newを拡張してnew(1)のように書けるようにすべきではないか。そうすれば&T{...}new(T{...})の糖衣構文として説明できる、という主張です。

これが最終形new(expr)の原型でした。

ジェネリクスの提案 (2021年4月)

一方でRoger Peppe氏は言語変更そのものに異を唱えました

Given this possibility, I don't see that there's any need to change new or the language syntax itself to accommodate this functionality.

Goのジェネリクスを使えば以下のように書けるのだから、newや言語仕様自体を変える必要はないのでは、というものです。

// ref returns a pointer to the value of t.
func ref[T any](t T) *T {
    return &t
}

このジェネリクス案は、その後4年にわたって繰り返される反論の原型となりました。

膠着状態 (2021年9月)

2021年9月、Ben Hoyt氏が議論の停滞を指摘し、再検討を求めました。

Looks like this was last discussed in the proposal review meeting on May 5. While there's no clear consensus here, there are a number of good options. It seems like there's a fair bit of enthusiasm for Russ's simple new(1) form, and a decent amount of support for a new builtin generic function like Roger Peppe's ptr(1) suggestion. My vote would be for ptr(1) as it just uses "ordinary" generics, but I like new(1) too. Could this be discussed at the review meetings again?

この時点で支持が集まっていたのはnewの拡張であるnew(1)とジェネリクスを使用したptr(1)の2案でしたが、コンセンサスには至りませんでした。ジェネリクスの正式リリース(Go 1.18、2022年3月)を待つ形で、議論は一時休止に入ります。

PtrTo[T any] vs &T(v) vs newの拡張 (2023年6月)

2023年6月、Go TeamのIan Lance Taylor氏がissueに戻り、選択肢を3つに絞りました

  1. PtrTo[T any]のような標準ライブラリ関数
  2. &T(v)構文
  3. new(v) / new(T, v) の拡張

そしてGo Teamの立場を明確にしました。

@griesemer, @bradfitz, and @ianlancetaylor prefer permitting both new(v) and new(T, v).

この時点では、Go Teamの主要メンバー3人がnew拡張を支持していました。

ただしnew(v)new(T, v)両方を許可する案であり、new(v)単独ではありませんでした。


また、依然として&T(v)を支持する声はあったものの、批判的な意見も支持されるようになってきました。Ben Hoyt氏の主張が端的に示しています。

I slightly prefer new(v) over &T(v) because it eliminates stuttering in cases like new(time.Now()) -- that would be &time.Time(time.Now()) with the other syntax. If new(T, v) is supported in addition for clarity in certain cases, that's fine. new() is also a bit clearer that it always creates a "new" thing.

new(time.Now()) のようなケースだと冗長な繰り返しがなくなりますが、&T(v)の構文だと &time.Time(time.Now()) になってしまいます。明確さが必要な場合に new(T, v)が追加でサポートされるのは問題にはならず、new() は常に「新しい」ものを作成することがより明確である、と主張しています。

さらにHoyt氏も&演算子が概念的に同等でないことも指摘しています。

When you do &Struct{} Go creates a new value every time and returns its address, but when you do &s Go returns the address of that same variable each time.

&Struct{}を行うと、Goは毎回新しい値を作成してそのアドレスを返しますが、&sを行うとGoは毎回その同じ変数のアドレスを返します。


この後も&T(v)案は依然として支持されるものの、議論の焦点はnewの拡張方法とジェネリクスの活用に移っていきます。

new(T, v) vs new(v) (2023年7月)

Goチームが支持しているnewの拡張方法はnew(T, v)new(v)の2パターンありました。

2023年7月、Ian Lance Taylor氏が方針転換を報告しました。Rob Pike氏とRoger Peppe氏などから「new(v)new(T, v)の両方ではなく、new(T, v)のみにすべき」という意見が出ました。

また型名が長くなるケースの大半は構造体であり、構造体には既に&S{}表記があります。単純な値vに対して複雑な型Tを書くnew(T, v)のケースはそもそもほとんど発生しないと考え、new(T, v)でも混乱を招くことは少ないだろう、という見解を示しています。

これに対してMerovius氏が具体例で切り返しました。

new(int64(42)) isn't any more to type or read than new(int64, 42), but new(time.Second) is significantly better than new(time.Duration, time.Second). I don't think having the type in there really adds anything. We are already kind of used to inferring the type from a constant literal.

new(int64(42))new(int64, 42)と比べてタイプ量も読む量も変わりませんが、new(time.Second)new(time.Duration, time.Second)よりもはるかに良いです、と述べています。

このコメントが賛同を集めた一方で、new(v)を見たときにvが型なのか値なのかを読者が把握している必要があるのでnew(v)を好まない、という意見も複数ありました。


new(T, v)は書き方として冗長である一方で明確に記述でき、new(v)は書き方として簡潔である一方で表現として曖昧であるとし、この時点ではコンセンサスには至りませんでした。

ジェネリクスの限界 (2023年 - 2024年)

「ジェネリクスでPtr[T]が書ける」という反論は依然として主張されていました。

しかし2023年12月、Rob Pike氏が改めてこの問題の本質を言い直しています

it's easier to build a pointer to a complex thing than to a simple one.

「複雑な構造体へのポインタは&T{...}で簡単に作れるのに、単純なintへのポインタは面倒」

ジェネリクスを用いたヘルパー関数を書くことは、この非対称性の問題の根本的な解決にはなっていないと言及しています。

ジェネリクスが根本の解決になっていないとするエピソードとして、perj氏の体験談が象徴的でした。

I appear to be writing this function about once every second month, when I need it in a new package. It's not very annoying, but does feel a bit like I'm littering my packages with this function, so not having to write it would be welcome. I do realise I can put it in a package I import, but that also seems overkill for a one-liner.

  • 2ヶ月に1度、新しいパッケージでこのヘルパー関数を書いている
  • パッケージをこの関数で散らかしているような感じがするので、書かなくて済むなら歓迎
  • importするパッケージに入れることもできるが、たった1行のコードのためにそれをするのはやりすぎな気がする

このコメントは20ものGood評価を集めており、ジェネリクス案の限界を端的に示しているといえます。

new(T, v)は解決策にならない (2025年3月-8月)

2025年3月、かつて「ジェネリクスで十分」と主張していたRoger Peppe氏が、new(T, v)案に対して批判を投じました。

Replacing, for example, ref(someMap[x]) with new(SomeType, someMap[x]) would be a net loss because it makes the code more verbose and a little bit more fragile, requiring update should the type of the map's values change.

ref(someMap[x])new(SomeType, someMap[x])に書き換えるのはコードが冗長になるだけでなく、mapの値の型が変わるたびに修正が必要になる。

型を2回書くnew(T, v)では、ジェネリクスのヘルパー関数からの移行メリットがない、という指摘です。


その後、2025年8月にGo TeamのAlan Donovan氏が決めてとなるコメントを投じました。

it is important not to have to redundantly state the type and the value, making new(T, v) a non-solution.

型と値を冗長に並べる必要がないことが重要であり、new(T, v)は解決策にならない、と主張し、Donovan氏は3月のPeppe氏のコメントに納得してnew(value)を支持する立場を表明しました。

デフォルトの型が合わない場合はnew(T(v))とキャストを組み合わせればよく、new(T, v)のような複雑なルールは不要だ、としています。

The proposal committeeの承認 (2025年8月)

2025年8月15日、The proposal committeeを代表してAustin Clements氏が宣言しました。

The proposal committee is happy with new(expr).

new(T)(型を渡す)とnew(expr)(式を渡す)は動作が異なり、構文的な曖昧さを欠点として持つものの、どちらも「新しいストレージを確保して返す」点で一貫しています。

そしてDonovan氏が収集したデータが決め手となりました。

the data @adonovan collected indicates that, while this can be written as a generic function, there are so many instances that it seems well-worth a standardized built-in.

ジェネリクスを用いた関数として記述することも可能ですが、その利用箇所が非常に多いため、標準化された組み込み関数として実装する価値は十分にある、としています。

Accepted (2025年9月)

2025年9月17日、Austin Clements氏が正式に採択を宣言しました。

そして2025年10月27日、実装を完了したAlan Donovan氏がissueを締めくくりました

All done, in only eleven years since #9097. ;-)

Go 1.26での仕様

The Go Programming Language Specificationでは、newは以下のように定義されています。

The built-in function new creates a new, initialized variable and returns a pointer to it. It accepts a single argument, which may be either an expression or a type.

引数が型Tの式(または、デフォルト型がTのuntyped定数式)である場合、new(expr)は型Tの変数を確保し、exprの値で初期化し、そのアドレス(型*Tの値)を返します。

type Config struct {
    Timeout *time.Duration
    Retries *int
    Verbose *bool
}

cfg := Config{
    Timeout: new(30 * time.Second),
    Retries: new(3),
    Verbose: new(true),
}

関数の戻り値も渡せます。

p := new(time.Now())       // *time.Time
q := new(strconv.Itoa(42)) // *string

注意点: untyped constantの挙動

ただし1つ注意点があります。new()に定数を渡した場合、default typeが使われます。

var ui uint = 10 // OK: untyped constant 10はuintに暗黙変換される

// しかし...
uip := new(10)      // *int(10のdefault typeがint)
var ui2 uint = *uip  // コンパイルエラー: cannot use *uip (type int) as type uint

定数10がそのまま変数宣言で使われる場合はuntyped constantとして柔軟に型推論されますが、new(10)の時点で*intに確定してしまいます。明示的な型が必要な場合は型変換を組み合わせましょう。

uip := new(uint(10)) // *uint

まとめ

最後に、11年の議論で登場した各提案の結論についてまとめます。

提案 結論
&T(v) &演算子の意味の不連続性。&2は毎回新しいアドレスを返すが&vは同じアドレスを返す。混乱を招く
ref(v) / ptr(v) ジェネリクスで1行で書ける。だが逆に「全員が書いている」。組み込みとして標準化する方が合理的
new(T, v) 冗長。new(time.Duration, time.Second)はジェネリクスのref(time.Second)より後退する
new(expr) 採用。 &のセマンティクスを変えず、既存のnew関数の自然な拡張

個人的には、議論全体を通してnew(expr)という結論に至ったことがとても腑に落ちました。ジェネリクスの導入を見越して一度議論を止め、導入後も便利さに飛びつかず実運用の課題を吸い上げた上で、本質的な解決策に辿り着いています。

最終形のnew(expr)は、2021年にRuss Cox氏が投じたnew(1)の発想そのものでした。4年の間に&T(v)new(T, v)が検討され、結局最もシンプルな案に戻ってきたのが面白いなと思いました。

UIKit アプリに Liquid Glass の検索タブを実装する

1. はじめに:Liquid Glass で変わる「検索」の体験

WWDC25 で発表された Liquid Glass は、iOS 26 の目玉となるデザインシステムです。ナビゲーションバーやタブバーがガラスのような半透明素材になり、コンテンツがその裏側に透過して見えるようになります。

見た目の変化も大きいですが、Liquid Glass がアプリの体験として特に大きく変えたのは検索です。iOS 26 では、タブバーの右端に検索アイコンが配置され、タップするとタブバー自体が検索フィールドに変わります。設定アプリや App Store など Apple 純正アプリではこの新しい検索 UI が標準になっており、ユーザーはどのアプリでも同じ操作で検索にアクセスできるようになりました。

これはサードパーティアプリにとっても重要な変更です。この新しい検索パターンを採用することで、iOS の標準的な検索体験と統一感のある UI を提供できます。

Liquid Glass の対応ポイントは多岐にわたりますが、この検索タブの変更は特にユーザー体験への影響が大きいと感じたため、既存の UIKit アプリでどう実現するかを調べて実装してみました。

SwiftUI であれば、検索タブの Liquid Glass 対応は驚くほど簡単です。

TabView {
    Tab("ホーム", systemImage: "house") { HomeView() }
    Tab(role: .search) { SearchView() }
}

Tab(role: .search) の 1 行で、タブバーの右端に検索アイコンが分離配置され、タップするとタブバー内に検索フィールドが展開する動きを実現できます。

この記事では、UIKit の UITabBarController + UINavigationController を基盤とした既存アプリに Liquid Glass の検索タブを適用する方法を紹介します。

2. ゴール:タブバー内に検索フィールドが展開する UI

実装するのは以下の動きです。

  1. タブバーの右端に検索アイコンが配置される
  2. 検索アイコンをタップすると、タブバー内に検索フィールドがスライドして展開する
  3. テキストを入力して検索できる
  4. キャンセルすると元のタブバー表示に戻る

これは iOS 26 の設定アプリや App Store で見られる標準的な動きで、UISearchTab というクラスを使って実現します。

iOS 18 で UITab API が導入されました。従来の viewControllers 配列ベースのタブ管理に代わり、UITab オブジェクトを使ってタブを構成する新しい方式です。

UISearchTabUITab のサブクラスで、検索専用のタブを表します。SwiftUI の Tab(role: .search) に対応する UIKit のクラスです。

@available(iOS 18.0, *)
private func setupWithUITabAPI() {
    // 検索以外のタブを UITab として生成
    var uiTabs: [UITab] = nonSearchItems.map { item in
        UITab(
            title: item.title,
            image: item.tabImage,
            identifier: item.identifier
        ) { _ in
            item.controller
        }
    }

    // 検索タブは UISearchTab を使う
    let searchTab = UISearchTab { [weak self] _ in
        guard let self else { return UIViewController() }
        return TabBarItemType.search.controller
    }

    uiTabs.append(searchTab)
    tabs = uiTabs
}

UISearchTab のポイントは以下の通りです。

  • タイトルや画像の指定が不要です。システムが検索アイコンを自動で提供します
  • タブバーの右端(trailing 端)に自動で分離配置されます。他のタブとは異なる位置に置かれます
  • tabs プロパティに設定するだけで、タブバー内の検索フィールド展開がシステム標準で動作します

4. 検索 ViewController の対応

UISearchTab を設定すると、タブバーに検索アイコンが表示されます。しかし、タップしたときに検索フィールドをタブバー内に展開させるには、ViewController 側の対応も必要です。

UISearchTab がタブバー内で検索フィールドを展開するには、検索コントローラーが navigationItem.searchController に設定されている必要があります。

navigationItem.searchController = searchController
navigationItem.hidesSearchBarWhenScrolling = false

navigationItem.titleView にカスタム配置した検索バーでは、UISearchTab のタブバー展開とは連携しません。既存のアプリで titleView ベースの検索バーを使っている場合は、navigationItem.searchController への移行が必要です。

5. iOS バージョンだけでなく Info.plist も見て分岐する

サンプルコード中で使用している LiquidGlassAvailability.isEnabled について説明します。

iOS 26 以降でも UIDesignRequiresCompatibilitytrue に設定している場合、アプリは Liquid Glass ではなく従来のデザインで表示されます。この場合、UISearchTab を使ったタブ構成にすると見た目と挙動が噛み合わなくなります。

そこで、iOS バージョンと Info.plist のフラグを両方チェックするヘルパーを用意しました。

enum LiquidGlassAvailability {
    static var isEnabled: Bool {
        guard #available(iOS 26.0, *) else {
            return false
        }
        // UIDesignRequiresCompatibility が true なら互換モード → Liquid Glass 無効
        if let requiresCompatibility = Bundle.main.object(
            forInfoDictionaryKey: "UIDesignRequiresCompatibility"
        ) as? Bool, requiresCompatibility {
            return false
        }
        return true
    }
}

判定ロジックは以下の通りです。

条件 isEnabled
iOS 25 以前 false
iOS 26+ / UIDesignRequiresCompatibility = true false
iOS 26+ / UIDesignRequiresCompatibility 未設定 or false true

なぜこれが必要か

UIDesignRequiresCompatibility は、Liquid Glass への移行を段階的に進めるための Apple 公式の仕組みです。Info.plist にこのキーを true で設定すると、iOS 26 でもアプリは従来のデザインで表示されます。つまり「iOS 26 以降 = Liquid Glass」ではないのです。

開発中は Liquid Glass を有効にして動作確認し、問題があればこのフラグを true にして一時的に互換モードに戻す、という使い方ができます。コード側もこのフラグに連動して分岐しておけば、フラグひとつで Liquid Glass の ON/OFF を切り替えられます。

Liquid Glass 対応は多岐にわたるため、ある程度長い開発期間が必要になると考えられます。他の施策の開発と並行して進められるように、このような仕組みを用意しました。

6. まとめ

UIKit アプリで Liquid Glass の検索タブを実装するための要点を整理します。

  1. UISearchTab を使うUITab のサブクラスで、SwiftUI の Tab(role: .search) に対応する UIKit のクラスです。タイトルや画像は不要で、タブバー右端への分離配置と検索フィールドの展開がシステム標準で動作します。

  2. 検索バーは navigationItem.searchController に設定するnavigationItem.titleView にカスタム配置した検索バーでは、UISearchTab のタブバー展開と連携しません。

  3. iOS バージョンだけでなく UIDesignRequiresCompatibility も見る#available だけでは不十分です。Info.plist のフラグと組み合わせた LiquidGlassAvailability.isEnabled を用意し、Liquid Glass の ON/OFF にコードが追従するようにしました。

  4. #available で後方互換を維持するUITab API は iOS 18+、automaticallyActivatesSearch は iOS 26+ です。既存の viewControllers ベースのコードと UITab API ベースのコードを分岐で共存させます。

SwiftUI への全面移行を待たなくても、UIKit アプリに段階的に Liquid Glass を取り入れることは可能です。UISearchTab はその第一歩として取り組みやすい対応だと思います。

エンジニアが仕様書を書くことで、AI開発の設計・実装を速くしたい

はじめに

こんにちは、開発1部で食事管理アプリ「ヘルシカ」の開発をしている新谷です。

apps.apple.com

社内でAIツールを使って開発を進める中で、個々のタスクは確実に速くなっているものの、開発フロー全体としてはまだ思ったほど生産性が上がっていないと感じています。この記事では、その原因を分析し、「エンジニアが仕様書を主導して書く」という開発フローの改善に取り組んだ話を紹介します。

現状の開発フローと課題

これまでの開発フロー

ヘルシカチームでは、以下のような流れでプロダクト開発を行っています。

施策立案 → 認識合わせ → デザイン作成 → 設計 → 実装 → コードレビュー → QA → リリース

認識合わせのために、PdMがPRD(Product Requirements Document)を作成しています。PRDの構成は大まかに以下のような形です。

## 背景・目的・仮説
なぜこの施策をやるのか(Why)

## 分析・検証
確認したい指標(定量面)

## 要件・仕様
ユーザーは〇〇にアップロードした画像を保存できる(What)

このPRDには、WhyとWhatが書かれています。これをもとにチーム全員で認識を合わせ、担当のエンジニアがシステム設計(How)を作成して実装に入ります。

タスク規模によるボトルネックの違い

AIツールの導入によって、小さなタスクでは設計・実装がボトルネックになりにくくなりました。むしろ認識合わせやコードレビューの方が相対的にボトルネックになってきています。

しかし、中規模〜大規模なタスクでは、依然として設計・実装がボトルネックです。具体的には以下の2つの課題があります。

  • 設計の課題
    • PRDの仕様からシステム設計に落とすのに時間がかかる
    • 仕様の考慮漏れや認識齟齬が設計段階で発覚し、手戻りが発生する
  • 実装の課題
    • AIが生成したコードの確認・修正に時間がかかる

課題の深掘り

実装の課題について掘り下げると、AIが期待通りのコードを出せない原因の多くは、設計書(= AIへのプロンプト)の不備に行き着きます。

もちろん、AIが力を発揮するための環境整備(リンターや自動テストの整備、ルールファイルの充実、既存コードの品質改善など)も大切です。しかし、これらを整えたとしても、不備のある設計書を渡せば不備のある実装が出てきます。

つまり、設計フェーズで短時間かつ考慮漏れのない設計を作れるかが、AI時代の開発生産性を左右するポイントだと考えました。

では、設計の課題をもう少し細かく見てみます。

  1. 設計に時間がかかる:技術的知識やドメイン知識に基づく設計力・スピードに依存する
  2. 手戻りが発生する:PRDの仕様に考慮漏れがあり、設計や実装の段階で初めて問題が発覚する

この2つの課題を解決するために、「エンジニアがPRDの仕様(What)を主導して作成する」というアプローチを考えました。

エンジニアが仕様を主導する

なぜエンジニアが仕様を書くのか

エンジニアは実際のコードベースを理解しています。そのため、仕様を考える段階で「この機能を実現するには、既存の〇〇の処理にも影響がある」「この条件分岐は仕様として明確にしておく必要がある」といった技術的な観点を織り込めます。

これにより、仕様段階での考慮漏れが減り、後続のシステム設計で大きな手戻りが発生しにくくなります。結果として、設計にかかる時間も短縮されます。

また、エンジニアが仕様を引き受けることで、PdMはマーケティングや数値分析など、本来注力すべき領域により多くの時間を使えるようになるのではないかと考えています。

AI時代ならではのメリット

さらに、AI時代ならではのメリットもあります。エンジニアが仕様書を作ることで、「どういう仕様書を書けば、後段のシステム設計書を楽に作れるか」「どういう粒度で書けば、AIで設計書の生成を自動化できるか」といったPDCAを回しやすくなります。

仕様書のフォーマット自体を改善していくことで、仕様書→システム設計書→実装の一連の流れを最適化できる可能性があります。

AI-DLCとの共通点

この考え方は、AWSが提唱しているAI-DLC(AI-Driven Development Life Cycle)のINCEPTION PHASE(AIと要件を深掘りして決めていくフェーズ)に通じるものがあります。AI-DLCでは、最初のフェーズでPdM・デザイナー・エンジニアが一緒に要件を詰めていくことを推奨しており、今回の取り組みと方向性が近いと感じています。弊社でも別チームがAI-DLCを試した事例があるので、興味のある方はこちらの記事もご覧ください。

難しい点

一方で、エンジニアが仕様作成を主導するには、PdMと同じくらいの解像度でビジネスや数値を理解する必要があります。これは簡単ではありません。

しかし、AIによって職種間のオーバーラップが進む中、エンジニアにもビジネス理解が強く求められるようになっていくと考えています。仕様を主導することは、エンジニアのスキルセットを広げる機会にもなるのではないかと思っています。

新しい開発フロー

最終的に、以下のフローに変更しました。

  1. PdMがPRDのWhy(背景・目的・仮説)を作成
  2. PdM・デザイナー・エンジニアで認識合わせ → エンジニアが主導してWhat(仕様)を詰める
  3. エンジニアが仕様書を作成
  4. 仕様書のレビュー
  5. エンジニアがシステム設計書(How)を作成
  6. 実装

これまでシステム設計書はドキュメントとして残すことを必須としていませんでしたが、仕様書→システム設計書の変換をAIで自動化するPDCAを回すために、今回から残すことを必須としました。

実際の成果物の例

具体例として、「プレミアム機能無料開放」施策での成果物の一部を紹介します。

まず、PdMが作成したPRDの一部です。施策の背景・目的・仮説が記載されています。

## 概要
新規ユーザーに対し、プレミアム機能を一定期間無料開放することで、
アプリの価値を早期に体験してもらう。

## 仮説
新規ユーザーはプレミアム機能がロックされているため、
アプリの価値を体験できず、継続利用につながっていない。

次に、エンジニアが作成した仕様書の一部です。PdMとの認識合わせを経て、具体的な仕様に落とし込んでいます。

## 無料開放期間
- 期間: 初回ホーム到達からx日間

## 機能要件
### プレミアム機能の無料開放
- 無料開放期間中、全ユーザーがプレミアム機能を利用できるようにする

### バナー表示(ホーム画面)
- 対象: 非プレミアムユーザーのみ
- 無料開放期間中: 無料開放中であることを示すバナーを表示
- 無料開放期間終了後: 終了のアナウンスに切り替え

そして、この仕様書をもとに作成したシステム設計書の一部です。

## 設計方針
### 意味の分離
フリーミアム導入により、「課金しているか」と「プレミアム機能を使えるか」の
意味が同じではなくなるので、コード内でこれらを分離する。

| プロパティ         | 意味               | 用途               |
| isPremium          | 実際に課金しているか | ログ送信パラメータ |
| freemiumLastDateTime | フリーミアム期間終了日時 | バナー表示       |
| hasFullAccess      | プレミアム機能を使えるか | UI表示・遷移制御 |

試してみての所感

この取り組みはまだ実験段階で、試し始めて2週間ほどです。

よかった点

  • エンジニアがWhatの段階から積極的に介入できる構造になった
  • 仕様書→システム設計書→実装の流れで、エンジニア同士の議論が増えた
  • システム設計書に着手する段階で、大きな手戻りは発生していない

課題

  • タスクの並列度が上がり、頭の切り替えコストが高い
    • これまでは「実装」と「認識合わせMTG・コードレビュー」の並行だったが、そこに「次のタスクの仕様書作成」が加わる
    • 実装中のタスクと仕様を書くタスクはまったく別の内容なので、コンテキストスイッチが頻繁に発生する
  • 仕様書にどこまで書くべきかの基準がまだ定まっていない
    • どの粒度で書けば後段のシステム設計書や実装がAIで精度よく出てくるのか、引き続き検証が必要
  • 正直なところ、現時点では開発速度が上がったという実感はまだない
    • ただし、仕様書・設計書をドキュメントとして残す運用にしたことで、「何を書けばAIの出力精度が上がるか」を振り返れる状態にはなった。この改善サイクルを回していくことに意味があると考えている

まとめ

AIツールによって個々のタスクの開発速度は向上していますが、中規模以上のタスクでは設計・実装がボトルネックになっています。この課題に対して、エンジニアがPRDの仕様(What)を主導して書くというアプローチを導入しました。

まだ試し始めたばかりで、タスク並列度の増加や仕様書の粒度の最適化など、新たな課題も見えてきています。

今後は仕様書のフォーマットを改善しながら、仕様書→システム設計書のAI自動生成についてもPDCAを回していきたいと思います。