Back

The Ultimate Guide to Modern C++ Projects


In the era of Modern C++ (C++17/20/23), the language has evolved into a powerful tool for safe and expressive high-performance computing. However, the ecosystem around itβ€”build systems, testing, and continuous integrationβ€”is just as critical as the code itself.

This guide walks you through setting up a production-ready C++ project using modern CMake, proper dependency injection, compiler hardening, and a robust CI/CD pipeline.

1. Project Structure and Modularity

The days of a single main.cpp or a flat folder structure are over. A modern project should be modular, separating public interfaces from private implementations.

The Recommended Layout

Adopt a standard layout that tools and other developers expect:

my-project/
β”œβ”€β”€ CMakeLists.txt            # Root build definition
β”œβ”€β”€ CMakePresets.json         # Build configurations
β”œβ”€β”€ vcpkg.json                # Dependency manifest
β”œβ”€β”€ src/
β”‚   β”œβ”€β”€ app/                  # Executable code
β”‚   └── core/                 # Business logic (library)
β”œβ”€β”€ include/
β”‚   └── myproject/            # Public headers
β”œβ”€β”€ tests/
β”‚   β”œβ”€β”€ unit/
β”‚   └── e2e/
└── external/                 # For vendored dependencies

Modern CMake: Targets over Variables

Forget include_directories and link_libraries. Modern CMake is all about Targets and Properties.

  • Rule: Always use target_include_directories, target_compile_features, and target_link_libraries.
  • Visibility: Use PUBLIC, PRIVATE, and INTERFACE to control dependency propagation.

src/core/CMakeLists.txt:

add_library(core_lib STATIC implementation.cpp)

# Consumers automatically get the include path
target_include_directories(core_lib PUBLIC
    $<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/../../include>
    $<INSTALL_INTERFACE:include>
)

# Enforce C++20 for this library and its consumers
target_compile_features(core_lib PUBLIC cxx_std_20)

CMake Presets for Reproducible Builds

CMakePresets.json (introduced in CMake 3.19) standardizes build configurations across your team and CI. Instead of remembering complex command-line flags, developers simply run cmake --preset=release.

CMakePresets.json:

{
  "version": 6,
  "cmakeMinimumRequired": { "major": 3, "minor": 25, "patch": 0 },
  "configurePresets": [
    {
      "name": "base",
      "hidden": true,
      "generator": "Ninja",
      "binaryDir": "${sourceDir}/build/${presetName}",
      "cacheVariables": {
        "CMAKE_TOOLCHAIN_FILE": "$env{VCPKG_ROOT}/scripts/buildsystems/vcpkg.cmake"
      }
    },
    {
      "name": "debug",
      "inherits": "base",
      "cacheVariables": { "CMAKE_BUILD_TYPE": "Debug" }
    },
    {
      "name": "release",
      "inherits": "base",
      "cacheVariables": { "CMAKE_BUILD_TYPE": "Release" }
    }
  ],
  "buildPresets": [
    { "name": "debug", "configurePreset": "debug" },
    { "name": "release", "configurePreset": "release" }
  ]
}

2. Testability and Dependency Injection

Writing testable C++ means writing code that is loosely coupled. The biggest enemies of unit testing are singletons and direct instantiation of heavy dependencies (databases, network sockets, file systems) inside your business logic.

The Approach: Constructor Injection

Instead of instantiating a dependency inside your class, accept an interface (abstract base class) in the constructor.

The Interface (The Contract):

// IDataStore.hpp
class IDataStore {
public:
    virtual ~IDataStore() = default;
    virtual std::string GetData(int id) = 0;
};

The Consumer (Your Business Logic):

// UserManager.hpp
class UserManager {
    IDataStore& db_;  // Reference to interface

public:
    // Dependency Injection via Constructor
    explicit UserManager(IDataStore& db) : db_(db) {}

    std::string GetUserName(int id) {
        return "User: " + db_.GetData(id);
    }
};

Unit Testing with Google Mock

Now you can inject a mock object during testing without needing a real database:

// tests/unit/TestUserManager.cpp
#include <gtest/gtest.h>
#include <gmock/gmock.h>
#include "UserManager.hpp"

class MockDB : public IDataStore {
public:
    MOCK_METHOD(std::string, GetData, (int id), (override));
};

TEST(UserManagerTest, FormatsNameCorrectly) {
    MockDB mock_db;

    // Expectation: When GetData(42) is called, return "Alice"
    EXPECT_CALL(mock_db, GetData(42)).WillOnce(testing::Return("Alice"));

    UserManager mgr(mock_db);
    EXPECT_EQ(mgr.GetUserName(42), "User: Alice");
}

3. End-to-End Testing with CTest

Unit tests verify logic in isolation, but E2E tests verify the application works as a whole. CMake's CTest tool is excellent for orchestrating both.

For a C++ CLI application, an E2E test typically looks like: run the binary with arguments X, expect output Y.

tests/e2e/CMakeLists.txt:

add_test(
    NAME E2E_Login_Flow
    COMMAND python3 ${CMAKE_CURRENT_SOURCE_DIR}/test_login.py
            $<TARGET_FILE:my_app_executable>
)

tests/e2e/test_login.py:

import subprocess
import sys

executable_path = sys.argv[1]
result = subprocess.run(
    [executable_path, "--login", "user"],
    capture_output=True,
    text=True
)

