Introduction: The Challenge of Shipping a Real-Time Trading App
Building a real-time trading application is notoriously difficult. The software must process market data with microsecond precision, execute orders without latency spikes, and remain stable under extreme load. For the Joyridez team, the journey from prototype to production was fraught with technical hurdles, but one decision proved pivotal: adopting modern CMake as the build system. This article shares our experience—how we moved from a tangled web of Makefiles and handwritten scripts to a clean, modular CMake structure that enabled faster iteration, easier collaboration, and ultimately, a successful launch.
The stakes were high. In algorithmic trading, a 10-millisecond delay can mean the difference between profit and loss. Our legacy build process was a bottleneck: full rebuilds took 45 minutes, incremental builds were unreliable, and integrating third-party libraries like Boost and ZeroMQ often broke the build. New hires spent their first week just setting up their environment. We knew we needed a change.
Why Modern CMake?
Modern CMake (version 3.12 and later) emphasizes targets and properties over global variables and directory-level commands. This shift aligns with how real-time trading code is organized: many small, independent libraries (market data parsers, order routers, risk checks) that must be composed without leaking dependencies. By declaring each component as a add_library target with explicit target_include_directories and target_link_libraries, we achieved true encapsulation. No more accidental transitive includes or linker errors from mismatched flags.
One team member described the transition as "going from a chaotic open-plan office to a well-designed co-working space." The build became predictable. We could run cmake --build . --target unit_tests and know it would compile only what changed. This was a game-changer for our continuous integration pipeline, where every commit triggered a suite of latency benchmarks.
Of course, the switch wasn't instant. We spent two months migrating incrementally, maintaining both old and new build systems in parallel. But the payoff came quickly: build times dropped by 60%, and developer onboarding shrank from a week to a day. In the sections that follow, we'll unpack exactly how we did it, what worked, and what we'd do differently.
Core Concepts: Why Targets and Properties Matter in Low-Latency Builds
To understand why modern CMake was the right choice for Joyridez, you need to grasp the core concepts that differentiate it from older CMake styles. The key ideas are targets, properties, and transitive usage requirements. These aren't just abstract improvements—they directly address the build challenges of a real-time trading app.
In legacy CMake, you might see something like include_directories(/path/to/boost) and add_definitions(-DBOOST_ALL_NO_LIB). These commands modify global state, meaning every subsequent target inherits them. In a large project, this leads to "dependency bleed": a library in a different part of the tree accidentally picks up headers or flags it shouldn't, causing silent bugs or build failures. For a trading app where correctness is paramount, that's unacceptable.
Targets as Contracts
Modern CMake treats each library or executable as a target with its own set of properties. You specify what a target needs (include directories, compile definitions, linked libraries) using commands like target_include_directories and target_compile_definitions. These properties are private by default, meaning they don't leak to consumers unless you explicitly mark them as PUBLIC or INTERFACE. This creates a contract: when you link against a target, you get exactly what it promises, nothing more.
For Joyridez, this was transformative. Our risk-check library, for example, depended on a custom math library for precise decimal arithmetic. By declaring that dependency as PRIVATE, the risk-check target's own linkers knew about it, but any target that linked risk-check (like the order router) didn't inherit the math library's include paths. This prevented subtle ODR violations and kept link times predictable.
Properties and Generator Expressions
Properties also enable generator expressions, which allow build logic to vary by configuration or platform. For instance, we used $<CONFIG:Debug>:--coverage to add coverage flags only in debug builds, and $<PLATFORM_ID:Linux>:-lrt to link the real-time library on Linux. This eliminated the need for multiple CMakeLists.txt files or external scripts.
One practical example: our market data parser needed different endianness handling on ARM vs x86. We wrote a single target with a generator expression: target_compile_definitions(market_data PRIVATE $<$<PLATFORM_ID:ARM>:LITTLE_ENDIAN>). This kept the codebase clean and the build matrix manageable.
In summary, modern CMake's target-centric model gave us the precision and control needed for a low-latency, high-correctness trading application. It wasn't just about convenience—it was about ensuring that every build artifact was compiled with exactly the right settings, nothing leaking, nothing missing.
Execution: Step-by-Step Migration to Modern CMake at Joyridez
Migrating a production trading system to a new build system is like changing the tires on a moving car. At Joyridez, we approached it methodically over two months, with a clear plan and rollback capability. Here's the step-by-step process we followed, which you can adapt for your own project.
Phase 1: Audit and Inventory
We started by listing every source file, library, and executable. Our project had ~150 targets spread across 4 main directories: core (market data, order management), strategies (trading algorithms), utils (logging, configuration), and tests (unit and integration tests). For each target, we documented its dependencies (internal and external), compile flags, and platform requirements. This inventory became our migration checklist.
We also identified external dependencies: Boost (several components), ZeroMQ, Protobuf, and a proprietary hardware access library. Each had its own quirks—Boost required specific find modules, while the hardware library was distributed as a prebuilt static library with non-standard include paths. We decided to use FetchContent for some dependencies and find_package for others, based on whether we needed to control the version or just use a system-installed one.
Phase 2: Create a Stub CMakeLists.txt
We created a top-level CMakeLists.txt that set the project name, language standard (C++17), and minimum CMake version (3.14). Then we added add_subdirectory calls for each major directory. Inside each subdirectory, we started with a minimal CMakeLists.txt that defined the targets but didn't yet set properties. This allowed the build to fail fast with clear error messages.
Phase 3: Incremental Property Assignment
We tackled targets one by one, starting with the leaf libraries (those with no internal dependencies). For each, we set target_include_directories, target_compile_options, and target_link_libraries. We used PRIVATE for internal details and PUBLIC only for headers that consumers needed. After each batch of changes, we ran the full build and test suite. When something broke, we rolled back and debugged.
One tricky case was a shared memory utility that depended on platform-specific threading primitives. We used generator expressions to select the correct source file: target_sources(shm PRIVATE $<$<PLATFORM_ID:Linux>:shm_linux.cpp> $<$<PLATFORM_ID:Windows>:shm_win.cpp>). This kept the CMake file clean and avoided platform branches.
Phase 4: Optimize Build Times
With the migration complete, we turned to performance. We enabled ccache via find_program(CCACHE ccache) and added it to the CMAKE_CXX_COMPILER_LAUNCHER. We also used CMAKE_UNITY_BUILD to merge translation units for faster compilation of rarely changed utility code. These changes cut build times by an additional 30%.
By the end of the migration, our build system was a model of clarity. New developers could understand the dependency graph just by reading the CMakeLists.txt files, and the CI pipeline ran reliably on every commit.
Tools, Stack, and Economics: The Realities of Maintaining a CMake-Based Build
Adopting modern CMake is not just about writing better CMakeLists.txt files—it's about choosing the right supporting tools, understanding the stack, and accounting for ongoing maintenance costs. At Joyridez, we evaluated several build systems before settling on CMake, and we learned hard lessons about what it takes to keep a build system healthy over years of development.
Comparing Build Systems: CMake vs. Bazel vs. Meson
Before committing, we spent two weeks evaluating alternatives. Here's a summary of our findings:
| System | Strengths | Weaknesses | Our Verdict |
|---|---|---|---|
| CMake (modern) | Wide adoption, IDE support, cross-platform, mature package managers (Conan, vcpkg) | Verbose syntax, steep learning curve for generator expressions | Best for heterogeneous environments with existing toolchains |
| Bazel | Hermetic builds, excellent caching, multi-language support | Complex setup, limited Windows support, smaller ecosystem | Overkill for our team size; better suited for monorepos at large companies |
| Meson | Fast, user-friendly syntax, great ninja backend | Smaller community, fewer prebuilt packages for niche libraries | Interesting but lacked the hardware library support we needed |
We chose CMake because it offered the best balance of ecosystem maturity and flexibility. The availability of vcpkg and Conan meant we could manage external dependencies without reinventing the wheel.
Dependency Management: vcpkg and Conan in Practice
We initially used vcpkg for its simplicity—just vcpkg install boost zeromq protobuf and CMAKE_TOOLCHAIN_FILE pointed to the vcpkg script. But we soon hit a snag: the hardware library wasn't in any package registry. We had to create a custom port in vcpkg, which required maintaining a separate repository. Later, we switched to Conan for its more flexible recipe system, allowing us to define our own packages with custom build steps.
This taught us an important lesson: no package manager is perfect. Budget time for maintaining custom recipes, especially if you rely on proprietary or niche libraries.
Maintenance Costs and Team Training
Modern CMake reduces maintenance overhead compared to legacy approaches, but it's not zero. We allocated about 5% of each sprint to build system improvements: updating CMake versions, refactoring old patterns, and adding new targets. We also held two lunch-and-learn sessions to bring the team up to speed on modern practices.
In terms of economics, the migration paid for itself within six months through reduced developer time. We estimated that the 60% build time reduction saved roughly 20 developer-hours per week—a substantial return on the two-month investment.
Growth Mechanics: How Modern CMake Enabled Team Scaling and Faster Iteration
One of the less obvious benefits of modern CMake at Joyridez was how it facilitated team growth and faster iteration. When you're building a real-time trading app, the ability to add new features quickly without breaking existing ones is critical. Here's how our CMake architecture supported that.
Onboarding New Developers
Before the migration, new hires spent an average of five days setting up their environment and understanding the build. After, it took two hours: clone the repo, run cmake -B build, and cmake --build build. The explicit target dependencies meant that even if someone wasn't familiar with the codebase, they could build and test a specific module without needing to understand the entire project.
We also created a README.md that explained the target hierarchy: "To work on the order router, look at the order_router target in core/CMakeLists.txt. It links against market_data and risk_check." This transparency reduced the learning curve dramatically.
Enabling Feature Toggles and A/B Testing
In trading, you often need to test new algorithms in production alongside existing ones. Modern CMake made it easy to define multiple executables with different configurations. For example, we had trader_main and trader_main_experimental that linked against different versions of the strategy library. Using target_compile_definitions with generator expressions, we could toggle features at build time without code changes.
This allowed our quantitative researchers to iterate quickly: they'd push a branch, CI would build both versions, run latency benchmarks, and produce a comparison report. The build system never got in the way.
Scaling the Codebase
As Joyridez grew from 5 to 20 engineers, the codebase expanded from 50,000 to 200,000 lines of code. The modular CMake structure meant we could add new libraries without refactoring existing ones. Each new team (e.g., the risk team) owned their own directory and CMakeLists.txt, with clear interfaces to other modules. This prevented the "ball of mud" that often plagues growing projects.
In summary, modern CMake wasn't just a build tool—it was an enabler of team velocity and codebase scalability. It allowed us to focus on writing trading logic rather than fighting the build.
Risks, Pitfalls, and Mistakes: What Could Go Wrong with Modern CMake
No tool is without its pitfalls, and modern CMake is no exception. At Joyridez, we encountered several issues that cost us time and frustration. By sharing these, we hope you can avoid the same mistakes.
Pitfall 1: Overusing Generator Expressions
Generator expressions are powerful, but they can make CMakeLists.txt files unreadable. We had one file with a 10-line generator expression that selected different source files based on platform, configuration, and a custom property. Debugging it was a nightmare because you couldn't easily see what it evaluated to. Our rule now: if a generator expression exceeds 3 lines, move the logic into a separate .cmake module or use a helper variable.
Pitfall 2: Ignoring the Cache
CMake caches variables in CMakeCache.txt, which can lead to stale values. We once spent a day debugging a linker error only to find that someone had manually edited the cache file to change a library path. The fix: always use cmake -B build to regenerate the cache, and never rely on cached values for critical paths. We also added a CI step that deleted the build directory on every clean run.
Pitfall 3: Misusing INTERFACE Libraries
We created an INTERFACE library for a set of header-only utilities, but we accidentally made it depend on a static library via target_link_libraries. This caused the header-only library to propagate a link dependency to all consumers, increasing link times. The lesson: INTERFACE libraries should only link other INTERFACE libraries, not actual build artifacts.
Pitfall 4: Not Testing the Build on All Platforms
Our CI ran on Linux, but developers used macOS and Windows. We assumed CMake's cross-platform nature would handle everything, but we missed that target_compile_options with -march=native is not portable. We added a presets file (CMakePresets.json) with platform-specific configurations, and now CI runs on all three OSes before merging.
By being aware of these pitfalls, you can adopt modern CMake with your eyes open. The key is to keep the build system simple, test it thoroughly, and document any non-obvious decisions.
Mini-FAQ: Common Questions About Modern CMake for Trading Apps
Based on our experience at Joyridez and conversations with peers in the finance tech community, here are answers to the most common questions about using modern CMake for real-time trading applications.
Q: Should I use FetchContent or find_package for dependencies?
A: It depends. Use find_package for system-installed libraries or those provided by a package manager (vcpkg, Conan). Use FetchContent for libraries you need to build from source with specific patches or versions. For Joyridez, we used FetchContent for ZeroMQ because we needed a custom build flag, and find_package for Boost because it was available via Conan.
Q: How do I handle proprietary libraries without source code?
A: Create an IMPORTED target that points to the prebuilt library and headers. For example: add_library(hardware_lib SHARED IMPORTED) and set its IMPORTED_LOCATION and INTERFACE_INCLUDE_DIRECTORIES. This makes the library a first-class citizen in your CMake graph.
Q: What's the best way to structure CMakeLists.txt for a trading app?
A: Follow the pattern: one top-level CMakeLists.txt that sets project options and calls add_subdirectory for each major module. Each module's CMakeLists.txt should define its own targets and dependencies, and never use global commands like include_directories. Keep the hierarchy shallow—no more than 3 levels deep.
Q: How do I ensure reproducible builds for audit purposes?
A: Use CMAKE_BUILD_TYPE consistently, pin dependency versions, and consider using CMAKE_TOOLCHAIN_FILE to lock compiler and flags. We also store the full CMake cache in a build manifest for every release.
Q: Can I use modern CMake with real-time constraints?
A: Yes, but be careful with FetchContent and ExternalProject as they can download sources at configure time, which may introduce latency in CI. Prefer prebuilt binaries or use a package manager with caching.
Q: What's the minimum CMake version I should target?
A: At least 3.14 for FetchContent and target_sources. For maximum compatibility, we use 3.16, which is available on Ubuntu 20.04 LTS and later.
Conclusion: Key Takeaways and Next Actions
The Joyridez experience demonstrates that modern CMake is not just a build system—it's a strategic enabler for teams building complex, real-time applications. By adopting targets, properties, and generator expressions, we cut build times, improved developer onboarding, and created a codebase that scales with the team.
If you're considering a similar migration, here are your next steps:
- Audit your current build system. List every target, dependency, and flag. Identify pain points like slow builds, fragile scripts, or unclear dependency chains.
- Start small. Migrate one leaf library at a time. Keep the old build working in parallel until you're confident.
- Invest in training. Modern CMake has a learning curve. Schedule team sessions to cover targets, properties, and generator expressions.
- Use a package manager. Whether vcpkg or Conan, having a consistent way to manage external dependencies saves headaches later.
- Automate testing. Ensure your CI runs builds on all target platforms and catches regressions early.
Remember, the goal is not to use every feature of CMake, but to use the right features to solve your specific problems. At Joyridez, we found that a clean, modular build system was worth the investment. We hope this guide helps you on your own journey.
Comments (0)
Please sign in to post a comment.
Don't have an account? Create one
No comments yet. Be the first to comment!