A practical guide to modernizing older C/C++ build systems with CMake—reducing platform-specific complexity, improving developer workflows, and creating a more maintainable path to builds across Windows, Linux, and macOS.

A practical guide to modernizing older C/C++ build systems with CMake—reducing platform-specific complexity, improving developer workflows, and creating a more maintainable path to builds across Windows, Linux, and macOS.
Make has been part of C and C++ development for decades.
And for good reason.
A well-written Makefile can be simple, fast, and extremely effective.
The problem usually appears as a project grows.
What started as:
main.cpp
utils.cpp
appeventually becomes:
Core
Networking
Database
UI
Tests
Tools
Third-Party Libraries
Platform CodeThen the Makefile starts accumulating platform-specific logic:
ifeq ($(OS),Windows_NT)
...
else
...
endifDifferent compiler flags appear.
Library paths change.
Windows needs one configuration.
Linux needs another.
macOS needs another.
Developers begin maintaining multiple build commands just to produce the same application.
At that point, the build system itself becomes a maintenance problem.
This is where CMake can provide a cleaner abstraction.
A common misconception is:
"CMake is a build system."
More precisely, CMake is a build-system generator and build configuration tool.
You describe the project in CMake.
CMake can then generate build files appropriate for the selected environment.
For example:
CMake Project
│
┌───────────┼───────────┐
▼ ▼ ▼
Ninja Make Visual Studio
│ │ │
▼ ▼ ▼
Build Build BuildThis gives developers a common project description without forcing every platform to use exactly the same underlying build tool.
That distinction is important.
Instead of writing platform-specific instructions everywhere, you describe:
What should be built
What it depends on
How targets relate to each other
CMake and the chosen generator handle much of the platform-specific build mechanics.
| Area | Legacy Makefile | Modern CMake |
|---|---|---|
| Platform handling | Often manual | Better abstraction |
| IDE integration | Limited / custom | Strong |
| Dependency modeling | Often implicit | Target-based |
| Build configuration | Custom variables | Standardized options |
| Windows support | Often more work | Native toolchain support |
| Linux support | Strong | Strong |
| macOS support | Possible | Strong |
| Testing integration | Custom | CTest |
| Package integration | Varies | Broad ecosystem |
| CI/CD | Script-heavy | Easy to integrate |
| Large projects | Can become complex | Designed for structured projects |
This does not mean CMake is automatically simpler.
Poorly designed CMake can become just as complicated as a large Makefile.
The difference is that modern CMake gives you better tools for expressing project structure and dependencies.
Older CMake projects sometimes rely heavily on global settings:
include_directories(...)
add_definitions(...)
link_libraries(...)Modern CMake encourages a target-oriented approach.
Instead of saying:
"Add this include path everywhere."
you define what a particular target needs.
For example:
add_executable(MyApp
main.cpp
utils.cpp
)
target_include_directories(MyApp
PRIVATE
include
)And dependencies can be expressed directly:
target_link_libraries(MyApp
PRIVATE
MyLibrary
)This creates a clearer dependency graph.
Conceptually:
MyApp
│
┌───────┴───────┐
▼ ▼
MyLibrary ExternalLib
│
▼
CoreEach target knows what it needs.
That is much easier to maintain as the project grows.
Targets are one of the most important concepts in modern CMake.
A target can represent:
For example:
add_library(core
src/core.cpp
)
add_executable(app
src/main.cpp
)
target_link_libraries(app
PRIVATE core
)The dependency is explicit:
app
↓
coreThis allows CMake to understand the relationship.
That can help determine:
The key principle is:
Describe relationships between targets instead of manually describing every compiler command.
A simple project might look like:
MyProject/
├── CMakeLists.txt
├── include/
│ └── calculator.h
├── src/
│ ├── calculator.cpp
│ └── main.cpp
└── tests/
└── calculator_test.cppA basic configuration could be:
cmake_minimum_required(VERSION 3.25)
project(MyProject
VERSION 1.0
LANGUAGES CXX
)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
add_library(core
src/calculator.cpp
)
target_include_directories(core
PUBLIC
${PROJECT_SOURCE_DIR}/include
)
add_executable(app
src/main.cpp
)
target_link_libraries(app
PRIVATE core
)The important part is not the exact version number or syntax.
It is the structure.
The project declares:
What it is
What it builds
What each target requires
How targets depend on each other
This is where the migration can provide major value.
A legacy Makefile might contain many operating-system checks:
Windows
├── Compiler Flags
├── Paths
└── Libraries
Linux
├── Compiler Flags
├── Paths
└── Libraries
macOS
├── Compiler Flags
├── Paths
└── LibrariesCMake provides platform-aware variables and conditional logic.
For example:
if(WIN32)
target_compile_definitions(app PRIVATE PLATFORM_WINDOWS)
elseif(APPLE)
target_compile_definitions(app PRIVATE PLATFORM_MACOS)
elseif(UNIX)
target_compile_definitions(app PRIVATE PLATFORM_LINUX)
endif()The important strategy is to minimize platform-specific code, not simply move every Makefile condition into CMake.
Use platform-specific logic only where the application genuinely behaves differently.
For example:
Shared Code
│
├── Windows Implementation
├── Linux Implementation
└── macOS ImplementationThis creates a cleaner separation between portable code and platform-specific functionality.
Dependency management is often one of the hardest parts of legacy build systems.
A Makefile may contain manually configured paths:
-I/path/to/library/include
-L/path/to/library/lib
-lSomeLibraryThose paths may work on one developer's machine and fail elsewhere.
Modern CMake encourages dependencies to be represented as targets.
For example:
find_package(SomeLibrary REQUIRED)
target_link_libraries(app
PRIVATE
SomeLibrary::SomeLibrary
)The exact mechanism depends on how the library is distributed.
The broader idea is:
Treat dependencies as part of the build graph rather than collections of manually constructed compiler flags.
This makes projects easier to reproduce across developer machines and CI environments.
One of the simplest improvements you can make when moving to CMake is separating generated build files from source code.
Instead of:
Project/
├── source files
├── object files
├── generated files
└── executablesuse:
Project/
├── CMakeLists.txt
├── src/
├── include/
└── build/
├── generated files
└── build artifactsThen configure the project:
cmake -S . -B build
and build it:
cmake --build build
This keeps the source tree clean and makes it easy to remove and recreate the build directory.
For example:
rm -rf build
cmake -S . -B build
cmake --build buildOn Windows, the same conceptual workflow can be used with the appropriate shell and generator.
A build migration is a good opportunity to modernize testing too.
CMake integrates with CTest.
A project can enable testing:
enable_testing()
add_test(
NAME CalculatorTest
COMMAND calculator_test
)Now testing becomes part of the project configuration.
The CI pipeline can follow a consistent workflow:
Checkout
↓
Configure
↓
Build
↓
Test
↓
Package
↓
PublishThe same general process can run across:
This is one of the biggest practical benefits of a standardized build configuration.
Developers and CI systems can use similar commands without maintaining completely separate build instructions.
Do not rewrite a large Makefile in one giant step.
That is one of the easiest ways to turn a build migration into a project-wide disruption.
Instead, migrate incrementally.
Start by documenting the existing build.
Identify:
Then map the existing structure into CMake targets.
For example:
Old Makefile
↓
Identify Build Targets
↓
Create CMake Targets
↓
Add Dependencies
↓
Add Platform Logic
↓
Add Tests
↓
Compare Outputs
↓
Remove Legacy BuildDuring the transition, it can be useful to keep both systems working temporarily.
That gives the team a way to compare results and catch differences before completely removing the Makefile.
If your new `CMakeLists.txt` contains hundreds of platform-specific commands and custom compiler invocations, you may have simply moved the problem.
Use CMake's target model instead.
Global include paths and compiler flags can create hidden dependencies.
Prefer target-specific configuration.
Avoid assumptions such as:
/usr/bin/g++
C:\SomePath\compiler.exeLet CMake work with the selected toolchain.
Cross-compilation and specialized environments often require explicit toolchain files.
Treat the toolchain as part of the build configuration rather than embedding compiler assumptions into project files.
Large build systems contain hidden behavior.
Move incrementally and verify each stage.
CMake has programming capabilities, but your `CMakeLists.txt` should primarily describe the build graph.
If the configuration becomes extremely procedural, step back and reconsider the design.
Document every target and dependency.
Identify what is genuinely platform-specific.
Create libraries and executables based on the real architecture.
Use `target_link_libraries()` and target-specific include directories.
Use:
cmake -S . -B build
cmake --build buildIntegrate CTest and make tests part of CI.
Validate the project on the operating systems your users and developers actually need.
Build and test consistently across environments.
Once CMake has proven stable, retire the old Makefile rather than maintaining two systems indefinitely.
Modern C++ development increasingly depends on reproducible, automated build environments.
The build system is no longer just a developer convenience.
It is part of the software delivery platform.
A modern workflow increasingly looks like:
Source Code
↓
CMake Configuration
↓
Platform / Toolchain
↓
Build
↓
Tests
↓
Package
↓
CI/CDCMake can also work alongside tools such as:
This allows teams to standardize project configuration while still using the development tools that fit each environment.
The result is a healthier separation:
Project configuration describes the project. The build tool performs the build. The platform provides the compiler and environment.
If your existing Makefile is small, stable, and used on a single platform, there may be little reason to migrate immediately.
But if your project is experiencing:
Multiple operating systems
Multiple compilers
Growing dependencies
Complex build flags
Developer onboarding problems
CI/CD duplication
Platform-specific build logic
then CMake can provide a much stronger foundation.
The migration should not be treated as:
"Replace Make with CMake."
Think of it as:
"Turn the build process into a portable, explicit, maintainable project model."
That mindset leads to better results.
Legacy Makefiles are not inherently bad.
The problem begins when the build system becomes a collection of platform-specific workarounds that only a few developers understand.
CMake provides a way to describe the project at a higher level:
Targets
Dependencies
Platforms
Toolchains
Tests
Build options
From there, CMake can generate builds appropriate for different environments.
The migration path is straightforward:
Understand → Model → Modernize → Test → Automate → Remove Legacy Complexity
The biggest benefit is not simply being able to type:
cmake --build build
on multiple platforms.
It is creating a build system where a developer can clone the repository, configure the project, build it, run the tests, and understand how the pieces fit together—without learning a different collection of platform-specific commands.
A modern build system should not make developers think about how every platform builds the software. It should make them think about what the software actually needs.
That is the real value of moving from legacy Makefiles to modern CMake.
We build custom software, mobile apps, and web platforms for startups and enterprises.



Their team became an extension of ours — within months they'd rebuilt our entire product experience from the ground up.
