Chapters

Hide chapters

Swift Internals

First Edition · iOS 26 · Swift 6.2 · Xcode 26

8. Architectural Dynamics: Modularization & Linking
Written by Aaqib Hussain

The word Architecture derives from Greek roots, combining Arkhi (” Chief” or “Principal”) and Tekton (“Builder” or “Craftsman”). As the architect of an app, your responsibility is to be the principal builder. You must construct a system that is not only scalable and stable, but also resilient to change. Authentic architecture isn’t just about how you write code inside a function; it is about how you organize that code across the entire system.

Why does this organization matter to you? Structuring your app into loosely coupled components provides three critical advantages:

  1. Maintainability: You isolate features so that changes in one area do not break another.

  2. Velocity: You enable parallel development, allowing large teams to work simultaneously without getting in each other’s way.

  3. Performance: You optimize the build system to drastically reduce compilation times.

This is where modularization becomes your structural reinforcement. It transforms a monolithic, fragile codebase into a structured assembly of reusable parts. It elevates boundaries, encourages clean interfaces, and, as a significant side effect, accelerates your feedback loops.

In this final chapter, you will dissect the mechanics of software architecture. You will examine the differences between static and dynamic linking, master the Swift Package Manager ecosystem, and explore the physics of the build graph to engineer apps that scale effortlessly.

The Case for Modularization

Most iOS apps and projects initially start with a monolithic architecture, using a single Xcode target that contains all source files, resources, and configurations. In the early stages, this setup is efficient when the project is small and still evolving. It’s easy to add new files, and CMD + R is instant.

However, as your codebase grows, the monolith becomes a liability. Compile times increase from seconds to minutes because even a minor change can trigger a complete rebuild of the project. At this moment, the project reaches a point where you have enough time to brew a coffee, drink it, and contemplate why you didn’t become a carpenter instead. Merge conflicts become more common as teams expand, often centered on the project.pbxproj file.

Modularization involves transforming this liability into an asset by splitting up the large single target into smaller, independent modules. Each of these targets produces its own binary. To do this effectively, you need to understand the structure of the graph you’re building.

Breaking the Monolith

When you split a monolithic app, you’re essentially trading convenience for control. By isolating code into modules, you enforce the Separation of Concerns at the compiler level.

With a monolithic app, boundaries are ill-defined. Nothing prevents a View Controller from accessing a Networking Manager and modifying a public property. In a modularized app, these boundaries are physical. If the Networking module doesn’t explicitly mark a property as public, the UI layer cannot simply see it. This strict enforcement prevents spaghetti code better than any code review could.

However, the immediate benefit you will notice is Incremental Compilation. When you modify a specific file inside a module, Xcode only needs to recompile that module and the targets that depend on it. It doesn’t need to touch other unchanged modules.

Note: Incremental Compilation is a compiler strategy that compiles only the parts of the code that have changed.

The Dependency Graph

Once you’re working with modules, you’re not just creating modules but also managing a Directed Acyclic Graph (DAG), possibly without realizing it.

  • Directed: Dependency flows in one direction. For example, Module A imports Module B.
  • Acyclic: There are no cycles.

The compiler relies on this graph to determine the build order. If Module A depends on Module B, then Module B must be compiled and linked before Module A can even start.

The main problem in modular architecture is Circular Dependency. Imagine the Profile module needs to know about the Settings module to save preferences, but the Settings module needs to display the current user’s avatar.

  • Profile module imports the settings module.
  • Settings module imports the profile module.

The compiler faces a contradiction: it cannot build Profile until Settings is finished, but it cannot finish building Settings until Profile is built. It is the architectural equivalent of two people stuck in a revolving door. Neither can move forward until the other one leaves, but neither is willing to step out. Xcode will show a cryptic build error indicating a dependency cycle.

To fix this, think like an architect and apply the Dependency Inversion Principle.

  1. Create a third module, UserPreferences.
  2. Define a protocol inside it.
  3. Have both Profile and Settings modules import it.

Now, both high-level modules depend on low-level definitions, breaking the cycle and restoring the DAG.

