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.

Greg Filak
Updated

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:

  1. Breaking a project into self-contained, reusable components each with their own CMakeLists.txt file
  2. 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 their CMakeLists.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

CMakeLists.txt
greeter
app
Select a file to view its content

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, and CMakeLists.txt files.
  • The add_subdirectory() Command: The root CMakeLists.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.

Next Lesson
Lesson 13 of 51

CMake Variables and Logging

A primer on the fundamental building blocks of the CMake language: variables, string interpolation, and logging messages.

Have a question about this lesson?
Answers are generated by AI models and may not be accurate