Organizing Multi-File Projects in PlatformIO: Best Practices for Scalability

As embedded systems evolve from weekend prototypes into full-fledged products, the codebase can become large, complex, and—if not well managed—difficult to maintain. While many developers start their embedded journey with a single monolithic .ino or main.cpp file, this approach quickly becomes unsustainable as features are added.

PlatformIO, with its professional build system and flexible project structure, provides a foundation for organizing scalable embedded software. However, PlatformIO alone doesn’t enforce any specific project layout beyond the default directory structure. The responsibility of creating a maintainable, modular, and scalable codebase lies with the developer.

In this post, we will explore how to effectively organize multi-file PlatformIO projects using modern software engineering practices. You will learn how to use the src/ and include/ directories, manage header files properly, structure your code into modules, and maintain good folder hygiene. These practices are applicable to Arduino-based projects, STM32 development, and more complex projects built using other frameworks like Zephyr or ESP-IDF.

The Default PlatformIO Project Layout

When you initialize a new PlatformIO project, it creates a minimal structure designed to get you started quickly. Here's the default directory layout:

MyProject/
├── include/
├── lib/
├── src/
├── test/
├── platformio.ini

The src/ directory is where PlatformIO expects your source files, such as main.cpp. The include/ directory is intended for public header files. The lib/ folder is used for libraries, whether you write them yourself or install them from the PlatformIO registry. The test/ folder contains unit tests, if you choose to write them.

This structure is simple but powerful. It lays the groundwork for a clean modular architecture without enforcing too many constraints. Let’s examine how to take full advantage of it.

Why Modularization Matters in Embedded Projects

As your project grows beyond a few hundred lines of code, modularization becomes not only helpful but essential. A modular project:

  • Enables code reuse and abstraction
  • Makes unit testing and debugging easier
  • Simplifies maintenance and future development
  • Encourages separation of concerns and cleaner interfaces

Consider an embedded application that reads from sensors, logs data to an SD card, and displays information on an OLED screen. Packing all this functionality into a single main.cpp file is impractical. Instead, each functional area—such as sensors, storage, and display—should reside in its own module, with a clear interface defined in a header file.

Creating a Modular Project Structure

PlatformIO does not limit you to placing everything directly in src/. In fact, organizing src/ into subdirectories that represent different features or components of your application is a recommended practice. Likewise, corresponding headers for these modules should go into the include/ directory.

Suppose you're building a smart weather station. A good directory structure might look like this:

MyWeatherStation/
├── src/   ├── main.cpp   ├── sensors/      ├── TemperatureSensor.cpp      └── HumiditySensor.cpp   ├── display/      └── OLEDDisplay.cpp   └── storage/       └── SDLogger.cpp
├── include/   ├── sensors/      ├── TemperatureSensor.h      └── HumiditySensor.h   ├── display/      └── OLEDDisplay.h   └── storage/       └── SDLogger.h

Each module has its .cpp file in the src/ directory and its corresponding .h file in include/. This clean separation of interface and implementation promotes readability and makes each component independently testable.

Managing Header Files Properly

Header files are the interfaces through which your modules communicate with the rest of the application. A well-structured header file exposes only the necessary functions, types, and constants that are intended to be used outside the module.

Each header file should include an include guard or use #pragma once to avoid redefinitions during compilation. Include guards are typically written like this:

#ifndef TEMPERATURE_SENSOR_H
#define TEMPERATURE_SENSOR_H

void initTemperatureSensor();
float readTemperature();

#endif

Or, you can use the more concise form supported by most modern compilers:

#pragma once

void initTemperatureSensor();
float readTemperature();

The key rule is to avoid exposing implementation details in the header file. Keep internal helpers and constants confined to the .cpp file.

Keeping main.cpp Minimal

A common mistake in embedded development is allowing main.cpp to grow into a massive, tangled block of logic. A better approach is to treat main.cpp as the entry point that delegates control to the various modules. Its responsibilities should be limited to initializing the system, managing the main loop, and invoking high-level functions from other modules.

Here's an example of what a clean main.cpp might look like:

#include "sensors/TemperatureSensor.h"
#include "display/OLEDDisplay.h"
#include "storage/SDLogger.h"

void setup() {
    initTemperatureSensor();
    initDisplay();
    initLogger();
}

void loop() {
    float temperature = readTemperature();
    displayTemperature(temperature);
    logTemperature(temperature);
    delay(1000);
}

