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
Published

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:

  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 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 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 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

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 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, 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.
  • Usage Requirements: Modern CMake is about targets defining their own usage requirements (target_include_directories, target_compile_features, etc.). Using the PUBLIC 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.

Have a question about this lesson?
Answers are generated by AI models and may not have been reviewed for accuracy