はじめに

Swift Package Manager(以下 SPM)でアプリをマルチモジュール化していくと、ほぼ必ず「Build Configuration をどう扱うか」という問題にぶつかります。Debug と Release だけでなく、Develop や Staging といった構成を足したくなります。すると、#if DEBUG がパッケージ側で意図どおりに効かない、環境ごとの設定値をどこに置くべきか分からない、といった悩みが出てきます。

躓きの原因は、Xcode の Build Configuration と SPM の configuration がそもそも別物であるという点にあります。この前提を押さえないまま「Xcode で構成を増やせば SPM も追従してくれるだろう」と考えると、パッケージだけ挙動が変わって原因が分からなくなります。

本記事では、この問題を次の3点に分けて整理します。

  1. SPM での debug 判定の実際 — パッケージの #if DEBUG は何で決まるのか
  2. Build Configuration を debug/release に寄せる方針 — SPM に見せる軸をどう絞るか
  3. アプリから注入する — 環境差はコンパイル時ではなくランタイムでどう扱うか

さらにその前段として、「そもそもなぜ SPM でモジュールを分けるのか」という動機を、AI コーディングエージェント時代のハーネスエンジニアリングの観点から補足します。

なお、本記事で扱う SPM の挙動(Configuration 名による判定や、カスタム条件がパッケージに伝播しないことなど)は Apple が公式に文書化しているものではありません。記載はいずれも Xcode 27 / Swift 6.4 での実測に基づきます。バージョンによって挙動が変わる可能性がある点はご留意ください。

なぜローカルパッケージに実装を閉じ込めるのか — ハーネスの観点

Build Configuration の話に入る前に、そもそも実装をローカルパッケージへ切り出す理由を確認しておきます。ここが本記事全体の動機になります。

コンパイル時に強制される依存境界

単一のアプリターゲットにすべてを詰め込むと、import は無制限です。UI 層からネットワーク実装を直接呼ぶといった規約違反も、コンパイラは止めてくれません。アーキテクチャのルールは「規約」と「レビュー」に頼ることになります。

これをローカルパッケージへ分割すると、各ターゲットが import できるのは Package.swiftdependencies: に宣言したモジュールだけになります。宣言していないモジュールを import するとコンパイルエラーになります。つまり依存グラフが、レビューではなくコンパイラによって強制されます。加えて、モジュールを跨いで見えるのは public を付けた API だけなので、公開面も意図的に絞れます。

ただし、この強制が効くのは自作モジュールや外部パッケージに対してです。UIKit / SwiftUI / Foundation などのシステムフレームワークは SDK 由来なので、dependencies: に宣言しなくてもどのターゲットからでも import できます(ビルド対象のプラットフォームに存在する限り)。したがって「ドメイン層に UIKit を持ち込ませない」といった制約は、SPM の依存宣言だけでは強制されません。規約や、UIKit の無いプラットフォームでもビルドさせるといった別の仕組みで担保する必要があります。

AI コーディングエージェント時代のガードレール

この「コンパイル時の依存境界」は、AI コーディングエージェントを使う開発でより重要になります。

Birgitta Böckeler の記事 Harness engineering for coding agent users(martinfowler.com, 2026-04-02)では、エージェントを取り巻く仕組み全体を Agent = Model + Harness と捉え、そのなかのガードレールを「エージェントに有害な操作を取らせないための決定論的なルール」と位置づけています。同記事は、モジュール境界の逸脱を検出する仕組みとして ArchUnit のような構造テストを「最も信頼できる層」に挙げています。

Swift ではここを一段強くできます。ArchUnit が「テストを走らせて事後に検出する」のに対し、SPM のターゲット依存宣言では、境界違反はそもそもビルドが通りません。エージェントに「アーキテクチャを守れ」とプロンプトで指示するのは確率的(守られないことがある)ですが、SPM の依存宣言は書いた瞬間にコンパイラが弾く決定論的なガードレールになります。事後検出から事前不可能化への格上げ、と言い換えてもよいでしょう。

もう一段踏み込むと、実装を protocol(インターフェース)の裏に隠すと、その中身である具体的な型を組み立てられるのはアプリ本体だけになります。分割したモジュールを最後に一か所へ束ねる場所——composition root(合成の起点。main module とも呼ばれます)——がアプリだからです。