Libraries vs. Modules vs. Frameworks

When discussing modularization, developers often use the terms “Framework,” “Library,” and “Modules” interchangeably. As an advanced Swift developer, you should be able to distinguish between them because they represent different stages of the build process.

The Library (.a or .dylib)

The library is the compiled code that serves as the module’s core. It contains the machine code derived from your Swift source. It can be of two types:

  1. Static Library: An archive of object files. It’s essentially a zip of compiled code that gets merged directly into your app’s binary executable.

  2. Dynamic Library: A standalone binary that loads into memory during runtime.

The Module (.swiftmodule)

The library contains executable code, but it doesn’t tell the Swift compiler how to use it. In languages such as C or Objective-C, header files (.h) define the public interface. In Swift, the compiler creates a Module Map (.modulemap file, which connects C/Objective-C headers with Swift’s module system) and a .swiftmodule file.

  • The .swiftmodule file is a binary format that contains the Swift module metadata, representing your public types, generic constraints, and function signatures.

  • When you write import Profile, the compiler searches for Profile.swiftmodule to perform type checking. It does not look at the .a or .dylib files at this stage; that occurs later during the linking phase.

The Framework (.framework)

A framework is not a file type; it’s a package, specifically a directory with a known structure. It bundles the Library (the code) together with the Module (the interface) and Resources (images, storyboards, localization strings, and so on).

Apple operating system utilize frameworks because they provide a single, portable, self-contained unit that includes everything a user needs to use your code.

Extension Component .swiftmodule Module / .a .dylib Library .framework Framework The public interface definitions (Compile-time). The compiled machine code (Runtime). The container holding the library, module, and resources. Role
Library vs. Module vs. Framework

Grasping this difference is essential when you’re trying to debug “module not found” errors. Usually, it means that the compiler found the framework folder but cannot locate the .swiftmodule file because it is missing or incompatible with your Swift version.

Static vs. Dynamic Linking

The Linking process begins as soon as the compiler successfully generates the object files (.o) from your source code.

The linker combines all object files, including those from your imported frameworks, into a single executable. It resolves symbols (variables and functions) and ensures the CPU knows exactly which memory address to jump to when a module calls a function in another module.

There are fundamentally two ways in which linking can occur: Static linking and Dynamic linking. Understanding the difference is not only important academically, but it’s the primary factor in your app’s startup and build performance.

Static Linking (.a)

When you link the library statically, the linker effectively “copies and pastes” the compiled object code from the library’s archive (.a) into your app’s main executable binary. Once the build is finished, the library effectively ceases to exist as an independent entity. It physically becomes part of your app.

The linker scans your code to identify which symbols are used. It then extracts the relevant object files from the static library and rewrites the memory addresses to place the library code inline with your app code.

Source files Static libraries Static libraries Static linker Application file Application code Heap Stack Static libraries
A figure representing Static Linking.

The Pros

  • App Launch Speed: Because the code is already in the main binary, the OS doesn’t need to look for it, load it, or verify the signature during launch. The code is ready to execute immediately.

  • Compiler Optimization: Static Linking enables Link-Time Optimization (LTO). Because the compiler can see the entire codebase as a single unit, it can perform aggressive optimizations, such as inlining functions that are often impossible across dynamic boundaries.

  • Dead Code Stripping: The linker is smart. If your library contains 50 helper functions but you only use one, the linker usually discards the remaining 49. This is the default behavior for release builds. This helps maintain a lean and compact binary.

The Cons and Some Surprises

  • Binary Size Duplication: Consider an app and an extension that are both linked to a networking library. The networking library is copied twice, once in the app and again in the extension binary.

  • Slow Clean Builds: During development, every time you clean and rebuild the app, each target that statically links the library performs its own linking steps, which slows down the overall build time.

  • Ghost Code: Just because you put code into a static library doesn’t mean it will actually end up in your final app. If you have special code, such as an Obj-C category or a function marked with __attribute__((used)), that isn’t explicitly called by name, the linker may ignore the whole file, and the code will be missing from your app.

  • Duplicate Conflicts: If a static library is incorporated into multiple frameworks, and those frameworks are used in the app, you’ll commonly see runtime issues due to multiple definitions of the same class.

  • Missing Symbols: This can hide many static library issues. For example, if you forget to include the function your library refers to, the linker stops and reports an error. If dead code stripping is enabled and the linker decides that the path to that missing function is never executed by your app, it simply deletes the reference and stays silent. You won’t know the code is missing until the app tries to use it later and crashes.

