CMake Project Structure and Subdirectories
Learn how to organize large C++ projects in CMake using subdirectories and the add_subdirectory()
command to create modular, maintainable builds.
In the last lesson, we successfully built our first CMake project. It worked, but all our build logic was crammed into a single CMakeLists.txt
file.
# 1. Set the minimum CMake version and project name
cmake_minimum_required(VERSION 3.16)
project(Greeter)
# 2. Define the library target
add_library(GreeterLib src/Greeter.cpp)
# 3. Add usage requirements to the library
target_include_directories(GreeterLib PUBLIC
${PROJECT_SOURCE_DIR}/include
)
target_compile_features(GreeterLib PUBLIC cxx_std_20)
# 4. Define the executable target
add_executable(GreeterApp src/main.cpp)
# 5. Add usage requirements to the executable
target_compile_features(GreeterApp PUBLIC cxx_std_20)
# 6. Link the library to the executable
target_link_libraries(GreeterApp GreeterLib)
For a tiny project, that's fine. But what happens when our project grows? Imagine adding a physics engine, a rendering module, a networking library, and a separate test suite. A single CMakeLists.txt
would quickly become a long, unmaintainable file.
There is also a more practical problem - how can we share our library with other projects if the build configuration for our library is mixed with the configuration for a load of other targets within the same CMakeLists.txt
file?
A core principle of good software design is modularity. This applies to our build scripts just as much as it does to our C++ code. A scalable project needs a scalable build structure.
In this lesson, we'll learn the two steps for for achieving this:
- Breaking a project into self-contained, reusable components each with their own
CMakeLists.txt
file - Bringing these components back together into a complete project using
add_subdirectory()
commands
From Monolith to Modular
The goal is to structure our project so that each component (like a library or an executable) is entirely self-contained. All its source code, public headers, and build instructions should live together in a single directory.
This makes the component:
- Maintainable: The logic for a component is in one place.
- Reusable: You could copy the component's directory into a new project and use it instantly.
- Scalable: Adding new components doesn't increase the complexity of existing ones.
To do this, we'll refactor our simple Greeter
project into a structure that treats the GreeterLib
library and the GreeterApp
executable as two distinct, modular components.
The Modular Project Structure
Here is the structure for our refactored project:
greeter/
├─ include/
│ └─ greeter
│ └─ Greeter.h
├─ src/
│ └─ Greeter.cpp
└─ CMakeLists.txt
app/
├─ src/
│ └─ main.cpp
└─ CMakeLists.txt
CMakeLists.txt
Each major component gets its own dedicated directory and CMakeLists.txt
file. In our top level directory, we have three things:
- The
/greeter
directory for our library. - The
/app
directory for our executable - A top level
CMakeLists.txt
to orchestrate everything
Each of our two components has its own dedicated CMakeLists.txt
file, as well as the usual subfolders like /src
, /include
, and more can be added as needed.
Within our library's /include
directory, we've also add an intermediate greeter
directory for our header files. We'll explain the reason for this later in this lesson.
Let's walk through each of our three CMakeLists.txt
files.
The Root CMakeLists.txt
The top-level CMakeLists.txt
becomes extremely simple. Its only job is to define project-wide settings and then pull in the components using add_subdirectory()
.
CMakeLists.txt
cmake_minimum_required(VERSION 3.16)
# Name the overall project
project(Greeter)
# Bring in our components
add_subdirectory(greeter) # The library
add_subdirectory(app) # The executable
This top level CMakeLists.txt
is now extremely high level, which is what we want for a file in such an important location as the root of our project. It simply declares what components our project has, and each new component we add requires a single line, keeping complexity to a minimum.
It doesn't care about the implementation details of how those components are built - those are lower level details stored deeper in our project structure.
The single line that loads these components is the add_subdirectory()
command. This command looks for a CMakeLists.txt
file in that location, and incorporates it into our build.
The Library's CMakeLists.txt
The greeter/CMakeLists.txt
file is responsible for defining the library in its directory.
Let's use it to define our GreeterLib
target, alongside its source files, include directories, and it's usage of the C++20 standard:
greeter/CMakeLists.txt
cmake_minimum_required(VERSION 3.16)
add_library(GreeterLib src/Greeter.cpp)
target_include_directories(GreeterLib PUBLIC
${CMAKE_CURRENT_SOURCE_DIR}/include
)
target_compile_features(GreeterLib PUBLIC cxx_std_20)
Note that we're now using the CMAKE_CURRENT_SOURCE_DIR
variable for our include directories, where previously we were using PROJECT_SOURCE_DIR
.
The CMAKE_CURRENT_SOURCE_DIR
variable is the directory where the current CMakeLists.txt
file. Given we're using this variable in a CMakeLists.txt
file stored in the greeter/
directory, then its value will be the path to that directory.
The PROJECT_SOURCE_DIR
variable is the directory where the CMakeLists.txt
file containing the project()
command is. In our case, the only CMakeLists.txt
file in our hierarchy containing a project()
command is within the top level directory.
So, if we were to use that variable in any CMakeLists.txt
file loaded by an add_subdirectory()
command in that file, it would contain the path to the root of our project.
With these changes, our library is now completely reusable. If we want to share it with other projects, we just share our entire greeter
directory. Any other project that wants to use our library involves three simple steps:
- copy this directory into their project's file structure
- add an
add_subdirectory()
command to theirCMakeLists.txt
file to make CMake aware of it - add a
target_link_libraries()
command for any target that wants to use the library
The Application's CMakeLists.txt
Finally, let's look at the application's CMakeLists.txt
file. Again, it's quite simple - it just needs to define itself, it's C++ standard, and link against the GreeterLib
library.
app/CMakeLists.txt
cmake_minimum_required(VERSION 3.16)
add_executable(GreeterApp src/main.cpp)
target_compile_features(GreeterApp PUBLIC cxx_std_20)
target_link_libraries(GreeterApp GreeterLib)
Namespaced Includes
Our project structure has added an intermediate /greeter
directory between our library's /include
directory and its header files.
The reason this is a common convention is that, as our project grows to contain multiple libraries, we want our #include
directives to specify which library they're importing a specific header from.
This makes it clear what our code is importing, and also mitigates against naming conflicts, where two or more libraries have header files with the same name.
Let's update our main.cpp
and our library's source file to match the new, namespaced directory structure:
Files
Remember, we can configure our include directories however we want. A library providing the option for namespaced includes is nice, but we don't have to use it if we don't want to. We can still add this specific /include/greeter
path as an include directory for any target:
app/CMakeLists.txt
cmake_minimum_required(VERSION 3.16)
add_executable(GreeterApp src/main.cpp)
target_compile_features(GreeterApp PUBLIC cxx_std_20)
target_link_libraries(GreeterApp GreeterLib)
target_include_directories(GreeterApp PUBLIC
${PROJECT_SOURCE_DIR}/greeter/include/greeter
)
Now, source files within our GreeterApp
target can omit the /greeter
namespace if they prefer:
app/src/main.cpp
#include <iostream>
// Either of these will work
#include <greeter/Greeter.h>
#include <Greeter.h>
int main() {
std::cout << get_greeting();
return 0;
}
This specific implementation in app/CMakeLists.txt
isn't great, as it means we no longer have a clean module separation. GreeterApp
now need to know where GreeterLib
lives within our project's directory structure (${PROJECT_SOURCE_DIR}/greeter
) and where that library stores its header files (include/greeter
).
target_include_directories(GreeterApp PUBLIC
# GreeterApp shouldn't know about this path
${PROJECT_SOURCE_DIR}/greeter/include/greeter
)
If either of those things change, GreeterApp
will stop working. By the end of the course, we'll have learned more robust ways to solve problems like this.
Building and Running
Let's verify our new modular structure is working using the same steps we walked through in the previous lesson. Within our /build
directory, we can generate our project files using the command:
cmake ..
This will process our top-level CMakeLists.txt
file. This will, in turn, load in the CMakeLists.txt
files in the other directories identified by our add_subdirectory()
commands, and process those too.
When it's successful, we can build our project using the same command as before. Again, we would do this from the /build
directory:
cmake --build .
Our new modular structure will slightly change where our compiled files are located. Before, our GreeterApp
executable was located in the base /build
directory. Now, it will be located in /build/app
.
We'll learn how to control our output locations soon but, for now, we can just run it from that location using ./app/GreeterApp
(or ./app/GreeterApp.exe
on Windows):
./app/GreeterApp
Hello from the modular Greeter library!
Summary
In this lesson, we refactored our project to a modular, component-based structure.
- The Goal: To create self-contained components that can be easily maintained, reused, and scaled.
- The Structure: Each component (library, executable) lives in its own directory with its own
include
,src
, andCMakeLists.txt
files. - The
add_subdirectory()
Command: The rootCMakeLists.txt
acts as an orchestrator, using this command to bring components into the build.
You now have a scalable template for organizing any C++ project, no matter how large it becomes. This modular approach is the key to managing complexity in a growing codebase.
CMake Variables and Logging
A primer on the fundamental building blocks of the CMake language: variables, string interpolation, and logging messages.