When you maintain a single app, architecture decisions are contained. When you maintain 27 apps across iOS, macOS, watchOS, and visionOS, every duplicated line of code becomes a liability. A bug in your paywall logic means 27 separate fixes. A new analytics event means 27 copy-paste sessions. A localization change means touching hundreds of files.
This is the story of how I centralized the shared infrastructure of a 27-app portfolio into a single Swift Package called AppFoundation, and the hard-won lessons from running it in production across every app we ship.
The Problem: Death by Copy-Paste
The first three apps were easy. Each had its own analytics setup, its own paywall view, its own onboarding flow. Small differences crept in between them — one used TelemetryDeck SDK v1.3, another used v1.5. One had a paywall timeout of 8 seconds, another had none.
By app number six, the divergence was costing real time. A RevenueCat SDK update that changed how offerings load? Six separate updates, six separate test cycles. A Sentry crash report showing a bug in the paywall? Fixed in one app, still broken in five.
The tipping point was localization. Our apps support up to 38 languages. When we updated the paywall copy, the change had to propagate through every app's .xcstrings file individually. Missing a locale meant users in that language saw English fallback text on a purchase screen — which tanks conversion rates.
The Solution: AppFoundation
AppFoundation is a local Swift Package that every app depends on. It lives in a shared directory and each app references it through a relative path in its Package.swift:
// In each app's Package.swift
dependencies: [
.package(path: "../AppFoundation")
]
The package provides four core modules:
- Analytics — TelemetryDeck initialization, standard event names, and a unified signal API. Every app calls
AppSetup.configure(tdAppID:)at launch, and the rest is automatic. - Monetization — RevenueCat configuration, offering loading, paywall presentation, and entitlement checking. The paywall view handles loading states, error recovery, and purchase restoration.
- Crash Reporting — Sentry DSN injection, breadcrumb helpers, and user-context tagging. One line per app.
- Onboarding — A configurable onboarding flow that each app customizes with its own content while sharing the navigation logic, page indicators, and completion tracking.
How It Works in Practice
A new app's App.swift setup looks like this:
import AppFoundation
@main
struct MyNewApp: App {
init() {
AppSetup.configure(
rcKey: "appl_XXXXXXXXXXXXX",
sentryDSN: "https://[email protected]/project",
tdAppID: "XXXXXXXX-XXXX-XXXX-XXXX"
)
}
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
Three lines. Analytics, crash reporting, and monetization are all running. No boilerplate, no configuration drift, no forgetting to initialize a service.
The paywall is equally minimal. AppFoundation wraps RevenueCatUI.PaywallView with proper error handling, a loading timeout, and retry logic:
import AppFoundation
struct SettingsView: View {
@State private var showPaywall = false
var body: some View {
Button("Upgrade") {
showPaywall = true
}
.sheet(isPresented: $showPaywall) {
AppPaywallView()
}
}
}
Behind the scenes, AppPaywallView handles four states: initial, loading, loaded, and error-with-retry. The loading state has an 8-second timeout that transitions to the error state rather than spinning forever. This solved a persistent issue where RevenueCat offerings occasionally failed to load — users saw an infinite spinner with no way out.
The Stats
Architecture Decisions That Mattered
Local package, not a remote dependency. Using a Git-hosted Swift Package would mean version pinning, branch management, and waiting for resolution. A local path dependency means changes propagate instantly. I edit the package, rebuild any app, and the change is live. For a solo developer, this speed matters more than the ceremony of semantic versioning.
Configuration over convention. Every app has different RevenueCat keys, Sentry DSNs, and TelemetryDeck IDs. AppFoundation takes these as runtime parameters, never as build-time constants. This means the same binary logic serves Wattora (an energy price tracker) and CCSE Study Guide (a UK construction exam app) identically.
Compile-time guards for paid variants. Several of our free apps have paid one-time-purchase counterparts — like eXpense and Ledgr Finance, or Email Converter and MailShift. These paid variants use the same codebase but compile with PAID_VERSION active compilation conditions. AppFoundation checks this flag:
#if !PAID_VERSION
import RevenueCat
import RevenueCatUI
// Full paywall + subscription logic
#else
// No RevenueCat import, no paywall views
struct AppPaywallView: View {
var body: some View { EmptyView() }
}
#endif
This prevents the RevenueCat SDK from even being compiled into paid apps. No accidental subscription prompts in apps that users already purchased outright.
XcodeGen for project generation. None of our apps have committed .xcodeproj files. Every project uses a project.yml that XcodeGen processes into a fresh Xcode project. This eliminates merge conflicts on project files and makes the AppFoundation dependency declaration declarative rather than buried in Xcode's GUI.
The Hard Parts
Localization propagation. When AppFoundation adds a new string key (like a paywall button label), every app that uses that component needs the key in its own .xcstrings file. There is no automatic inheritance. After an AppFoundation update, I run a batch audit script that checks every app for missing keys and flags gaps before submission. Without this, apps silently fall back to English — which Apple reviewers in non-English locales will notice.
macOS sheet behavior. On macOS, SwiftUI .sheet() modifiers open a separate NSWindow. Environment objects injected via .environment() do not propagate across window boundaries. The paywall view, presented as a sheet, would crash because it could not find the PremiumManager in its environment. The fix: explicitly inject the environment on the sheet's content view. This cost hours to diagnose because the same code worked perfectly on iOS.
Breaking changes cascade. When I change a public API in AppFoundation, all 27 apps need updating. For additive changes this is fine — new optional parameters with defaults. For breaking changes (renaming a method, removing a parameter), every app fails to compile until updated. I batch these carefully and always run a full-portfolio build after any breaking change.
What This Enables
The payoff is shipping speed. When we add a new app to the portfolio — like TokenMeter AI for tracking LLM API costs, or NautiCoach for Italian nautical exam prep — the core infrastructure is done in minutes, not days. The app-specific work is the actual app: its domain logic, its UI, its content.
A single privacy manifest (PrivacyInfo.xcprivacy) template covers all apps because they all use the same SDKs. One Sentry project per app, but identical crash reporting configuration. One RevenueCat setup pattern, replicated instantly.
The portfolio now spans quiz apps (Einbürgerungstest, Examen Civique, CSCS Quiz), productivity tools (Phygital Timer, PARA Mail), utilities (Mbox Splitter Pro, Email Converter), and energy apps (Wattora). They share analytics infrastructure, monetization logic, and crash reporting. They differ in everything that matters to users.
Would I Do It Again?
Without question. The upfront cost of extracting shared code into a package was about two weeks of work. That investment has paid back every time a third-party SDK ships a breaking update, every time a reviewer flags a localization issue, and every time a new app goes from idea to App Store submission in a single day.
If you are maintaining more than three apps with overlapping dependencies, the shared package approach is not optional — it is the only sane architecture. The alternative is copy-paste entropy, where each app slowly drifts into its own incompatible state, and the cognitive overhead of keeping them aligned eventually exceeds the cost of building the apps themselves.
Key Takeaways
- Local Swift Packages beat remote dependencies for solo/small-team workflows. Instant propagation, no version management overhead.
- Compile-time flags (
PAID_VERSION) are the cleanest way to create free/paid app variants from one codebase. - XcodeGen eliminates project file conflicts and makes dependency declarations readable and diffable.
- Localization audits after package updates are mandatory. SwiftUI does not warn about missing string keys at compile time.
- macOS and iOS have real behavioral differences in SwiftUI. Test shared components on both platforms, especially sheets and environment propagation.