Dynamic Linking (.dylib / .framework)

With dynamic linking, the static linker places a promise (a stub) in your executable. The promise says: “I don’t have the code for this function, but you can find it in FrameworkB.framework at runtime.”

The actual code remains in a separate binary (.dylib). It is loaded into memory only when the user launches the app.

When the user taps your app icon, the kernel spawns your process. Before the process reaches main(), the dynamic linker dyld runs.

The dynamic linker finds all the dynamic frameworks your app lists as dependencies, then maps them into the process’s memory space. Finally, it fulfills all those promises in your code, replacing the stubs with actual memory addresses where the dynamic libraries were loaded.

Dynamic libraries Dynamic libraries Source files Static linker Dynamic library references Application file Dynamic library references Application code Heap Stack
How the process of Dynamic Linking occurs.

You can also inspect the dyld linking process using the dyld_usage tool. In your terminal, run:

sudo dyld_usage <name-of-your-app>

Then launch your app. You will see a long list of logs; look for an entry like this:

12:23:59.242875   app launch -> 0x100db0000                  6.512988 YourAppName.33688106

In addition to using Instruments, this command lets you verify app launch time and examine the dynamic linking process.

The Pros

  • Fast Incremental Builds: If you have a bunch of dynamic frameworks and change a line of code in one of them, Xcode only needs to recompile and relink that specific framework. The main app binary doesn’t need to be modified; it simply points to the new reference.

  • Memory Sharing: System frameworks (like UIKit or SwiftUI) are dynamic. The OS loads them into memory once and shares them across all running apps. This saves a massive amount of RAM.

The Cons

  • Slow App Launch: This is one of the primary downsides of dynamic frameworks. Each dynamic framework you add increases the pre-main time because the dynamic linker must perform extensive work before your app can even display its launch screen. Apple recommends keeping the number of dynamic frameworks to a minimum.

  • No Aggressive Stripping: Because a library is a standalone binary, it must contain all its public code in case the app needs it. As a result, unused symbols are harder to strip from a dynamic library.

The Decision Matrix

So, as an app architect, which one do you choose?

By default, when using Swift Package Manager (SPM), the target is usually static. However, you can override this by explicitly declaring .dynamic in your Package.swift, but you should have a solid reason to do so.

Use the following matrix to guide your linking strategy:

Recommendation Scenario Static Core Utilities (e.g., , ) used only by the App NetworkingDesignSystem Static Feature Modules (e.g., , ) ProfileCart Dynamic Shared Code (App + Widget + Notification Extension) Dynamic Large Pre-compiled Vendor SDKs Enables the compiler to inline small helper functions for maximum performance. Optimizes launch time. Features are typically used only by the main app. Prevents code duplication. If you link statically here, the same heavy logic is bloated across three different executables in your bundle. Often distributed as XCFrameworks. Keeping them dynamic prevents them from slowing down your daily link times during development. Why?
Choosing Between Static & Dynamic Frameworks

The Rule of Thumb: For performance, the default approach is Static Linking. Use Dynamic when you need to share code between extensions or when you are explicitly optimizing for incremental build speeds in a large codebase.

The Swift Package Manager (SPM) Ecosystem

For many years, developers have relied on third-party tools like Carthage and CocoaPods to manage external dependencies. These tools were essentially workarounds layered on top of Xcode project files (.xcodeproj).

The SPM is different; it’s integrated directly into the build system. More importantly, it’s defined in Swift. This makes your build configuration type-safe, compiled, and logic-capable.

To master modularization, you must stop treating Package.swift as a simple config file and start treating it as a blueprint for your architecture.

Deconstructing Package.swift

