One Codebase, Two Regions
Our app ships as two targets from one codebase. Different regions get different releases: different branding, different compliance requirements, different distribution channels. The overlap is well above ninety-five percent. The differences are real but small, and almost all of them are leaf values:
- the login page button color;
- the global accent color of the theme;
- the terms-of-service and privacy-policy hyperlinks, which point to different legal documents per region;
- a handful of others in the same family: the support email, the marketing site URL, the display name shown on the About page.
For a long time these differences lived where such differences usually live, in conditional compilation blocks scattered across the codebase:
final class LoginViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
#if REGION_INTL
loginButton.backgroundColor = UIColor(hex: 0x4A7DFF)
#else
loginButton.backgroundColor = UIColor(hex: 0xE84545)
#endif
#if REGION_INTL
termsLink.url = URL(string: "https://example.com/intl/terms")!
#else
termsLink.url = URL(string: "https://example.com/cn/terms")!
#endif
}
}
Multiply that by every screen that touches a branded value and the cost becomes visible. Business logic gets sliced apart by build configuration every few lines. A reviewer reading a login change has to mentally skip over branches that belong to a target they are not reviewing. New teammates learn which macro means which region by folklore. And because each #if site is written by hand, the two targets drift: one branch gets a fix, the sibling branch quietly does not.
The code was correct. It was the reading experience that was broken. Every call site was forced to answer a question it should never have been asked: which target am I compiled into?
The Alternatives We Already Had
Before reaching for anything new, it is worth being honest about the standard fixes and why none of them felt right.
A configuration singleton. Collect every branded value into Config.shared and branch once inside it. This does centralize the #if, but the call sites now read Config.shared.loginButtonColor everywhere, and the type system gives no hint about which values vary by target and which are simply global constants. The giant config file also becomes a merge magnet.
A plist per target. Each target bundles its own resource file and values are looked up at runtime. Now the compiler cannot help at all: a missing key is a runtime crash or a silent default, colors and URLs need hand-written parsing, and refactoring tools cannot see the usages.
Subclassing or full dependency injection. Inject a Theme object into every screen. This is the right shape for behavioral differences, but for five constant leaf values it is ceremony: protocols, initializer plumbing, and container registrations, all to deliver a color. We had just been through that exercise for object graphs and knew what DI buys and what it costs.
What we actually wanted was narrower than any of these: a way to declare, once, where a value comes from, and then use it everywhere with zero syntactic noise and full type checking.
Then the Language Grew a Feature Shaped Like the Problem
At WWDC 2019 Apple introduced Swift 5.1, and with it property wrappers, the mechanism behind SwiftUI's @State and Combine's @Published. The proposal, SE-0258, describes the feature in one sentence: a property wrapper moves the definition of how a property is provided out of the property's declaration site and into a reusable type.
That sentence is the whole insight. Our #if problem was never about the values themselves; it was about where the answer to "which value?" lived. It lived at every call site. A property wrapper is precisely a tool for relocating that answer to one place while keeping the call site's syntax untouched.
We did not adopt it immediately. Swift 5.1 shipped with Xcode 11 in September 2019, and we waited for the toolchain to settle and for our minimum-supported configuration to move forward. By June 2020, a year after the announcement, the wrapper described below went into production. Looking back, the speed was not about chasing a new language feature. The feature simply gave a name, and a syntax, to a pain we already had.
The Design: One File Owns the #if
The structure has three layers, and conditional compilation appears in exactly one of them.
First, a protocol declares every value that is allowed to vary by target. This is the contract between the targets:
protocol BrandSheet {
var loginButtonColor: UIColor { get }
var accentColor: UIColor { get }
var termsOfServiceURL: URL { get }
var privacyPolicyURL: URL { get }
var supportEmail: String { get }
}
Second, each target provides its own conforming sheet. These are plain structs full of constants, one file per region, and the active one is chosen by the target's compilation conditions:
struct DomesticBrand: BrandSheet {
let loginButtonColor = UIColor(hex: 0xE84545)
let accentColor = UIColor(hex: 0xD63C3C)
let termsOfServiceURL = URL(string: "https://example.com/cn/terms")!
let privacyPolicyURL = URL(string: "https://example.com/cn/privacy")!
let supportEmail = "[email protected]"
}
struct InternationalBrand: BrandSheet {
let loginButtonColor = UIColor(hex: 0x4A7DFF)
let accentColor = UIColor(hex: 0x3566D6)
let termsOfServiceURL = URL(string: "https://example.com/intl/terms")!
let privacyPolicyURL = URL(string: "https://example.com/intl/privacy")!
let supportEmail = "[email protected]"
}
#if REGION_INTL
let activeBrand: BrandSheet = InternationalBrand()
#else
let activeBrand: BrandSheet = DomesticBrand()
#endif
The protocol is what makes this safer than any plist. If a new value is added to BrandSheet and one region's struct does not provide it, the project does not compile. Drift between targets, the silent killer of the scattered-#if approach, becomes a build error.
Third, the wrapper itself. It is small enough to read in one breath:
@propertyWrapper
struct Branded<Value> {
private let keyPath: KeyPath<BrandSheet, Value>
var wrappedValue: Value {
activeBrand[keyPath: keyPath]
}
init(_ keyPath: KeyPath<BrandSheet, Value>) {
self.keyPath = keyPath
}
}
A key path into the brand sheet, resolved against whichever sheet this target compiled. Nothing else. No storage, no state semantics, no magic beyond the relocation of one question.
The Login Page, After
The screen that motivated the whole exercise now reads like this:
final class LoginViewController: UIViewController {
@Branded(\.loginButtonColor) private var loginButtonColor: UIColor
@Branded(\.accentColor) private var accentColor: UIColor
@Branded(\.termsOfServiceURL) private var termsURL: URL
@Branded(\.privacyPolicyURL) private var privacyURL: URL
override func viewDidLoad() {
super.viewDidLoad()
loginButton.backgroundColor = loginButtonColor
forgotPasswordButton.tintColor = accentColor
termsLink.url = termsURL
privacyLink.url = privacyURL
}
}
Three things changed, and all of them are about reading rather than executing.
The body of viewDidLoad is now pure business logic. There is no build configuration to skip over, so a reviewer sees exactly what the screen does. The declarations at the top are an honest inventory: every @Branded line announces "this value varies by target," which is information the old code buried inside method bodies. And the diff separation is complete: a change to the login flow touches the class body, a change to regional branding touches a brand sheet file, and the two never appear in the same review hunk again.
Adding a region later is the same story. A new target means one new conforming struct and one new compilation condition in the selection file. Business code does not change at all, and the compiler produces a checklist of every value the new region must decide, in the form of protocol conformance errors.
What It Cannot Do
It is worth stating the boundary as plainly as the benefit, because the wrapper's success created pressure to push more through it.
A property wrapper wraps a value. It cannot wrap a flow. Shortly after shipping, one region added a real-name verification step to login, and for about a day there was an attempt to express that as a branded value:
@Branded(\.needsRealNameVerification) private var needsRealNameVerification: Bool
Technically it compiles, and it looks consistent with the rest of the file. But the if needsRealNameVerification branch it feeds is business logic that only one target ever executes, sitting in a class both targets compile. The conditional did not disappear; it moved from build configuration into runtime control flow, where it is harder to find, always shipped, and testable only by faking a constant. That is strictly worse than the #if we started with.
The rule we settled on:
- Value differences (colors, URLs, strings, numbers, feature constants): property wrapper.
- Behavior differences (an extra login step, a different payment flow): a protocol with one implementation per target, provided through the same selection file or through the DI container.
- Structural differences (a module one region ships and the other legally cannot): target membership, so the code does not exist in the other binary at all.
The brand sheet holds answers. The moment something needs to decide, it has outgrown the wrapper.
Tradeoffs
The honest costs, a year into the feature and a few weeks into our production use of it:
Wrappers are opaque at the call site. @Branded looks like magic to anyone who has not read the fifteen lines behind it. We kept the magic budget small: one wrapper, one selection file, and a rule that new wrappers need a stronger justification than "it would look neat."
The value is fixed at compile time. A unit test running in one target cannot observe the other region's values. We decided this is a feature, not a bug: the other region's correctness is verified by that region's CI lane, which builds the other target. Pretending one binary can impersonate the other is how the plist approach got into trouble.
It shares syntax with stateful wrappers. @Branded sits in declarations right next to @Published and, later, SwiftUI's @State, but it has no storage semantics whatsoever. It is a read-only resolution shorthand. We documented that distinction in the wrapper's header comment, because the syntax alone does not convey it.
Takeaway
The interesting part of this story is not the fifteen lines of wrapper code. It is the timing. Property wrappers were announced as the machinery behind SwiftUI's state system, and most of the early writing about them stayed in that orbit. But the language feature underneath is more general: it lets a codebase declare, in one reusable type, where a certain kind of value comes from.
We had been living with a question asked in thirty places and answered by folklore. A year after WWDC, the language offered a way to ask it exactly once. The #if did not disappear from the project, and it should not: the two targets really are different. It moved into one file, guarded by a protocol that turns drift into compile errors, behind a syntax that lets every other file focus on what the screen actually does.
New language features earn their place not by being new, but by giving existing noise a proper home. This one did.