if "Welcome, user" in result.stdout:
    sys.exit(0)  # Success
else:
    print(f"Failed! Output was: {result.stdout}")
    sys.exit(1)  # Failure

πŸ’‘ Tip: For more complex E2E scenarios, consider using pytest with fixtures for setup/teardown and better assertion messages.

Running ctest in your build folder will execute both your GTest unit tests and your Python E2E scripts.

4. Compiler Hardening and Sanitizers

A production-ready project should catch bugs at compile time whenever possible and use runtime sanitizers in Debug builds.

Warning Flags

Create a reusable interface target for compiler warnings:

cmake/CompilerWarnings.cmake:

add_library(project_warnings INTERFACE)

if(MSVC)
    target_compile_options(project_warnings INTERFACE /W4 /WX /permissive-)
else()
    target_compile_options(project_warnings INTERFACE
        -Wall -Wextra -Wpedantic -Werror
        -Wshadow -Wnon-virtual-dtor -Wold-style-cast
        -Wcast-align -Wunused -Woverloaded-virtual
        -Wconversion -Wsign-conversion -Wnull-dereference
    )
endif()

# Usage: target_link_libraries(my_target PRIVATE project_warnings)

Address and Undefined Behavior Sanitizers

Enable sanitizers in Debug builds to catch memory errors and undefined behavior:

cmake/Sanitizers.cmake:

option(ENABLE_SANITIZERS "Enable ASan and UBSan" OFF)

if(ENABLE_SANITIZERS AND NOT MSVC)
    add_compile_options(-fsanitize=address,undefined -fno-omit-frame-pointer)
    add_link_options(-fsanitize=address,undefined)
endif()

5. Reproducibility and Dependency Management

"It works on my machine" is not an acceptable excuse. To achieve reproducibility, you must manage third-party libraries strictly. Do not ask users to install dependencies manuallyβ€”use a package manager.

Using vcpkg

vcpkg integrates seamlessly with CMake via a toolchain file. Create a manifest file to declare your dependencies:

vcpkg.json:

{
  "name": "my-project",
  "version-string": "1.0.0",
  "dependencies": [
    "fmt",
    "gtest",
    "nlohmann-json",
    "spdlog"
  ]
}

When you run CMake with the vcpkg toolchain, it automatically downloads and builds specific versions of these libraries, ensuring every developer and CI runner has the exact same binary dependencies.

Alternative: Conan

Conan is another popular option, especially for projects that need fine-grained control over package versions or binary compatibility. A conanfile.txt serves the same purpose:

[requires]
fmt/10.1.1
gtest/1.14.0
nlohmann_json/3.11.2

[generators]
CMakeDeps
CMakeToolchain

6. CI/CD Pipeline (GitHub Actions)

A modern C++ project must build and test on multiple platforms automatically. This proves your code compiles with GCC, Clang, and MSVC.

.github/workflows/ci.yml:

name: CI

on: [push, pull_request]

jobs:
  build:
    name: ${{ matrix.os }} - ${{ matrix.config }}
    runs-on: ${{ matrix.os }}
    strategy:
      fail-fast: false
      matrix:
        os: [ubuntu-latest, windows-latest, macos-latest]
        config: [Debug, Release]

    steps:
    - uses: actions/checkout@v4
      with:
        submodules: true

    - name: Setup vcpkg
      shell: bash
      run: |
        if [[ "$RUNNER_OS" == "Windows" ]]; then
          ./vcpkg/bootstrap-vcpkg.bat
        else
          ./vcpkg/bootstrap-vcpkg.sh
        fi

    - name: Configure CMake
      run: |
        cmake -B build -S . \
          -DCMAKE_TOOLCHAIN_FILE=vcpkg/scripts/buildsystems/vcpkg.cmake \
          -DCMAKE_BUILD_TYPE=${{ matrix.config }} \
          -DENABLE_SANITIZERS=${{ matrix.config == 'Debug' }}

    - name: Build
      run: cmake --build build --config ${{ matrix.config }}

    - name: Run Tests
      working-directory: build
      run: ctest -C ${{ matrix.config }} --output-on-failure

Key Features of This Pipeline

  1. Cross-Platform: Proves your code compiles with GCC (Linux), Clang (macOS), and MSVC (Windows).
  2. Configuration Matrix: Tests both Debug (catches assertions, enables sanitizers) and Release (catches optimization bugs).
  3. Platform-Aware Setup: Uses bash conditionals to run the correct bootstrap script per OS.
  4. Automated Testing: If ctest fails, the Pull Request cannot be merged.

Summary

By combining these modern practices, you elevate your C++ project from "just code" to a professional engineering product:

  1. Structure: Use src/, include/, and tests/ with clear separation of concerns.
  2. Modularity: Use add_subdirectory and target_link_libraries with proper visibility.
  3. Testability: Inject dependencies via interfaces; mock them with Google Mock.
  4. Hardening: Enable strict warnings (-Wall -Wextra -Werror) and sanitizers in Debug.
  5. Reproducibility: Use vcpkg or Conan manifests to lock dependency versions.
  6. CI/CD: Automate cross-platform builds and tests on every push.

Modern C++ is not just about language features like concepts or coroutinesβ€”it's about the discipline of building software that is portable, verifiable, and maintainable.