A Package.swift file is the manifest that defines a package’s dependency graph. It has three core components: Products, Targets, and Settings.

1. Products vs. Targets

New architects often confuse these two.

  • Targets are the basic building blocks. They contain the source code.
  • Products are the executable artifacts (libraries or executables) that you expose to clients.

You can have internal targets that are not exposed as products at all. For example, you might have a CoreNetworking target and a CoreNetworkingTesting target. You expose CoreNetworking to the outside, but you keep the testing internal to your package unless you explicitly create a product for it.

2. Resources and Bundles

Handling assets in modular code is a bit trickier than in a monolith. You cannot just call Bundle.main.

When you add resources (images, JSON, storyboards) to a target, SPM synthesizes a new Bundle for that module.

  • .process: The default. It optimizes platform resources (compiles asset catalogs, compresses images).
  • .copy: Preserves the directory structure exactly as is.

To access these resources, you must use the compiler-synthesized accessor Bundle.module. If you try to use Bundle(for: MyClass.self), you might accidentally grab the wrong bundle if the linking type (static vs dynamic) changes.

3. Conditional Build Settings

You often need to pass flags to the compiler. SPM allows this via swiftSettings.

targets: [
  .target(
    name: "MyFeature",
    swiftSettings: [
      .define("DEBUG_NETWORK", .when(configuration: .debug)),
      .enableExperimentalFeature("StrictConcurrency")
    ]
  )
]

This is how you enforce strictness or enable experimental features on a per-module basis.

Local Packages & The Monorepo

One of the most effective ways to use SPM is the Monorepo approach.

Instead of creating hundreds of repositories for each of your packages, which ultimately creates a versioning nightmare, you put them all in the same repository as your main app.

The Setup:

  1. Create a folder named Packages/ in your root directory.
  2. Initialize new packages inside that folder (swift package init).
  3. In your workspace, drag the folder into Xcode.

Finally, in your main app’s dependencies or in a “umbrella” package, you reference these modules using a file path, not a URL.

dependencies: [
  // No version requirement needed!
  .package(path: "../Packages/ProfileFeature"),
  .package(path: "../Packages/CoreUI")
]

Benefits:

  • You edit a file in ProfileFeature, hit Run, and the app updates. No git add, git push, git tag, or SPM Update required.
  • Code searching finds everything. The refactoring tools work across module boundaries.

Binary Targets (XCFrameworks)

Sometimes, you cannot (or should not) ship source code.

  • Vendor SDKs: You’re distributing a proprietary feature or algorithm.
  • Build Time Optimization: You have a massive module (like OpenCV or a custom 3D engine) that takes 5 minutes to compile. You should compile it once and let the team use the binary.

XCFramework is the right tool for that.

An XCFramework is a bundle that contains multiple compiled binaries for different architectures, for example, ios-arm64 for devices and ios-arm64_x86_64-simulator for the simulator.

Declaring the Target: In your Package.swift, you define a binaryTarget.

targets: [
  .binaryTarget(
    name: "MySecretAlgo",
    // Local file
    path: "Binaries/MySecretAlgo.xcframework"
  )
]

When the compiler encounters a binaryTarget, it skips the compilation phase for that framework and proceeds directly to linking. This is a powerful optimization technique for stabilizing build times in large teams.

Versioning and Distribution Strategies

Writing a module is one thing, and maintaining it for others is another. When you distribute a framework, whether to the open-source community or to another team in your company, you’re essentially establishing a contract.

The build system relies on this contract to resolve the dependency graph. If you break the contract, you break the build.

Semantic Versioning (SemVer)

SPM relies heavily on Semantic Versioning to make decisions. It’s not just a numbering scheme but a language that tells the resolver how safe it is to upgrade.

Format: major.minor.patch (e.g., 1.4.2)

  • major: You made incompatible API changes. (Code that compiled previously may no longer compile.)
  • minor: You added functionality in a backward-compatible way.
  • patch: You made backward-compatible bug fixes.

When you define a dependency in your Package.swift, you typically specify the version with .upToNextMajor(from: "1.0.0"). This tells the resolver: “I accept any version from 1.0.0 up to (but not including) 2.0.0.”