そのため、「依存をコンパイル時に絞る」ことと「実装をアプリから注入する」ことは、別の手法ではなく同じ設計方針の上にあります。境界を絞るほど結線はアプリ一か所に集まり、その場所で環境ごとの差も注入できます。具体的なやり方は後半の「環境差と分岐をどう扱うか」で扱います。

こうした強い依存限定を得る代わりに、Build Configuration の扱い(debug 判定・環境差)という実務上の課題が生まれます。以降ではそこを詰めていきます。

SPM が Build Configuration をどう見るか

SPM の debug/release がどう決まり、それに合わせて構成をどう設計するかを見ていきます。

前提:SPM は debug/release の2値しか知らない

まず前提となる仕様を確認します。SPM が理解する configuration は .debug.release2値だけです。コマンドラインでも swift build -c debug / swift build -c release の二つしかありません。カスタムの構成名を SPM に理解させる仕組みは、そもそも用意されていません。

では、Develop や Staging といった構成を切ったアプリを Xcode でビルドしたとき、パッケージはこの2値のどちらでビルドされるのでしょうか。その答えが、次節の debug 判定です。

debug 判定は「Configuration 名」で決まる

パッケージの #if DEBUG が効くかどうかは、実は Build Configuration の名前で決まります。

名前で .debug / .release が決まる

Xcode は Build Configuration の「名前」を見て、パッケージのビルドモードを決めます。名前に debug または development を含めば(大文字小文字は無視).debug、含まなければ .release です。

Build Configuration 名パッケージのビルドモード
Debug / Development / Debug_Staging / _development_.debug
Release / Dev / Staging / Prod / Beta.release

判定は大文字小文字を無視した単純な部分文字列一致です。ここに罠が2方向あります。

  • ひとつは、DevStaging、そして Develop 単体(Development を完全には含まない)が .release になること。「開発用の構成のつもりなのに、パッケージだけ最適化ビルドされ #if DEBUG が効かない」という事故はこれが原因です。
  • もうひとつは逆で、Not_DEBUG のような「意味的には非デバッグでも文字列 debug を含む」名前が .debug になってしまうこと。実際にサンプルで Not_DEBUG 構成をビルドすると、アプリ側で DEBUG を立てていないのにパッケージの #if DEBUG が有効になります(実行結果は後述)。

パッケージに渡るコンパイル条件

Xcode がパッケージをビルドするとき、自動で定義されるコンパイル条件は次のとおりです。

  • SWIFT_PACKAGE — SPM ビルドで常に定義される
  • DEBUG — configuration が .debug のときだけ定義される

一方、アプリターゲット側の SWIFT_ACTIVE_COMPILATION_CONDITIONS に足した条件は、STAGING などのカスタム条件はもちろん DEBUG も含めて、パッケージには一切伝播しません

ここは誤解しやすいところです。パッケージで #if DEBUG が有効になるのは、アプリが DEBUG を渡したからではありません。パッケージ自身が .debug でビルドされるときに、SPM が DEBUG を定義する——これが唯一の経路です。そして .debug かどうかは、前述のとおり Build Configuration の名前で決まります。つまりアプリ側の DEBUG とパッケージ側の DEBUG は独立した別経路で、名前を整えたときに結果として一致するだけです(この独立性は、後述の Staging_Trap と Not_DEBUG のスクリーンショットがそのまま示しています)。

これは仕様として明言されています。SPM のエンジニアは Swift Forums で「Packages do not inherit build settings from projects in any way(パッケージはプロジェクトのビルド設定を一切継承しない)」と述べています。パッケージはカスタム構成をサポートせず、debug/release へのマッピングも非公式なヒューリスティック(前述の名前ルール)に依存します。Package.swiftswiftSettings.define(..., .when(configuration: .debug)) を書く手もありますが、これも SPM 自身の2値に対する条件でしかなく、カスタム構成を区別できるわけではありません。

