Skip to content
ExponaBLOG
SWIFT, SWIFTUI & IOS APPS · DAY 055 / 100

Modularizing an iOS app: real-world lessons and examples

Faster builds and clearer boundaries.

Expona Admin8 min read

Building for scale

Modularizing an iOS app: real-world lessons and examples is not a theoretical exercise—it's a survival strategy for teams shipping features at pace. When your codebase grows beyond a few thousand lines, the friction of builds, circular dependencies, and unclear ownership becomes your main bottleneck. We've seen this pattern across e-commerce apps handling payments and inventory, travel apps coordinating multiple booking flows, and SaaS products juggling user management, analytics, and real-time data. The investment in modularization pays dividends almost immediately: faster preview rendering, clearer architectural boundaries, and the ability to test features in isolation without dragging the entire app into the build graph.

Local Swift packages per feature

The simplest way to start modularizing is to extract each feature into its own local Swift package. In a travel app, this might mean separate packages for BookingFlow, PaymentUI, UserProfile, and SearchFilters. Each lives in its own directory, has its own Package.swift manifest, and compiles independently.

Here's what a basic feature package looks like:

swift
// BookingFlow/Package.swift
// swift-tools-version:5.9
import PackageDescription

let package = Package(
    name: "BookingFlow",
    platforms: [
        .iOS(.v14)
    ],
    products: [
        .library(name: "BookingFlow", targets: ["BookingFlow"])
    ],
    dependencies: [
        .package(path: "../Core")
    ],
    targets: [
        .target(
            name: "BookingFlow",
            dependencies: ["Core"]
        ),
        .testTarget(
            name: "BookingFlowTests",
            dependencies: ["BookingFlow"]
        )
    ]
)

Inside that package, organize your code the same way you would in a framework: Views, ViewModels, Models, Services, and Tests. The key constraint: this package declares its own dependencies explicitly. It can depend on shared Core packages, but it should never depend on sibling features. If BookingFlow needs something from PaymentUI, you extract the common logic into Core instead.

In practice, we've found that feature packages typically range from 500 to 2,000 lines of code. Anything larger signals that you're trying to do too much in one feature—time to split further. We keep a strict rule: one package, one clear responsibility, minimal external dependencies.

Core packages for networking and design system

Above the feature layer, you need a stable foundation: core packages that every feature depends on. These typically include networking abstractions, authentication, logging, analytics, and your design system.

A networking core package might look like this:

swift
// Core/Sources/Networking/APIClient.swift
import Foundation

public protocol APIClient {
    func request(
        endpoint: String,
        method: HTTPMethod,
        body: Encodable?
    ) async throws -> T
}

public actor DefaultAPIClient: APIClient {
    private let baseURL: URL
    private let session: URLSession
    private let decoder: JSONDecoder
    
    public init(baseURL: URL, session: URLSession = .shared) {
        self.baseURL = baseURL
        self.session = session
        self.decoder = JSONDecoder()
        self.decoder.dateDecodingStrategy = .iso8601
    }
    
    public func request(
        endpoint: String,
        method: HTTPMethod,
        body: Encodable?
    ) async throws -> T {
        var request = URLRequest(url: baseURL.appendingPathComponent(endpoint))
        request.httpMethod = method.rawValue
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        
        if let body = body {
            request.httpBody = try JSONEncoder().encode(body)
        }
        
        let (data, response) = try await session.data(for: request)
        
        guard let httpResponse = response as? HTTPURLResponse,
              (200..<300).contains(httpResponse.statusCode) else {
            throw APIError.invalidResponse
        }
        
        return try decoder.decode(T.self, from: data)
    }
}

public enum HTTPMethod: String {
    case get = "GET"
    case post = "POST"
    case put = "PUT"
    case delete = "DELETE"
}

public enum APIError: Error {
    case invalidResponse
    case decodingFailed
}

Your design system package exposes colors, typography, and reusable components:

swift
// DesignSystem/Sources/Colors.swift
import SwiftUI

public struct AppColors {
    public static let primary = Color(red: 0.2, green: 0.4, blue: 0.8)
    public static let secondary = Color(red: 0.95, green: 0.95, blue: 0.97)
    public static let error = Color(red: 0.9, green: 0.2, blue: 0.2)
    public static let text = Color(red: 0.1, green: 0.1, blue: 0.1)
}

public struct PrimaryButton: View {
    let title: String
    let action: () -> Void
    
    public var body: some View {
        Button(action: action) {
            Text(title)
                .font(.system(.body, design: .rounded))
                .fontWeight(.semibold)
                .foregroundColor(.white)
                .frame(maxWidth: .infinity)
                .padding(16)
                .background(AppColors.primary)
                .cornerRadius(8)
        }
    }
}

The critical principle: Core packages are stable and backward-compatible. Changes to these packages ripple through all features, so you move deliberately. We reserve Core for truly shared abstractions—authentication tokens, HTTP clients, logging sinks, and UI fundamentals. Feature-specific utilities stay in the feature package.

