Today, we’re examining two modern build systems for C++: CMake, the industry favorite, and Bazel, a powerful alternative. While CMake is often the default choice, I believe that approach warrants a bit more scrutiny—after all, we’re focusing on modern tools here (yep, not counting Make, right?). To explore this, I’ve created a practical demo project showcasing how both systems manage a real-world scenario.
Using the maelstrom-challenges project as a starting point, I’ve extracted a C++ library called maelstrom-node. This library has been set up to work seamlessly with both Bazel and CMake, giving us a hands-on comparison of their approaches, strengths, and quirks.
The Project Structure
Here’s what the final directory layout for maelstrom-node looks like:
.
├── BUILD.bazel
├── CMakeLists.txt
├── CMakePresets.json
├── LICENSE
├── MODULE.bazel
├── MODULE.bazel.lock
├── README.md
├── include
│ └── maelstrom-node
│ ├── handler.hpp
│ ├── message.hpp
│ └── node.hpp
├── src
│ ├── CMakeLists.txt
│ ├── message.cpp
│ └── node.cpp
├── vcpkg-configuration.json
└── vcpkg.json
With this structure in place, the library could seamlessly integrate with both Bazel and CMake while maintaining clarity and modularity.
Building with Bazel
Bazel provided a relatively straightforward approach to compile maelstrom-node. The key was to define two critical files: MODULE.bazel and BUILD.bazel.
MODULE.bazel
The MODULE.bazel file in Bazel serves as a central place to define project metadata and manage dependencies. In this file, we use
name = "maelstrom_node",
version = "1.0.0",
compatibility_level = 1,
)
# Dependencies
# Adding spdlog and nlohmann_json via Bazel Dependency Archive
bazel_dep(name = "spdlog", version = "1.15.0.bcr.3")
bazel_dep(name = "nlohmann_json", version = "3.11.3.bcr.1")
# Adding rules_boost manually due to its unavailability in Bazel Central Registry
bazel_dep(name = "rules_boost", repo_name = "com_github_nelhage_rules_boost")
archive_override(
module_name = "rules_boost",
integrity = "sha256-ZLcmvYKc2FqgLvR96ApPXxp8+sXKqhBlCK66PY/uFIo=",
strip_prefix = "rules_boost-e3adfd8d6733c914d2b91a65fb7175af09602281",
urls = ["https://github.com/nelhage/rules_boost/archive/e3adfd8d6733c914d2b91a65fb7175af09602281.tar.gz"],
)
The
Why Not Use bazel_dep for rules_boost?
While popular, the
Using
BUILD.bazel
We cannot proceed without BUILD.bazel, as it serves as a location to store the actual app or library information.
name = "maelstrom_node",
srcs = [
"src/message.cpp",
"src/node.cpp",
],
hdrs = glob(["include/maelstrom-node/**/*.hpp"]),
copts = [
"-std=c++23",
],
includes = ["include"],
visibility = ["//visibility:public"],
deps = [
"@boost//:algorithm",
"@boost//:asio",
"@nlohmann_json//:json",
"@spdlog",
],
)
The
To build the library, simply run:
Building with CMake
Adapting maelstrom-node for CMake brought its own set of challenges and solutions, exposing the limitations of this ancient technology. Despite its widespread use, even a straightforward migration effort highlighted CMake’s inherent bottlenecks and inefficiencies, reinforcing the idea that it might be time to consider moving on from it entirely.
Managing Includes
This is the first challenge you’ll face when trying to move from Bazel to CMake. CMake assumes
Bazel, on the other hand, often requires full paths from the project root unless explicitly configured using the includes property in the
While CMake’s shorter paths may appear cleaner within the source code and more natural for C++ developers, they abstract away the file hierarchy, making it harder to understand the project structure at a glance. As Bazel requires, full paths provide better context by explicitly showing where a file is located in the directory tree, making navigation and maintenance easier, especially in larger projects.
Setting Up CMake and VCPKG
Using vcpkg as the package manager is one of the ways to simplify dependency management if you are a CMake fun. After installing vcpkg, I initialized the project and added the necessary ports. The following command will create
vcpkg add port boost-algorithm
vcpkg add port boost-asio
vcpkg add port nlohmann-json
Another option for CMake dependency management is Conan. However, based on my experience, it’s even more complex than VCPKG, so I won’t discuss it in this post.
Making a CMakeLists.txt
Managing dependencies was relatively streamlined using the vcpkg package manager. The toolchain file was included dynamically to ensure compatibility, allowing for flexibility when configuring the build environment. This setup reduces manual configuration efforts while ensuring that dependencies are correctly integrated:
set(CMAKE_TOOLCHAIN_FILE "${CMAKE_SOURCE_DIR}/vcpkg/scripts/buildsystems/vcpkg.cmake"
CACHE STRING "Vcpkg toolchain file")
endif()
The main part of the CMakeLists.txt file is standard for CMake projects. The maelstrom_node library is defined as a static library with its core functionality implemented in message.cpp and node.cpp. Dependencies such as Boost, nlohmann_json, and spdlog are configured using
src/message.cpp
src/node.cpp
)
find_package(Boost REQUIRED NO_MODULE)
find_package(nlohmann_json REQUIRED)
find_package(spdlog REQUIRED)
target_include_directories(maelstrom_node PUBLIC
${CMAKE_SOURCE_DIR}/include
${Boost_INCLUDE_DIRS}
)
target_link_libraries(maelstrom_node PUBLIC
nlohmann_json::nlohmann_json
spdlog::spdlog
Boost::headers
)
The
Finally, the installation section of the file ensures that the library can be packaged and reused in other projects. This includes specifying where the compiled library should be installed and ensuring that headers are copied to the appropriate directories:
EXPORT maelstrom_nodeConfig
ARCHIVE DESTINATION lib
LIBRARY DESTINATION lib
RUNTIME DESTINATION bin
)
install(DIRECTORY ${CMAKE_SOURCE_DIR}/include/maelstrom-node
DESTINATION include/maelstrom-node
)
This setup ensures the maelstrom-node library is compiled correctly and packaged in a way that makes it easy to integrate into other projects. While CMake offers much flexibility, its verbosity and reliance on external tools like vcpkg highlight some of its limitations in managing dependencies efficiently. CMake provides multiple approaches to configure and build the project. Ideally, you could use a preset configuration for simplicity, such as (based on the official documentation):
cmake --build build
This approach is clean and convenient, leveraging a preset defined in CMakePresets.json. However, due to a defect in Visual Studio Code’s CMake Tools, environment variables like
To address this issue, the following manual configuration proves reliable despite its ugliness:
cmake --build build
Both methods ultimately lead to the same result, but the manual approach is necessary in environments where preset limitations or toolchain file paths are not resolved automatically. This highlights a minor inconvenience in the CMake ecosystem that can complicate workflows when integrating with tools like vcpkg.
Let’s integrate it!
Let’s integrate maelstrom-node into our application! This is the final step to bring everything together, and it’s where the differences between Bazel and CMake really stand out in terms of setup, dependency management, and usability. Here’s how each approach handles this critical task.
Bazel Integration
In Bazel, the integration is streamlined by declaring the maelstrom-node library as a dependency using
git_override(
module_name = "maelstrom_node",
commit = "004b1d793427838db32d5e175f1f474bec260766",
remote = "https://github.com/astavonin/maelstrom-node.git",
)
The library is then added as a dependency in the build rule for the application:
name = "echo-cpp",
srcs = [
"main.cpp",
],
copts = [
"-std=c++23",
],
visibility = ["//visibility:public"],
deps = [
"@maelstrom_node",
"@spdlog",
],
)
Bazel’s approach to dependencies is more declarative and centralized. Dependencies are fetched and managed globally at the repository level, ensuring consistency across the project. The use of
The compilation process is still very straightforward for the final app:
CMake Integration
The CMakeLists.txt file for the integration is relatively standard, with one notable addition: the use of
FetchContent_Declare(
maelstrom_node
GIT_REPOSITORY https://github.com/astavonin/maelstrom-node.git
GIT_TAG v1.0.0
)
FetchContent_MakeAvailable(maelstrom_node)
This snippet looks pretty and allows the library to be built alongside the application. But, it’s not without its drawbacks. Unlike Bazel, CMake relies heavily on external tools like vcpkg for dependency management. Unfortunately, vcpkg requires a complete list of dependencies to be declared for every project (is it a defect? It looks so to me.). In this case, both echo-cpp and maelstrom-node must redundantly list their dependencies in echo-cpp’s vcpkg.conf file. This duplication can be tedious and error-prone, especially in larger, more complex projects.
Another significant limitation is CMake’s lack of integration with other ecosystems like Rust and Cargo. If you already have existing integration tests written in Rust, as I have, CMake cannot reuse them natively. This inability to leverage cross-language infrastructure is one of CMake’s most significant downsides and a stark contrast to Bazel, which excels in mixed-language project support.
While this integration approach works, it highlights the challenges of relying on CMake for projects beyond simple use cases, especially when dealing with multiple dependencies or mixed-language ecosystems.