なお Xcode という条件も定義されますが、Xcode 経由かどうかの判別には使えません。手元の環境(Xcode 27 / Swift 6.4)では、Xcode を介さない swift run でも Xcodetrue になりました。判定に安心して使えるのは SWIFT_PACKAGEDEBUG です。

名前でコントロールする

名前で決まるということは、逆に名前で意図どおりにコントロールできるということです。方針はシンプルで、#if DEBUG を効かせたい環境は、Build Configuration の名前に Debug または Development を含める。たとえばロガーをパッケージ内の #if DEBUG で切り替えたいなら、Staging 系の構成名を Debug_Staging にします。Develop 系は Development(または Debug_Develop)とします。

スキーム名まで変える必要はありません。ユーザーが目にするスキーム名は据え置き、SPM 判定に効く Build Configuration 名だけを命名の責務として使い分けるのが実務的です。

Scheme(表示名)Build Configuration 名パッケージ#if DEBUG(アプリ / パッケージ 共通)logger
DevelopDevelopment.debug効く有効
StagingDebug_Staging.debug効く有効
ReleaseRelease.release効かない無効

こうするとアプリとパッケージの #if DEBUG が一致し、ロガーなどがパッケージ内でも意図どおりに効きます。トレードオフとして、Debug_Staging はパッケージが .debug(非最適化)でビルドされます。最適化した検証ビルドが要る場合は別途検討します。

そして重要なのは、サーバー URL のような「環境の出し分け」は DEBUG 軸とは別問題だということです。DEBUG 軸は命名でパッケージまで届きますが、Develop / Staging / Prod といった環境軸は、アプリ側のカスタム条件や注入で扱います(後述)。

見せる軸を debug/release に絞る

「Build Configuration を debug/release に限定する」とよく言われますが、これは Xcode の構成の数を二つに絞れという意味ではありません。絞るのは SPM に見せる軸です。

SPM は .debug / .release の2値しか理解しません。構成はいくつ作っても構いませんが、各構成は名前によって必ずどちらか一方に割り当てられます(前節のとおり)。したがって要点は、構成名を2値へ意図どおりにマップすることです。Development / Debug_Staging.debug へ、Release.release へ、といった具合です。

やってはいけないのは、SPM に存在しない軸を構成で表そうとすることです。Develop / Staging / Prod という環境の区別は SPM の軸ではありません。これらはコンパイル時(アプリ側のカスタム条件)かランタイム(注入 / xcconfig → Info.plist)で扱います。こうしておけば SPM の挙動と食い違わず、アプリとパッケージの #if DEBUG も一致します。

環境差と分岐をどう扱うか

環境ごとの値やデバッグ分岐を、どこで決めてどう届けるかを整理します。

アプリから注入する

環境の区別を SPM の外で扱うと決めたら、次は「どこで決めて、どう渡すか」です。

原則は、feature パッケージは自分の環境を知らないことです。パッケージは設定を受け取るだけにして、具体値は合成の起点であるアプリが注入します。debug/prod の分岐もアプリ側で決めてパッケージへ渡し、パッケージ内には #if DEBUG を散らしません。前半(「なぜローカルパッケージに実装を閉じ込めるのか」)で触れたとおり、依存を限定していれば結線は自然とアプリに集約されるので、注入はむしろ必然です。

まずパッケージ側は「必要な値の形(契約)」だけを公開します。

public protocol FeatureConfig: Sendable {
    var apiBaseURL: URL { get }
    var isDebugMenuEnabled: Bool { get }
}

public struct FeatureView: View {
    private let config: FeatureConfig
    public init(config: FeatureConfig) { self.config = config }

    public var body: some View {
        Text(config.apiBaseURL.absoluteString)
    }
}

具体値と環境を知るのはアプリ側(composition root)だけです。ここで登場する AppFeatureConfig は、FeatureConfig に準拠した struct です。debug ↔ release の1軸なら #if DEBUG で分岐できます。この分岐をアプリ側に閉じ込めるのがポイントです。

extension AppFeatureConfig {
    static func resolve() -> AppFeatureConfig {
        #if DEBUG
        AppFeatureConfig(apiBaseURL: URL(string: "https://dev.api.example.com")!,
                         isDebugMenuEnabled: true)
        #else
        AppFeatureConfig(apiBaseURL: URL(string: "https://api.example.com")!,
                         isDebugMenuEnabled: false)
        #endif
    }
}

Staging と Prod のように release 内でさらに環境を分けたい場合は、重要な非対称があります。カスタムのコンパイル条件(STAGING など)はアプリターゲットでは定義できますが、パッケージには伝播しません。したがって #if STAGING はアプリ側なら使えます(Staging 構成の SWIFT_ACTIVE_COMPILATION_CONDITIONSSTAGING を足す)。パッケージ側で同じことはできないので、環境分岐はアプリに寄せ、パッケージには結果を注入する——という本記事の方針がここでも裏付けられます。

// 骨組み。各ブランチで環境に応じた AppFeatureConfig(...) を返す
extension AppFeatureConfig {
    static func resolveWithEnv() -> AppFeatureConfig {
        #if DEBUG
        // develop 用の AppFeatureConfig(...) を返す
        #elseif STAGING
        // staging 用の AppFeatureConfig(...) を返す
        #else
        // production 用の AppFeatureConfig(...) を返す
        #endif
    }
}

コンパイル時に分岐せず、ビルドごとに焼き込んだ値を読む方式もあります。xcconfig で Info.plist に値を入れておき、アプリ側で読み出して注入します。

extension AppFeatureConfig {
    static func fromInfoPlist() -> AppFeatureConfig {
        let info = Bundle.main.infoDictionary
        return AppFeatureConfig(
            apiBaseURL: URL(string: info?["APIBaseURL"] as? String ?? "")!,
            isDebugMenuEnabled: (info?["DebugMenuEnabled"] as? String) == "YES"
        )
    }
}

いずれの方式でも、パッケージは環境非依存のまま保たれ、テストもしやすくなります。

成果物から消したいなら #if(ランタイム if では残る)

前節の resolve() は、main で #if DEBUG を使って値を決め、その結果だけを注入していました。この場合、選ばれなかった側は #if の時点でコンパイルされないので、release ビルドに dev 用 URL の文字列は残りません。値を注入したからといって両方の値がバイナリに入るわけではない、という点をまず押さえてください。

問題が起きるのは、次のようにフラグを注入してランタイムで分岐したときです。

// アンチパターン:dev / prod 両方の URL がバイナリに残る
struct AppFeatureConfig: FeatureConfig {
    let isDebug: Bool

    var apiBaseURL: URL {
        if isDebug {
            URL(string: "https://dev.api.example.com")!   // release でも残る
        } else {
            URL(string: "https://api.example.com")!
        }
    }
}

if isDebug { ... } else { ... } はランタイム分岐です。注入された isDebug はコンパイル時に確定しない値なので、コンパイラは分岐を畳み込めません。結果として両方のブランチ・両方の URL 文字列がコンパイルされ、strings で拾える前提になります。「デバッグ用エンドポイントを本番バイナリから消したい」といった要件は、これでは満たせません。

一方 #if DEBUG はコンパイル時分岐なので、false 側のブランチはそもそもコンパイルされず成果物から消えます(前節の resolve() がこれ)。ただし表現できるのは debug ↔ release の1軸だけです。

