How do you manage multiple environments?
1. Xcode configurations + schemes as the foundation
Separate build configurations (Debug/Staging/Release) mapped to separate schemes, each targeting a different environment. Each scheme can build with a different bundle identifier, which lets you install Dev, Staging, and Production builds side by side on the same device.
2. .xcconfig files for environment-specific values
// Staging.xcconfig
API_BASE_URL = https://api-staging.example.com
PRODUCT_BUNDLE_IDENTIFIER = com.example.myapp.staging
APP_DISPLAY_NAME = MyApp (Staging)
These get exposed to Info.plist via build setting variables, and from there read into Swift.
3. A single AppEnvironment/Configuration type as the Swift-side seam
struct AppEnvironment {
let baseURL: URL
let apiKey: String
let isProduction: Bool
static var current: AppEnvironment {
guard let urlString = Bundle.main.object(forInfoDictionaryKey: "API_BASE_URL") as? String,
let url = URL(string: urlString) else {
fatalError("Missing API_BASE_URL — check .xcconfig setup")
}
return AppEnvironment(baseURL: url, apiKey: ..., isProduction: ...)
}
}
This ties directly back to the networking layer — AppEnvironment.current.baseURL gets injected at the composition root, so the networking layer itself has zero knowledge of which environment it’s running in.
4. Distinguish “build configuration” from “runtime-switchable environment”
- Build-time environment (Dev/Staging/Prod via schemes) — fixed at compile time, separate bundle IDs. Right mechanism for anything security-sensitive.
- Runtime-switchable environment — a debug menu inside a single Dev build that lets QA flip between backends without rebuilding, entirely compiled out (
#if DEBUG) of release builds.
5. Secrets management — never commit API keys to source control
.xcconfigfiles with real secrets excluded from git, populated locally or injected by CI.- A separate, gitignored
Secrets.xcconfigwith a template committed for reference. - For anything truly sensitive server-side, the better architecture is often “the app doesn’t hold the secret at all.”
6. CI/CD pipeline awareness of environment
fastlane ios build_staging
fastlane ios build_production
Each lane points at the corresponding scheme/configuration — removing the human “did I pick the right one” step.
7. Backend/API versioning coordination
Staging and production often run different backend versions simultaneously, so the networking layer’s response parsing needs to be tolerant of fields staging has that production doesn’t yet — defensive decoding rather than assuming lockstep.
8. App icon/name differentiation as a practical safety net
Give Dev/Staging builds a visually distinct app icon and modified display name via .xcconfig-driven settings. This sounds cosmetic, but it’s a real safety mechanism — testers with multiple builds installed side by side need an immediate, glanceable way to know which one they’re in, especially right before doing something environment-sensitive like a payment test.
The failure modes this whole setup is designed to prevent
- Shipping a build with a staging API URL to the App Store
- Committing production secrets to git history
- QA testing against the wrong backend without realizing it
- Scattered
#if STAGINGconditionals throughout business logic
Interview-ready summary
| Layer | Mechanism |
|---|---|
| Build-time separation | Xcode configurations + schemes, distinct bundle IDs |
| Environment-specific values | .xcconfig files → Info.plist → Swift AppEnvironment type |
| Runtime switching (Dev only) | Debug-only environment switcher, compiled out of Release |
| Secrets | Gitignored .xcconfig/CI-injected env vars, never committed |
| CI/CD | Fastlane/pipeline lanes mapped explicitly to schemes |
| Visual safety net | Distinct app icon/name per environment |
One-liner if pressed: “I’d use Xcode configurations and schemes with distinct bundle IDs as the build-time separation — so Dev, Staging, and Production can be installed side by side without ambiguity — push environment-specific values into .xcconfig files rather than scattered #if conditionals, centralize them into one AppEnvironment type injected at the composition root so the rest of the app never checks which environment it’s in, keep secrets out of git via CI-injected values, and add a debug-only runtime environment switcher for QA that’s compiled out of release builds entirely.”