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 chapter, 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(GreeterApp)
# 2. Define the library target
add_library(Greeter src/Greeter.cpp)
# 3. Add usage requirements to the library
target_include_directories(Greeter PUBLIC
${PROJECT_SOURCE_DIR}/include
)
target_compile_features(Greeter 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 Greeter)
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 GreeterApp
into a structure that treats the Greeter
library and the GreeterApp
executable as two distinct, modular components.
The Modular Project Structure
Here is the structure for our refactored project:
GreeterApp/
├─ greeter/
│ ├─ include/
│ │ └─ greeter
│ │ └─ Greeter.h
│ ├─ src/
│ │ └─ Greeter.cpp
│ └─ CMakeLists.txt
│
├─ app/
│ ├─ src/
│ │ └─ main.cpp
│ └─ CMakeLists.txt
│
└─ CMakeLists.txt
Each major component gets its own top-level directory and dedicated CMakeLists.txt
file. Within our library's /include
directory, we'll 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. CMake will find and process
# the CMakeLists.txt inside each of these directories.
add_subdirectory(greeter)
add_subdirectory(app)
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, where each component is a single add_subdirectory()
command.
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 Library's CMakeLists.txt
The greeter/CMakeLists.txt
file is responsible for defining the GreeterLib
library target and specifying its usage requirements. It needs to tell any potential consumer how to use it correctly.
For now, that just requires us to define where our header files are with a target_include_directories()
command.
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, 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 GreeterApp/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 GreeterApp/
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 GreeterApp/
directory.
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 a
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 and link against the 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 PUBLIC GreeterLib)
Namespaced Includes
Our project structure has added an intermediate /greeter
directory between our library's /include
directory and its header files.
The reason for this is, 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 instead just add this /greeter
directory as an include directory if preferred:
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 PUBLIC GreeterLib)
target_include_directories(GreeterApp PUBLIC
${PROJECT_SOURCE_DIR}/greeter/include/greeter
)
This specific approach is a little brittle. It requires our GreeterApp
module to specify exactly where the GreeterLib
library is within our project's directory structure (${PROJECT_SOURCE_DIR}/greeter
) and where that library stores its header files (include/greeter
).
This infringes on our desired design goals of module separation. By the end of the course, we'll have learned more robust ways to solve problems like this.
But this approach works for now, and it lets source files within our GreeterApp
target 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;
}
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 which will, in turn, load in the CMakeLists.txt
files in the other directories identified by our add_subdirectory()
commands.
When it's successful, we can build our project, again from the /build
directory:
cmake --build .
Our new modular structure will slightly change the default build output. Before, our executable was created in the /build
directory. Now, it will be created in /build/app
. We'll learn how to control this soon but, for now, we can verify it worked by running ./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 truly 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. - Usage Requirements: Modern CMake is about targets defining their own usage requirements (
target_include_directories
,target_compile_features
, etc.). Using thePUBLIC
keyword allows these requirements to propagate to any consumers, making dependency management automatic and elegant.
You now have a powerful, 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.