つまり、消えるかどうかを決めるのは注入か否かではなく、分岐がコンパイル時(#if)かランタイム(if)かです。「成果物から消したい」要件は粒度で切り分けます。

  • debug ↔ release で消したい(デバッグ専用 UI など) → #if DEBUG。main で判定して値だけ注入するか、パッケージ内で直接書く
  • release 内の staging ↔ prod を分離・秘匿したいパッケージ内では原理的に不可(カスタム条件が伝播しないため)。アプリターゲットなら #if STAGING / xcconfig → Info.plist / ビルド時 codegen(ビルドフェーズでその構成の定数だけを書いた Swift を生成する。SwiftGen・Sourcery や自作スクリプトなど)で対応する

まとめると、秘匿・ストリップが要件なら、フラグを注入してのランタイム if では解けません。判定を #if に寄せるか codegen が必要です。

使い分けの指針

ここまでの手段を、ひとつの問いで整理できます。その分岐を成果物から消す必要があるか(コンパイル時)、それともランタイムのデータでよいか

判断フロー:成果物から消すか(#if)/ランタイムのデータか(注入)で分岐する図
目的手段使える場所他環境が成果物に残るか
コンパイル時に消す#if DEBUGアプリ/パッケージ(パッケージは Config 名に Debug・Development が必要)残らない
コンパイル時に消す#if STAGING 等カスタム条件アプリのみ(パッケージに伝播しない)残らない
コンパイル時に消す(生成で分離)ビルド時 codegenアプリ生成物のみ
ランタイムに分岐する注入したフラグで if 分岐アプリ / パッケージ両ブランチ残る

注入は「配送方法」であって、#if と競合する別手段ではありません。注入する値の供給元(#if DEBUG でのハードコード / Info.plist / リモートコンフィグなど)は別の話で、たとえば「Info.plist から読む → パッケージに注入する」と連結します。

判断は2ステップです。

  1. 消す必要があるか? — あり: #if(アプリ側ならカスタム条件で release 内も分離可)や codegen。なし: 値を注入する。
  2. 注入するなら 値をどこから得るか — ハードコード+#if DEBUG / Info.plist / リモートなど。

3行でまとめると次のとおりです。

  1. 構成は SPM に見せる軸を debug/release に絞る。
  2. debug/release の2値差はパッケージ内の #if DEBUG で扱う(名前規約に注意)。
  3. それ以外の環境差は注入。ただし成果物から消したいコードは codegen / Build Configuration 層で扱う。

サンプルプロジェクトでの実行結果

以上の挙動は、検証用に作成したサンプルプロジェクトを各構成でビルド・実行して確認しました。各画面はアプリとパッケージそれぞれで #if DEBUG などが効くかを並べて表示し、注入された設定も見えるようにしています。⚠️#if DEBUG がアプリとパッケージで食い違っている状態です。

Develop構成の実行画面
Develop(Build Configuration: Development):名前に development を含むのでパッケージも .debug。#if DEBUG はアプリ・パッケージとも効く。環境判定用の DEVELOP はアプリ側のみで、パッケージには伝播しない。
Staging(Debug_Staging)構成の実行画面
Staging(Build Configuration: Debug_Staging):名前に debug を含むのでパッケージも .debug。#if DEBUG はアプリ・パッケージとも効く。STAGING はアプリ側のみ。
Staging_Trap構成の実行画面
⚠️ Staging_Trap(Build Configuration: Staging):名前に debug/development を含まないのでパッケージは .release。アプリでは #if DEBUG が効くのにパッケージでは効かない(Configuration 名の罠)。
Not_DEBUG構成の実行画面
⚠️ Not_DEBUG(Build Configuration: Not_DEBUG):逆向きの罠。名前に debug を含むのでパッケージは .debug。アプリは DEBUG を立てていないのにパッケージで #if DEBUG が効く。名前の部分一致判定の証拠。
Release構成の実行画面
Release(Build Configuration: Release):#if DEBUG はアプリ・パッケージとも効かない。environment は Production。

Staging_TrapNot_DEBUG の二つが、本記事の核心を両方向から示しています。パッケージの #if DEBUG は、アプリ側の設定ではなく、構成名に debug/development を含むかどうかだけで決まります。

まとめ

  • Xcode の Build Configuration と SPM の configuration は別物。SPM は debug/release の2値しか知らない。
  • パッケージの #if DEBUGConfiguration 名で決まる。効かせたい環境は名前に Debug/Development を含める。
  • 環境の出し分け(staging/prod など)は SPM の軸ではないので、アプリ側のコンパイル時分岐か注入で扱う。
  • 注入したフラグでランタイム if 分岐すると、両ブランチが成果物に残る。消したい(秘匿したい)なら、判定を #if に寄せるか codegen を使う。
  • 依存をコンパイル時に限定し、実装をアプリから注入する——この二つは同じ設計スタンスであり、AI コーディングエージェント時代のガードレールとしても妥当。

参考リンク



ギャップロを運営しているアップフロンティア株式会社では、一緒に働いてくれる仲間を随時、募集しています。 興味がある!一緒に働いてみたい!という方は下記よりご応募お待ちしております。
採用情報をみる