This is why complying with SemVer is critical. For example, if you introduce a breaking change (renaming a public function) in your framework and only increment the minor version (e.g., 1.1.0 to 1.2.0), you’ll end up breaking the build for every consumer who trusted your SemVer promise.

Note: Package.resolved is generated (or updated) every time SPM performs dependency resolution. It records the exact versions of all resolved Swift package dependencies to ensure reproducible builds across machines.

Dependency Hell: The Diamond Problem

Imagine the following scenario:

  • Module A depends on Logger v1.0.
  • Module B depends on Logger v2.0 (which has a breaking change).
  • Your app imports both Module A and Module B.

Basically, if Dante had been an iOS developer, he would have added a Tenth Circle to the Inferno specifically for this. The build system now gets stuck. It cannot build the app with two different versions of the Logger framework in the same executable.

Unlike the web ecosystem (e.g., Node.js/NPM), which allows nested dependencies (each module receives its own copy), Swift/iOS enforces a flat namespace. In a single process, there can be only one implementation of a symbol. Logger.log() cannot mean two different things simultaneously.

The Resolution: SPM resolves dependencies by selecting a single package version that satisfies all the declared version constraints. When multiple versions are valid, it prefers the latest one within the overlapping range defined by the constraints. If no overlapping version exists, dependency resolution fails.

The Fix: As an architect, you must coordinate updates across your team. This is why monorepos are popular: they require all modules to use the same versions of dependencies, eliminating version conflicts.

API Design for Modules

Controlling what is visible to the outside world is fundamental to compile-time performance and long-term stability.

1. public vs. open

  • public: A consumer can call this class or method, but cannot override or subclass it. This allows the compiler to make stronger optimization assumptions.
  • open: A consumer can override and subclass it. This is the most expensive access level because it requires the compiler to use dynamic dispatch for all operations, thereby preventing many optimizations.

2. The package Access Control

For years, Swift developers struggled with a limitation: if you wanted to share code between two targets in the same package (e.g., Core and UI), you had to make it public. However, that also exposed it to the rest of the world.

There is another access control in Swift: package.

  • internal: Visible only within the target.
  • package: Visible to any target in the same package (but hidden from external consumers).
  • public: Visible to everyone.

This changes the game for modularization. It allows you to create Swift packages with shared internal utilities that remain invisible to the client app.

3. @inlinable

This attribute is a double-edged sword. When you mark a function with @inlinable, you allow the compiler to copy the function’s body directly into the client’s code during compilation.

  • Pros: Can deliver significant performance gains, especially for tight loops (Array.map). It avoids the overhead of a function call.
  • Cons: Your implementation details are exposed. If you change the logic of an @inlinable function in a future release, consumers will not see the change until they recompile their own code, because the old logic was already baked into their binary.

Use @inlinable only for small, stable algorithms that are unlikely to change over time. Never use it for logic that depends on private state.

Build Time Optimization

In professional software development, build time is currency. If a clean build takes 20 minutes and an incremental build takes 2 minutes, a developer who builds 30 times a day loses an hour of concentration and efficiency every day.

As an architect, optimizing build times isn’t simply about making builds fast; it’s about structuring your dependency graph so the compiler does only the work necessary.

Compiler Flags: Hunting for Slow Code

Sometimes, the slowdown isn’t just the architecture but the code itself. Swift has a powerful type inference engine, but complex expressions (especially those involving nested closures, generics, or overloaded operators) can cause the type checker to take exponential time to resolve.

You can ask the Swift compiler to tell you exactly which functions are slowing down compilation by passing specific arguments to the frontend.

In your Target’s Build Settings –> Other Swift Flags, add:

  • -Xfrontend -warn-long-function-bodies=100 This triggers a warning for any function that takes longer than 100ms to compile.
  • -Xfrontend -warn-long-expression-type-checking=100 This triggers a warning for each particular expression (such as a complex dictionary literal or a chained map/filter) that takes longer than 100ms to type-check.

This is how the setup looks in the Build Settings:

Setting up Other Swift Flags
Setting up Other Swift Flags

