The Fork We Refused to Make
A year and a half ago I wrote about hiding per-region values behind a property wrapper: colors, legal URLs, display names. That post ended with a rule. Values go in the brand sheet; behavior goes behind a protocol; whole modules one region cannot ship go to target membership. At the time the third category was a sentence. By the end of 2021 it was most of my year.
The product is a video editor that ships as two apps. The domestic app and the international app share well over ninety percent of their code, and the editing engine underneath is identical. But the ten percent is not the harmless kind. The international app lives under a different legal regime: different privacy law, different app review expectations, a different answer to the question where may this user's data go. Several features that are table stakes domestically simply cannot exist in the international binary. Not disabled. Not hidden behind a flag. Absent.
There were two obvious ways to get there and we rejected both.
Fork the repository. Two codebases, two teams, no coordination cost on day one. Every other company I have talked to that did this regretted it within a year: bug fixes land on one side and quietly not the other, the engines drift, and the "shared" editing core becomes two cores with the same name. We had just finished a bridge project that lived or died on protocol discipline across teams; forking would have thrown that lesson away.
Keep one target and branch at runtime. if region == .international { return } in front of every sensitive entry point. Cheap, and it fails the only test that matters: the forbidden code is still in the binary. A reviewer decompiling the app, a regulator asking which SDKs are linked, a crash log from a code path that was never supposed to run. Runtime branching changes what the app does; compliance asks what the app contains.
So the constraint we wrote down on the first day was the one every later decision was checked against:
If a region may not ship a capability, that capability's code and its dependencies must not be present in that region's binary. Everything else should be shared with zero duplication.
Satisfying both halves of that sentence at once is the whole problem. What follows is the layered model we ended up with. The layers are ordered from the coarsest mechanism to the finest, and the discipline is to use the coarsest layer that fits.
Each kind of difference has exactly one home. The coarser the layer, the stronger the guarantee that the other region's code is not in the binary; the finer the layer, the less duplication it costs.
Layer 1: Two Workspaces, Not Two Targets
The starting point was one Xcode project with two app targets. That is the setup every multi-brand app begins with, and it hides a structural lie: two targets in one project share a single dependency graph. Every pod, every framework, every resource bundle is resolved once, and the targets merely select which files to compile. The international target could exclude source files, but the forbidden third-party SDKs were still integrated into the workspace, still resolved by the dependency manager, and one careless import away from being linked.
We moved to two workspaces. The Podfile is Ruby, so it reads an environment variable and resolves a different workspace, project, and dependency set per region:
region = ENV.fetch('APP_REGION', 'domestic')
workspace "VideoEditor-#{region}.xcworkspace"
project "VideoEditor-#{region}.xcodeproj"
target "VideoEditor-#{region}" do
pod 'EditingEngine'
pod 'Edit', :subspecs => ['Core', region.capitalize]
pod 'Resource', :subspecs => ['Core', region.capitalize]
if region == 'domestic'
pod 'FaceTrackingKit'
pod 'DomesticSocialShare'
end
end
APP_REGION=international pod install produces a workspace in which FaceTrackingKit does not exist. Not excluded, not stubbed: never downloaded, never resolved, never linked. The build fails on import FaceTrackingKit, which is exactly the failure we want, because the file that tried to import it is in the wrong place.
The cost is real. Two workspaces means two pod install runs, two sets of derived data, and CI lanes that must build both or trust neither. We paid it, because this is the only layer that answers the compliance question with the build system instead of with a promise.
Layer 2: Core Plus One Subspec per Region
Inside the workspace, the shared code is organized as pods, and each pod that has any regional difference is split into subspecs: a Core that both regions compile, and one subspec per region that only that region compiles.
Pod::Spec.new do |s|
s.name = 'Edit'
s.subspec 'Core' do |ss|
ss.source_files = 'Edit/Core/**/*.swift'
ss.dependency 'EditingEngine'
end
s.subspec 'Domestic' do |ss|
ss.source_files = 'Edit/Domestic/**/*.swift'
ss.dependency 'Edit/Core'
ss.dependency 'FaceTrackingKit'
end
s.subspec 'International' do |ss|
ss.source_files = 'Edit/International/**/*.swift'
ss.dependency 'Edit/Core'
end
end
Two properties of this arrangement matter more than they look.
First, the Swift module name is the same in both regions. import Edit works everywhere, and the files in Edit/Domestic are simply part of the Edit module in one build and nonexistent in the other. Call sites in Core never know which region they were compiled into. There is no #if REGION_INTL in this layer at all; the file system is the condition.
Second, the dependency arrow points one way. Domestic depends on Core; Core must never depend on Domestic. CocoaPods will not stop a Core file from referencing a Domestic type (they are the same module), so this is enforced by a lint script that runs on CI and fails if any file under Core/ mentions a symbol declared only under a regional directory. It is a crude check and it has caught real mistakes in every quarter since.
The subspec is the right tool for file-level differences inside a pod that is mostly shared. It is the wrong tool for a feature that is large and different in both regions. That distinction is Layer 3.
Layer 3: Whole Modules Versus Extracted Features
Not every difference deserves the same treatment, and the single most useful question we learned to ask was: is this a different module, or a shared module with a forbidden feature in it?
Different modules: just do not share
The community feed is the clearest example. The domestic feed and the international feed have different product owners, different content rules, and by late 2021 different rendering technologies: the domestic one had moved to a cross-platform dynamic UI runtime, the international one stayed native because its traffic profile made stability the priority. There is no "shared ninety percent" here. Sharing would mean a FeedModule whose Core subspec is nearly empty and whose two regional subspecs are two unrelated apps wearing one name.
So the feed is two pods, DomesticFeed and InternationalFeed, each included only by its own workspace. Nothing clever. The temptation to unify them was strong because they looked similar in the product, and resisting that temptation saved more time than any abstraction we built.
Shared modules with a forbidden feature: extract and inject
Editing is the opposite case. The timeline, the trimming, the text tools, the export pipeline: identical. But one capability inside it, face tracking for a beauty effect, uses a vendor SDK that the international app is not allowed to ship. The feature is perhaps two percent of the editing module and the vendor SDK is the single largest binary dependency in the app.
The original code had the dependency in the worst possible place, pulled directly from the effects panel:
// Edit/Core/EffectsPanelController.swift — before
import FaceTrackingKit
final class EffectsPanelController {
private let tracker = FaceTracker()
func applyBeauty(to frame: Frame) -> Frame {
let landmarks = tracker.detect(in: frame)
return beautyRenderer.render(frame, landmarks: landmarks)
}
}
This file is in Core because the effects panel is shared. The import at the top means Core depends on the vendor SDK, which means the international build cannot compile Core at all. This one import is why the project had two targets in one workspace for so long: the only way to make it build was to link the SDK everywhere and branch at runtime.
The fix is the dependency inversion that every architecture book describes and that is genuinely hard to apply retroactively: Core declares what it needs as a protocol, and the regional subspec supplies it.
// Edit/Core/FaceLandmarkProviding.swift
public protocol FaceLandmarkProviding {
func landmarks(in frame: Frame) -> [FaceLandmark]
}
// Edit/Core/EffectsPanelController.swift — after
final class EffectsPanelController {
private let landmarks: FaceLandmarkProviding?
init(landmarks: FaceLandmarkProviding?) {
self.landmarks = landmarks
}
var supportsBeauty: Bool { landmarks != nil }
func applyBeauty(to frame: Frame) -> Frame {
guard let provider = landmarks else { return frame }
return beautyRenderer.render(frame, landmarks: provider.landmarks(in: frame))
}
}
// Edit/Domestic/FaceTrackingKitProvider.swift
import FaceTrackingKit
struct FaceTrackingKitProvider: FaceLandmarkProviding {
private let tracker = FaceTracker()
func landmarks(in frame: Frame) -> [FaceLandmark] {
tracker.detect(in: frame).map(FaceLandmark.init)
}
}
The container we chose for the document SDK a year earlier does the wiring. Each region has an assembly; the domestic assembly registers the provider, the international assembly registers nothing, and the resolver returns nil:
// Edit/Domestic/DomesticEditAssembly.swift
final class DomesticEditAssembly: Assembly {
func assemble(container: Container) {
container.register(FaceLandmarkProviding.self) { _ in FaceTrackingKitProvider() }
}
}
// Shared composition, in Core
let provider = resolver.resolve(FaceLandmarkProviding.self) // nil internationally
let panel = EffectsPanelController(landmarks: provider)
The arrow that used to point from Core to the vendor SDK now points from the regional subspec to Core. The international binary contains the protocol, a nil, and no trace of the SDK.
Notice what supportsBeauty does. The panel hides the beauty entry when the provider is absent, so the international UI does not show a button that does nothing. This is the line between Layer 3 and runtime branching: the nil is not a region check. It is the honest statement that a capability was not supplied. Core does not know why it was not supplied and does not need to.
The retroactive part is what consumed the quarter. Face tracking was referenced from four places, not one, and two of them had leaked the vendor's own landmark type into Core's public API. Every one of those had to be re-expressed in terms of FaceLandmark, a type Core owns. The protocol was the easy part; owning the vocabulary was the work.
Layer 4: Configuration Is a Theme, Not a Condition
Below features sit the differences that are pure data: API hostnames, the application identifier, the display name, the accent color, the terms-of-service URL, the analytics endpoint. The property-wrapper post covers the mechanism; what changed this year is the scope and one rule.
The scope grew from "branding" to "everything a region needs to answer," which we collected into a single AppConfig sheet per region, provided by the regional subspec of the Resource pod. The brand values moved in alongside the network ones, because they turned out to have the same lifecycle.
The rule is the one that matters for compliance: a hostname may appear in exactly one file per region. The network stack reads the base URLs from the config sheet, and nothing else in the app is allowed to construct a URL from a string literal containing a domain. That is enforced the same way the Core-to-regional dependency is enforced, with a lint rule on CI. It sounds pedantic until the first time it catches an engineer pasting a domestic upload endpoint into a shared file, which it did, in the second week.
Layer 5: One Protocol Method
The finest layer is for the case where a flow is ninety-eight percent identical and differs in one step. Login is the example. Both regions authenticate the same way, store tokens the same way, and refresh the same way. The single difference is the third-party sign-in they offer: the domestic app hands off to the domestic social video app, the international one to its international sibling, and the return URL handling on each side is different.
Copying the login module into two subspecs for one method is exactly the duplication the model is supposed to prevent. Instead the step is a protocol with one implementation per region:
// Account/Core
public protocol SocialSignInHandoff {
var providerName: String { get }
func beginHandoff(from presenter: UIViewController)
func handleCallback(_ url: URL) -> SignInResult?
}
// Account/Domestic
struct DomesticSocialHandoff: SocialSignInHandoff { /* domestic app scheme */ }
// Account/International
struct InternationalSocialHandoff: SocialSignInHandoff { /* international app scheme */ }
The login flow in Core receives a SocialSignInHandoff and calls it. The two implementations are forty lines each. Mechanically this is the same move as Layer 3, a protocol in Core with regional implementations, and that is the point: the model has only two real mechanisms, membership (Layers 1 to 3) and injection (Layers 3 to 5). The layers describe at what granularity each is applied.
The Data Lifecycle Is Also a Layer
Everything above is about code. The compliance team's questions were about data, and those questions did not map onto modules at all. They mapped onto a lifecycle: what is collected, what is processed where, and where it travels. Each stage turned out to have a regional answer, and each answer had to land in one of the five layers.
Collection. Which signals the app reads at all. The pasteboard is the canonical example: reading it on launch to detect a shared link is a common domestic growth tactic, and on the international side it is both a legal problem and, since iOS 14, a visible banner that users screenshot. Photo library access is similar; the international app requests the limited picker by default. These are feature-level decisions, so they live in Layer 3, as capabilities the international assembly does not register.
Processing. Where computation on sensitive data happens. Face landmarks computed on device are one category; face images uploaded to a server for an AI effect are a different category under every privacy regime we looked at. Server-side effects on the international side were restricted to a list reviewed by legal, and the client enforces that list through the same provider pattern: the international assembly simply does not register the server-side face effect provider.
Transport. Where data goes. A user in one jurisdiction must not have their traffic routed to infrastructure in another, and the practical failure is never a deliberate decision; it is one hard-coded hostname in a shared file. That is Layer 4, and the lint rule exists because of this stage.
Telemetry, which cuts across all three. Analytics events are the easiest place to leak a PII field, because the event schema is defined far from the code that fills it. We gave the international AppConfig an allowlist of event properties, and the analytics layer drops anything not on it before serialization. Not elegant, but it turned a class of incident into a no-op.
What the Layers Bought Us
The honest scorecard a quarter after the split:
- The compliance question has a mechanical answer. "Does the international binary contain the face SDK?" is answered by
pod installoutput and the link map, not by an engineer's assurance. This is the single biggest change, and it is a change in kind, not degree. - Duplication stayed near zero. The editing module is one module. The login module is one module. The only duplicated code is the pair of forty-line handoffs and the two feeds, which were never really one thing.
- Regional work has a known address. When a product manager asks for an international-only tweak, the answer is one of five layers, and the review question is always "could this be one layer coarser?" Most proposals arrive one layer too fine, usually as a runtime check, and get pushed up.
And the costs, which are just as real:
- Two workspaces are two of everything. Build times, CI lanes, derived data, and the occasional afternoon lost because one workspace was installed with the wrong
APP_REGION. - Subspecs do not enforce their own direction. The Core-must-not-depend-on-regional rule is a script, not a compiler guarantee. Swift packages with real module boundaries would make it a compiler guarantee, and that migration is on the list.
- Retroactive injection is slow. Extracting face tracking took a quarter because the dependency had grown roots into public types. The lesson is not "use protocols," which everyone knows. It is that a shared module must own the types at its boundary, not just the calls, or the boundary is fiction.
Takeaway
The property-wrapper post ended by saying the #if should not disappear, because the two regions really are different. This year taught me the second half of that sentence: the differences are not all the same shape, and the mistake is treating them as if they were. A missing SDK, a different feed, a different hostname, and a different sign-in step are four different problems, and solving all of them with the same mechanism is how a codebase ends up with either a fork or a thousand runtime checks.
Five layers is just the vocabulary we needed to stop having that argument. Each difference gets the coarsest home that fits, membership where the binary must differ, injection where only behavior must, data where only values must. The result is a codebase where the question "which region am I in?" is answered by the build in a handful of places and never asked anywhere else.