Features depend on core, not each other

This is the architectural rule that makes everything else work. Your app's main target depends on all feature packages. Each feature depends only on Core (and optionally on other packages it truly needs, like Redux or Alamofire). Features never depend on sibling features.

Here's how the dependency graph flows:

Layer Packages Dependencies
App MyApp (main target) BookingFlow, PaymentUI, UserProfile, Core
Features BookingFlow, PaymentUI, UserProfile Core only
Core Networking, DesignSystem, Analytics, Auth Third-party libraries (Combine, AsyncAlgorithms, etc.)

When a feature needs to coordinate with another feature, you route through the app-level coordinator or dependency injection. For example, if PaymentUI needs to notify UserProfile that a transaction succeeded, PaymentUI publishes an event through a shared event bus (part of Core), and UserProfile subscribes. The two features remain decoupled.

This constraint feels restrictive at first, but it forces you into cleaner patterns. You're naturally pushed toward smaller, focused APIs. You're prevented from creating implicit dependencies that later become impossible to untangle. In a SaaS app with dozens of features, this discipline has saved teams weeks of refactoring.

Previews build faster in small modules

One of the most tangible wins from modularization is preview performance. In a monolithic app, previewing a button component means Xcode compiles every view, view model, and network service in the project. With modular packages, previewing that same button—if it lives in DesignSystem—only triggers compilation of DesignSystem and its dependencies.

Here's a concrete example. In an e-commerce app:

swift
// DesignSystem/Sources/ProductCard.swift
import SwiftUI

public struct ProductCard: View {
    let product: Product
    let onTap: () -> Void
    
    public var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            Image(systemName: "photo")
                .resizable()
                .scaledToFill()
                .frame(height: 150)
                .clipped()
            
            VStack(alignment: .leading, spacing: 4) {
                Text(product.name)
                    .font(.headline)
                    .foregroundColor(AppColors.text)
                
                Text("$\(product.price, specifier: "%.2f")")
                    .font(.subheadline)
                    .foregroundColor(AppColors.primary)
                    .fontWeight(.semibold)
            }
            .padding(.horizontal, 12)
            .padding(.bottom, 12)
        }
        .background(Color.white)
        .cornerRadius(8)
        .onTapGesture(perform: onTap)
    }
}

#Preview {
    ProductCard(
        product: Product(id: "1", name: "Wireless Headphones", price: 99.99),
        onTap: {}
    )
}

When you hit the Preview button on this file, Xcode only needs to compile DesignSystem. If this component were buried in the main app target with hundreds of other classes, the preview would stall while the entire app compiled. We've measured 5–10x speedups in preview compilation time after moving components to isolated packages. This directly translates to faster iteration: you change a button style, hit refresh, and see the result in two seconds instead of thirty.

For teams, this unlocks asynchronous development. While one engineer refines the SearchFilters UI in isolation, another is building the BookingFlow logic. Both have fast feedback loops. There's no waiting for the monolithic build to complete.

Common mistakes

Creating too many packages too early. Resist the urge to modularize every utility function. Start with clear, coarse-grained features. A good rule of thumb: if your feature package isn't at least a few screens or a major user flow, it's probably not worth its own package yet.

Leaking implementation details through public APIs. When you expose a package's functionality, keep the surface area small and stable. Avoid exporting all internal models. Use wrapper types and clearly mark what's public. A careless public keyword can lock you into supporting an API you didn't intend.

Circular dependencies through Core. If you're tempted to make Core depend on a feature, or if two features seem to need each other, you've likely misidentified your boundaries. Re-examine the abstraction. In most cases, the solution is a third package that both can depend on, or a redesign of the data flow.

Ignoring Package.swift dependencies. Your Package.swift is your contract. Keep it up to date. When a feature stops using a Core package, remove the dependency. This keeps the build graph clean and prevents version conflicts later.

Building monolithic view hierarchies within a package. Even within a single feature package, resist the temptation to nest views deeply. Extract reusable sub-views. Your future self will thank you when you need to preview or test a single screen.

Key takeaways

  • Organize features into local Swift packages with explicit dependencies. Each package owns one clear responsibility.
  • Build a stable Core layer: Networking, Auth, DesignSystem, Analytics. Every feature depends on Core; Core depends on third-party libraries only.
  • Enforce a strict rule: features depend on Core and third-party packages, never on each other. Use event buses or coordinators for cross-feature communication.
  • Preview compilation time drops dramatically in small, focused packages. This multiplies developer velocity, especially in large teams.
  • Modularization is an investment that compounds. The cost is upfront (time spent organizing packages); the return is paid every day in faster builds, faster previews, and clearer ownership.
  • Start with 2–3 core packages and 3–5 feature packages. Grow gradually as your codebase does. Avoid over-modularization.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles