For a small native Mac utility, start with a static library when the code ships with the executable. Choose a dynamic library when a separate runtime component is useful, and a framework when you need Apple’s bundle packaging. This guide builds the same C++ helper both ways.
The concrete task is to package a reusable grid calculation for a local macOS desktop utility. The example uses synthetic inputs and terminal commands so you can see compilation, linking, and loading separately before choosing a format for an app project.
What are you actually choosing?
There are two decisions: how code links, and how its deliverable is packaged. A static library, usually ending in .a, is an archive of object files. A dynamic library, ending in .dylib, remains a separate runtime image. A framework is a bundle that organizes a library and related material. Apple describes these underlying formats in An Apple Library Primer.
A framework can also contain a static library. Apple supports creating static frameworks in Xcode 15 and later, including resources. Consequently, the .framework suffix alone does not establish whether the library links statically or dynamically. See Creating a static framework.
Use this decision aid for an app-owned helper that you build from source:
| Your requirement | Starting choice | Responsibility it introduces |
|---|---|---|
| One executable and a helper rebuilt together | Static .a |
Relink the executable when the helper changes |
| A helper that remains a separate runtime component | Dynamic .dylib |
Supply the library and a working runtime lookup path |
| An Xcode component with public headers and bundled resources | Framework | Choose its linkage separately and configure target membership |
| Binary variants for multiple Apple platforms | XCFramework | Build and package the required platform variants |
These are practical recommendations derived from the documented formats. They do not establish a performance winner. For this grid utility, a static archive is the natural starting point: there is one client, no resource bundle, and no requirement to replace a separate library.
An XCFramework packages binary variants; it does not replace the static-versus-dynamic decision. Apple permits static or dynamic frameworks and libraries within that distribution format. See Creating a multiplatform binary framework bundle.
Check compatibility before selecting a container
A matching filename or CPU architecture is insufficient. macOS, Mac Catalyst, and iOS are distinct build platforms. An arm64 library compiled for one platform cannot simply be linked into another platform’s executable. Apple explicitly distinguishes platform from architecture in An Apple Library Primer.
For this task, keep these inputs consistent: the macOS target, the required CPU architecture, the minimum macOS version, and compatible language/runtime settings. A universal Mac deliverable needs matching dependency slices for each supported architecture. Apple’s Building a universal macOS binary covers rebuilding libraries as well as executables.
C++ compatibility needs its own attention. Selecting -std=c++17 chooses a language mode; it does not certify binary compatibility between independently built components. LLVM documents ABI guarantees and vendor configuration choices for libc++, including settings that alter type layouts. For an app-owned dependency, a useful default is to rebuild it with the app’s toolchain and consistent settings. See libc++’s ABI Guarantees.
The example exposes one function through a C-compatible header while implementing it in C++. The guarded extern "C" declaration specifies C language linkage for C++ compilation. It does not convert arbitrary C++ classes into C interfaces or make a binary portable across platforms. The C++ working draft defines this boundary in Linkage specifications.
Build one helper as both a static and a dynamic library
You need a Mac with Apple’s developer tools and a macOS SDK. Full Xcode includes the command-line tools; Apple also provides a standalone Command Line Tools package. If neither is installed, run xcode-select --install and finish the installer before continuing. See Installing the command-line tools.
The complete script below creates a fresh temporary directory and uses only synthetic source files. Run it in Terminal. It explicitly targets the current shell environment’s architecture and macOS 11.0 as an illustrative deployment minimum. It is a single-architecture exercise; use a different minimum when your project requires one.
I inspected the example but could not execute the Mac build: the research environment runs Linux and has no clang++. The displayed results are expected results, not recorded test output.
bash <<'SH'
set -eu
work_dir=$(mktemp -d "${TMPDIR:-/tmp}/mac-library-demo.XXXXXX")
cd "$work_dir"
printf 'Example directory: %s\n' "$work_dir"
mkdir include src build
mkdir -p build/static build/dynamic/lib
cat > include/grid.h <<'EOF'
#ifndef GRID_H
#define GRID_H
#ifdef __cplusplus
extern "C" {
#endif
/* Inputs: positive dimensions whose product fits in int. */
int grid_cells(int rows, int columns);
#ifdef __cplusplus
}
#endif
#endif
EOF
cat > src/grid.cpp <<'EOF'
#include "grid.h"
int grid_cells(int rows, int columns) {
return rows * columns;
}
EOF
cat > src/main.cpp <<'EOF'
#include "grid.h"
#include <cstdio>
int main() {
const int cells = grid_cells(3, 4);
std::printf("Grid cells: %d\n", cells);
return cells == 12 ? 0 : 1;
}
EOF
sdk_path=$(xcrun --sdk macosx --show-sdk-path)
build_arch=$(uname -m)
case "$build_arch" in
arm64|x86_64) ;;
*) printf 'Unexpected architecture: %s\n' "$build_arch" >&2; exit 1 ;;
esac
xcrun --sdk macosx clang++ \
-std=c++17 -arch "$build_arch" -isysroot "$sdk_path" \
-mmacosx-version-min=11.0 -Iinclude \
-c src/grid.cpp -o build/grid.o
xcrun --sdk macosx ar rcs build/static/libGrid.a build/grid.o
xcrun --sdk macosx clang++ \
-std=c++17 -arch "$build_arch" -isysroot "$sdk_path" \
-mmacosx-version-min=11.0 -Iinclude \
src/main.cpp build/static/libGrid.a -o build/static/grid-demo
xcrun --sdk macosx clang++ \
-arch "$build_arch" -isysroot "$sdk_path" \
-mmacosx-version-min=11.0 -dynamiclib build/grid.o \
-Wl,-install_name,@rpath/libGrid.dylib \
-o build/dynamic/lib/libGrid.dylib
xcrun --sdk macosx clang++ \
-std=c++17 -arch "$build_arch" -isysroot "$sdk_path" \
-mmacosx-version-min=11.0 -Iinclude \
src/main.cpp build/dynamic/lib/libGrid.dylib \
-Wl,-rpath,@loader_path/lib -o build/dynamic/grid-demo
./build/static/grid-demo
./build/dynamic/grid-demo
xcrun --sdk macosx ar t build/static/libGrid.a
xcrun --sdk macosx otool -L build/static/grid-demo
xcrun --sdk macosx otool -L build/dynamic/grid-demo
xcrun --sdk macosx otool -l build/dynamic/grid-demo
xcrun --sdk macosx lipo -archs build/dynamic/grid-demo
SH
Both executions should print Grid cells: 12. This calculation is deliberately small: three rows multiplied by four columns. The helper’s stated input contract excludes dimensions whose product cannot fit in int; this example does not implement a general input-validation API.
What the static build demonstrates
The -c invocation produces grid.o without linking an executable. The archive command packages that object into libGrid.a; its r, c, and s options request insertion/replacement, archive creation, and a symbol index. Clang documents compilation stages in its compiler command guide, and LLVM explains archive operations in llvm-ar — LLVM archiver.
The client links using the archive’s explicit path. This makes the selected input unambiguous when both static and dynamic versions exist. The expected archive listing contains grid.o, while the executable’s dependency listing should have no runtime dependency on libGrid.a.
Static linking here applies to the Grid helper. It does not mean the resulting executable is independent of macOS runtime libraries; inspect the remaining entries reported by otool -L. Apple distinguishes the build-time linker from the runtime loader in An Apple Library Primer.
What the dynamic build demonstrates
The -dynamiclib invocation creates a separate library. Its install name is @rpath/libGrid.dylib. The executable records a run path of @loader_path/lib, which, for this executable’s run-path entry, points to the adjacent lib directory. Clang lists the relevant driver options in its command-line argument reference.
The expected otool -L output for the dynamic client includes @rpath/libGrid.dylib; the load-command output should contain an LC_RPATH entry with @loader_path/lib. Apple documents how install names and executable run paths cooperate in Run-Path Dependent Libraries.
This gives you a concrete packaging test: move the entire build/dynamic directory together, preserving its internal layout, then run its executable from the new location. Moving only the executable leaves its Grid dependency behind. A successful link establishes that build inputs were found; it does not establish that the runtime package is complete.
When should the helper become a framework?
Choose a framework when its bundle structure serves the project—for example, when the component needs public headers and resources managed together in Xcode. For this calculation-only helper, a framework adds packaging work without satisfying an additional requirement.
For a static framework in Xcode 15 or later, Apple documents this sequence:
- Choose
File > New > Target, select macOS, and use theFrameworktemplate. - Select the new target’s
Build Settingsand changeMach-O TypetoStatic Library. - Add implementation files to
Compile Sourcesand resources toCopy Bundle Resources. - Add exported headers to the
Headersbuild phase and include public headers in the umbrella header. - Add the framework to the consuming app and follow Apple’s documented embedding configuration.
For a resource-bearing static framework, Apple specifies Embed & Sign. Xcode omits the already-linked main binary from the embedded bundle while retaining the bundle material. Follow Creating a static framework rather than treating every static framework as an archive that needs no embedding.
If you distribute binary variants, use an XCFramework. For an existing standalone .a, Apple documents the -library and -headers inputs to xcodebuild -create-xcframework; it advises against manually disguising the archive as a framework. Full Xcode is required for xcodebuild. See Creating a multiplatform binary framework bundle and Installing the command-line tools.
How does Swift change the decision?
Swift adds module-consumption and evolution requirements to the underlying linkage decision. ABI stability, module stability, and library evolution address different compatibility concerns; a .framework container does not enable them automatically.
Swift’s guidance says that frameworks built and distributed together with their clients generally should not enable library evolution. For a binary framework built and updated separately, decide on evolution support from the first release. In Xcode, BUILD_LIBRARY_FOR_DISTRIBUTION enables library evolution and module stability. These choices affect performance characteristics and permitted API changes, as explained in Library Evolution in Swift.
Keep those guarantees within their documented platform scope. Swift’s documentation describes cross-compiler binary compatibility on Apple platforms; do not extend that promise to every Swift-supported platform or interpret it as permission to make arbitrary API changes. The C-compatible header in this example also does not constitute a complete Swift module setup.
Diagnose a failed build in the order it happens
Use the stage of failure to narrow the next check:
| Symptom | First check | Next action |
|---|---|---|
Compiler cannot find grid.h |
Header exists under include |
Check the working directory and -Iinclude |
| Undefined reference to the Grid function | Implementation and declaration agree | Confirm the intended archive or dynamic library is on the link command |
| Architecture or platform mismatch | Actual dependency slices and build platform | Rebuild the dependency for the client’s macOS target |
| Executable links but cannot load Grid | Recorded dependency and run path | Compare otool -L, otool -l, and the packaged directory layout |
| Intel and Apple silicon builds behave differently | Every dependency’s architecture coverage | Inspect each binary and test the intended architecture |
Clang’s compiler command guide separates compilation from linking. Apple’s Run-Path Dependent Libraries explains runtime resolution, while Building a universal macOS binary documents architecture inspection with lipo -archs. Those distinctions make the diagnostic order useful: changing header paths cannot repair a missing runtime library.
For the synthetic grid utility, keep the static archive until a concrete packaging or runtime requirement justifies another choice. If that requirement appears, preserve the same small calculation as a build check, then verify the selected library, target compatibility, and final directory layout separately.
Sources
- An Apple Library Primer — Apple Developer Technical Support.
- Creating a static framework — Apple.
- Creating a multiplatform binary framework bundle — Apple.
- Building a universal macOS binary — Apple.
- Installing the command-line tools — Apple.
- Run-Path Dependent Libraries — Apple Documentation Archive.
- clang — the Clang C, C++, and Objective-C compiler — LLVM Project.
- Clang command line argument reference — LLVM Project.
- llvm-ar — LLVM archiver — LLVM Project.
- libc++’s ABI Guarantees — LLVM Project.
- Linkage specifications — C++ Working Draft, rendered by eel.is.
- Library Evolution in Swift — Swift.org.