Greetings, traveler!
Modularization changes how a project feels day to day. The codebase becomes easier to reason about, feature work becomes more parallel, and dependency boundaries start to matter. At the same time, the build system becomes part of the engineering surface area. Linking strategy is one of the levers that shows up as projects grow, especially once you have multiple targets, many modules, and a CI pipeline that needs to stay fast.
This article lays out a clear mental model for static, dynamic, and mergeable linking, then explains where mergeable libraries fit and why tools like Tuist often enter the conversation. The goal is clarity and trade-offs, not a single “correct” choice.
Libraries, frameworks, and where assets live
A library is code packaged for reuse. On Apple platforms, that code can be shipped as a static library (.a) or a dynamic library (.dylib).
A framework is a folder that contains a library plus the surrounding structure and metadata: an Info.plist, code signature, headers (when relevant), and potentially resources such as asset catalogs or localized strings. The framework “inherits” the library’s linking model. A framework that contains a static library behaves as a static framework. A framework that contains a dylib behaves as a dynamic framework.
Resources follow their own rules. Code may end up inside the app executable, while assets can be packaged separately as .bundle files inside the .app. In Swift Packages, that resource bundle is common and easy to miss when reasoning about size and duplication.
Linking as a build step: what actually happens
At a high level, the linker combines compiled object files into an executable program. Modular iOS apps feed the linker a set of compiled modules: app targets, feature modules, shared utilities, and third-party dependencies.
The key question is timing: does the final “composition” happen at build time, or at launch time? That is the difference between static and dynamic linking.
Static linking
With static linking, compiled object files from a static library or static framework are copied into the app’s main executable during the final stages of the build.
This detail drives the practical characteristics engineers care about:
- Large modules take longer to integrate into the executable.
- Complex graphs cause more work for incremental builds, because changes ripple into link steps more often.
- The output for the app target becomes a single Mach-O executable that contains the app’s code plus the linked code from static dependencies.
From a runtime perspective, a single executable is straightforward for the OS to load, and that typically helps launch time.
Where static linking gets expensive: multiple targets
Static linking becomes more visible once the product contains more than one executable target. Widgets, notification service extensions, share extensions, and similar targets can each pull in shared modules. Static libraries and static frameworks (alongside their resource bundles) can be copied into each target that links them, which inflates the overall installed size.
If your product is a single executable (just the main app), size differences between static and dynamic often feel minor. With extensions, the duplication story becomes concrete.
Dynamic linking
With dynamic linking, code lives in a separate dynamic library or dynamic framework. During the build, the main executable records where the dynamic module will be found. The module’s code is mapped into the process at launch by the dynamic linker (dyld) before your code reaches main.
A few consequences follow from that model:
- Build iterations can be faster because the link step avoids copying the entire module into the app binary.
- Launch does additional work because
dyldmaps each dynamic module into memory in the pre-mainphase. - Shared code can be used across multiple targets without copying the same code into each executable.
Dynamic linking is not “lazy loading.” These modules are not pulled in on demand halfway through the session. They are part of process startup.
Optimization and size are not guaranteed
Dynamic modules are compiled independently from the rest of the app. That affects what kinds of whole-program optimizations can happen at build time. It also means “make it dynamic to reduce size” is not a rule that always holds. The right way to decide is measurement on your project.
Declaring a UI library as static or dynamic
In Swift Package Manager, the choice between static and dynamic linking is expressed at the product level. The module’s source code remains the same; only the way it is exposed to consuming targets changes.
A UI library declared without an explicit linking type uses SwiftPM’s default behavior, which results in static linking:
// Package.swift
let package = Package(
name: "UILibrary",
platforms: [.iOS(.v16)],
products: [
.library(
name: "UILibrary",
targets: ["UILibrary"]
)
],
targets: [
.target(
name: "UILibrary",
resources: [
.process("Resources")
]
)
]
)In this configuration, the library’s compiled code is incorporated into each executable target that depends on it during the final link step.
The same library can also be declared as dynamic by specifying the product type explicitly:
// Package.swift
let package = Package(
name: "UILibrary",
platforms: [.iOS(.v16)],
products: [
.library(
name: "UILibrary",
type: .dynamic,
targets: ["UILibrary"]
)
],
targets: [
.target(
name: "UILibrary",
resources: [
.process("Resources")
]
)
]
)Here, SwiftPM builds the UI library as a framework that is referenced by consuming targets rather than embedded into each one. From the perspective of application code, imports and APIs are identical in both cases. The distinction exists entirely at the build and packaging stage, where the linker determines how shared modules are represented in the final app bundle.
Mergeable libraries in Xcode 15+: what they aim to provide
Mergeable libraries were introduced as a way to balance the trade-off between build iteration speed and production launch behavior.
The basic design is simple:
- During development builds, the module behaves like a dynamically linked library.
- During release builds, the module can be merged into the main executable, producing behavior closer to static linking.
Under the hood, a dynamic module can be built as mergeable by setting the appropriate build setting (MERGED_BINARY_TYPE). The linker emits additional metadata alongside the .dylib so the release build can merge it. That metadata increases the size of the mergeable library artifact itself, which is expected given the extra information carried for the merge step.
Mergeable libraries are best understood as a build configuration tool. They change how the build system decides to assemble the final binaries.
The cost of flexibility
Mergeable libraries add power to the build system. Power tends to arrive with new failure modes.
Once mergeable behavior is introduced, the final linking model is no longer a direct property of a module. Instead, it becomes the result of build-time resolution across configurations and targets. Whether a dependency is treated as static or dynamic may depend on a combination of settings defined in different parts of the project. As the number of modules and targets grows, reasoning about that outcome becomes increasingly indirect.
At the same time, linking decisions are often influenced by a more concrete concern: how shared code is represented across executable targets. In projects that include extensions or auxiliary targets, static linking can lead to the same modules being embedded multiple times. For some teams, avoiding that duplication becomes a primary motivation for introducing dynamic or mergeable behavior in the first place.
This shift moves more responsibility from explicit structure to build-time inference. Teams need consistency and clear ownership of those settings to avoid subtle mismatches, especially in long-lived codebases where new targets are added incrementally.
Asset bundling can become part of this complexity as well. Shared resources are often packaged per target by default, and reducing duplication requires deliberate coordination between how modules are built and how they are consumed. Mergeable libraries do not automatically change this behavior.
Finally, this added indirection can surface in development tooling. Features that rely on predictable build outputs—such as SwiftUI previews or other forms of partial builds—are particularly sensitive to configuration drift and implicit resolution. Mergeable libraries can still be the right choice, but they turn linking into a system rather than a local decision. Systems, in turn, require explicit control points to remain reliable.
Using a Shared Dynamic Framework to Avoid Duplication Across Targets
As mentioned earlier, modularizing your code using Swift Packages can lead to duplication when SwiftPM modules are statically linked (the default), any shared module used by the main app and by an app extension ends up being incorporated into each binary separately. The effect becomes more pronounced as the product grows: a Share extension, Widget, Notification Service, or App Intents extension can all pull the same modules, inflating installed size and making shared code harder to treat as a single artifact.
This duplication often applies to resources as well. When a Swift package target contains resources, Xcode generates a separate resource bundle per module (typically named like <Package>_<Module>.bundle) and copies that bundle into every target that depends on the module. As a result, the same images, localized strings, and other assets can be duplicated across the app bundle and each extension bundle.
A practical approach to eliminating code duplication is to introduce an “aggregator” module: a package target that depends on all shared feature modules but contains no code itself. By exposing that aggregator as a single dynamic framework and making the app and extensions depend on it, shared code becomes a single runtime artifact instead of being statically embedded multiple times. However, even after code is consolidated into a dynamic framework, module resource bundles typically remain duplicated because Xcode still copies them into each target by default.
The key observation is how SwiftPM resources are resolved at runtime. Xcode generates a module-scoped Bundle.module accessor for package resources, and its lookup logic includes the bundle of the hosting framework (i.e. the bundle where the module’s code actually lives). This means it is safe for resource bundles to live inside the dynamic framework that contains the modules — resource resolution still succeeds because Bundle.module checks both the main bundle and the framework’s bundle. Based on that, the workflow becomes two-layered: first, consolidate code into a single shared dynamic framework; then, relocate package resource bundles into that framework and remove redundant copies from the app and extension bundles.
It can be implemented using additional build phases and scripts: one step copies the generated module bundles into the framework during the build, while another step removes the same bundles from the app and extension directories, ensuring that each resource exists only once in the final installed product. Paulo Andrade wrote an article about this approach, you can read it here.
Where Tuist fits
Tuist is not a linking technology. It is a way to define and generate your Xcode project from a manifest, so build settings and target structure live as code rather than a large .pbxproj file.
That matters in this discussion for a practical reason: modern modular apps often accumulate:
- many targets and configurations
- shared build settings that must stay aligned
- scripts and resource rules that need to apply uniformly
- decisions about whether modules are built as static frameworks or dynamic frameworks
Xcode can represent all of this. Keeping it consistent by hand becomes harder as the surface area grows. A project generator can make those choices explicit, reviewable, and repeatable across new targets.
A simple way to think about the choices
- Static linking concentrates code into the app executable at build time.
- Dynamic linking keeps code in separate modules and maps them into memory during startup.
- Mergeable libraries allow a module to behave dynamically during development and merge during release, driven by build configuration.
- A shared dynamic “aggregator” framework can reduce duplication, with assets consolidated into the framework when needed.
- Tools like Tuist help teams keep these decisions consistent as projects grow.
Linking behavior shows up in build times, launch performance, and installed size, and the exact numbers depend on your graph, targets, and resources. Measure your project. The win comes from making the trade-offs explicit and keeping the configuration manageable over the lifetime of the codebase.