Frequent Causes:

  • Collection Literals: Large dictionaries or arrays missing explicit type annotations (let data: [String: Any] = ...).
  • Operator Overloading: Chaining + on strings or custom math operators.
  • SwiftUI Bodies: Deeply nested view builders can sometimes overwhelm the compiler. Breaking complex views into smaller subviews often significantly speeds up compilation.

Indexing & The Index Store

While compilation builds the binary, indexing builds the brain.

The Index Store is the database that lives inside your Derived Data folder. From it, Xcode gains the power of features such as jump to definition, Find Callers, and refactoring tools.

The indexing process runs separately from the build process, but they share the same CPU resources.

  • The Hang: If you see “Indexing…” stuck at the top of Xcode indefinitely, or if autocomplete stops working, it likely means that your Index Store is corrupted.

  • The Fix: You don’t always need to “Clean Build Folder” (which deletes the binaries). You can clear only the index:

  1. Navigate to DerivedData/YourApp/Index
  2. Delete the DataStore folder.
  3. Restart Xcode.

Xcode will rebuild the index from scratch without forcing a full recompile of your binaries.

Explicit Modules: Apple is pushing towards explicit modules, where the build system separates the scanning phase (finding dependencies) from the building phase (builds the modules first and then the source code). This prevents the compiler from repeatedly parsing the same headers across targets, drastically reducing overhead in highly modularized setups. This is the default behavior in Xcode 26 and later, and it relies heavily on an acyclic dependency graph.

Key Points

  • True architecture isn’t just about code quality; it is about system organization. It aims to maintain usability, velocity, and performance.
  • Monolithic apps suffer from slow compilation times, frequent merge conflicts, and blurred feature boundaries.
  • Modularization enables the compiler to rebuild only the parts of the app that changed, significantly accelerating development cycles.
  • You are building a Directed Acyclic Graph (DAG). Circular dependencies break the graph and must be resolved using the Dependency Inversion Principle.
  • A Library is the compiled code (.a/.dylib), a Module is the interface definition (.swiftmodule, containing the AST/SIL), and a Framework is the package container (.framework).
  • Static linking copies code into the executable, optimizing launch time and enabling aggressive optimizations at the cost of slower clean builds.
  • Dynamic linking references code at runtime via dyld, enabling memory sharing and faster incremental builds at the cost of slower app launches.
  • Default to Static for feature modules and core utilities. Use Dynamic only when sharing code between extensions or optimizing build times at scale.
  • In modules, use the compiler-synthesized property Bundle.module to access resources regardless of linking style.
  • Develop local packages within the same repository using file path (path: "../Packages/ProfileFeature") to avoid versioning fatigue.
  • Use package access control to share code within a component without exposing it publicly. Avoid open unless necessary, as dynamic dispatch incurs costs.
  • Use -warn-long-function-bodies and -warn-long-expression-type-checking to identify specific code blocks that are strangling your build times.

Where to Go From Here?

You have reached the final page of the last chapter, which also brings you to the end of The Swift Internals, but do not mistake this for the finish line. In reality, this is just the starting line.

You began this journey by delving into the microscopic details of Memory Layout and Reference Counting. You explored the power of Generics, the complexity of Concurrency, and the magic of the compiler. Finally, you zoomed out to take a macroscopic view of Modularization and Linking.

You now understand that every abstraction has a cost. Behind every import, a linker is doing heavy lifting (not literally). You know that safety in Swift is often a choice, and sometimes you must reach toward the unsafe side to get the job done.

Bring this mindset into your daily work. Don’t just fix bugs; understand why they happened. Don’t just optimize build time; profile the dependency graph as well. The tools will change. Swift will evolve. But the dynamics of how software is built, linked, and executed remain the foundation of everything a software developer does.

You’re no longer just a coder; you’re a master of Swift. When people ask whether your modular architecture can handle the scale, look them in the eye and say: “It can.”

As you turn to leave, look back and say: “You have no idea how high it can scale.”

The foundation is set. The tools are in your hands. Now, go build something robust.

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2026 Kodeco Inc.