Notice how the code avoids dealing with low-level details directly. This separation of concerns makes it easier to test and refactor individual components without affecting the rest of the application.

Folder Hygiene and Naming Conventions

As projects grow, folder hygiene becomes increasingly important. Proper organization helps developers find files quickly, reduces merge conflicts in version control systems, and makes documentation and onboarding easier.

Here are several recommendations for keeping your directory structure clean and maintainable:

  • Group related files together by feature, not by file type.
  • Use consistent naming. Match the header and source file names exactly, differing only by file extension.
  • Avoid overly deep folder structures. One or two levels of nesting is generally sufficient.
  • Write README files for complex modules, especially if they contain multiple components or have configuration requirements.
  • Delete unused or obsolete files as soon as they are no longer needed. Leaving old files in the project increases confusion and build time.
  • Avoid circular includes. This often happens when two headers include each other. Instead, use forward declarations where possible.

By following these practices, you reduce technical debt and make it easier for others (or yourself, months later) to work on the code.

The Role of lib/ in Reusability

The lib/ folder in PlatformIO is intended for libraries that are either imported from the PlatformIO registry or written locally. If you develop a component that you use in multiple projects—such as a custom driver or utility module—it should live in the lib/ folder.

Here is an example of organizing a local library:

lib/
└── EEPROMDriver/
    ├── EEPROMDriver.cpp
    └── EEPROMDriver.h

To use this library in your project, you can simply include the header in your source files:

#include <EEPROMDriver.h>

PlatformIO will automatically add the lib/ directory to the compiler’s include path. You do not need to modify platformio.ini unless you want to override default paths.

Placing shared components in lib/ improves reusability and encourages encapsulation. You can even turn these into installable libraries by adding a library.json manifest and publishing them to the PlatformIO registry or storing them in a shared Git repository.

Testing and Validation with the test/ Folder

Testing embedded code is often overlooked due to hardware dependencies. However, many modules can be tested in isolation, especially those that perform data processing or logic that does not directly interact with hardware.

PlatformIO supports unit testing using the Unity test framework. Tests are written in the test/ folder and can be executed with the pio test command.

Here is a simple test case for the temperature sensor module:

#include <unity.h>
#include "sensors/TemperatureSensor.h"

void test_temperature_within_expected_range() {
    float t = readTemperature();
    TEST_ASSERT_TRUE(t >= -50.0f && t <= 100.0f);
}

void setup() {
    UNITY_BEGIN();
    RUN_TEST(test_temperature_within_expected_range);
    UNITY_END();
}

void loop() {}

While some tests may require hardware, others can run in host environments or using mocks. Writing tests becomes significantly easier when the codebase is modular and organized.

Managing Build Configuration in platformio.ini

PlatformIO’s platformio.ini file controls the build environment, including board configuration, build flags, library dependencies, and more. In most cases, you do not need to modify the include paths if you stick to the default src/, include/, and lib/ directories.

However, if you choose to place headers in non-standard directories, you can explicitly set include paths like this:

[platformio]
include_dir = include

[env:myboard]
platform = espressif32
board = esp32dev
framework = arduino
build_flags = -Iinclude/sensors -Iinclude/display

Keeping your build configuration centralized and version-controlled ensures that others can clone the repository and build the project with minimal setup.

Summary and Final Thoughts

Developing embedded applications with PlatformIO can scale from simple experiments to production-level systems. But with great flexibility comes the need for discipline. Organizing your PlatformIO projects using modular design, clean directory structures, and consistent naming conventions is essential for long-term success.

By using src/ for implementation files and include/ for headers, you create a clear separation between interface and logic. Dividing your project into feature-specific modules simplifies navigation and promotes code reuse. Keeping main.cpp minimal allows the rest of your application to remain flexible and easy to refactor. Additionally, using lib/ for reusable libraries and test/ for unit testing prepares your project for real-world maintainability.

These practices not only improve the developer experience but also make your project more accessible to collaborators, contributors, and future you.

Taking the time to organize your code today will pay off in faster development, fewer bugs, and smoother scaling tomorrow.

If you are just getting started with PlatformIO, try applying these techniques to a small project first. Refactor an existing Arduino sketch into multiple files, create a clean module structure, and explore how easier it becomes to add features, debug, and test. Once you experience the benefits of a modular structure, you’ll never want to go back to a monolithic file again.