Designing Classes and Modules in C++ After Ten Years: Insights from Real Project Structures

From WeChat Official Account: Programmer Cat

In my first year of writing C++, I thought “as long as it runs, it’s fine”; by the third year, I started to emphasize “encapsulation, inheritance, and polymorphism”; it wasn’t until the tenth year that I realized: designing a good class and module is far more challenging than just writing correct code.

Today, I want to share with you how, after ten years of C++ development, I design classes and modules in real projects—not an academic explanation, but a summary of experiences gained from pitfalls and real maintenance.

1. What Did My “Bad Design” Look Like When I Just Started Working?

Let’s take a look at my early code:

class VideoDecoder {
public:
    bool open(const std::string& filePath);
    Frame decodeNextFrame();
    void close();
private:
    AVFormatContext* formatCtx;
    AVCodecContext* codecCtx;
    ...
};

This code looks quite neat, but as the project grew, many issues became apparent:

  • The class is too bloated: it handles files, manages memory, and implements decoding logic, taking on too many responsibilities;
  • Strong coupling: the code depends on FFmpeg, hardcoding platform details, requiring a complete refactor if the library changes;
  • Hard to test: you need to prepare video files to test, making pure unit testing impossible;
  • Hard to extend: if you want to support streaming, you have to modify the original class; if you want to add logging, you have to change this class again…

This kind of “spaghetti class” is easy to write at first but becomes a maintenance nightmare later.

2. How Did I Design Later On?

Principle One: Each Class Should Do “One Thing”

When I design classes now, the first question I ask myself is: “What is the responsibility of this class?”

For example, “decoding video” sounds like one task, but it actually involves many responsibilities:

  • Opening resources (files, local, network)
  • Initializing the decoder (platform-specific)
  • Decoding logic (frame processing)
  • Logging and error handling

So now I break it down into multiple classes, each doing one thing:

class VideoSource {
public:
    virtual bool open(const std::string& path) = 0;
    virtual std::vector<uint8_t> read() = 0;
    virtual void close() = 0;
};

class VideoDecoder {
public:
    void setInput(std::shared_ptr<VideoSource> input);
    Frame decode();
};
  • <span>VideoSource</span> is responsible for “reading video data from a source” and can have multiple implementations:
    • <span>FileVideoSource</span> (reads from files)
    • <span>HttpVideoSource</span> (reads from the network)
  • <span>VideoDecoder</span> is responsible for “how to decode”; it does not care where the data comes from and does not concern itself with resource management.

The benefits of this design are:

  • ✅ Easy to test: just write a <span>MockVideoSource</span> to test <span>VideoDecoder</span>;
  • ✅ Extensible: support for new sources can be added without modifying the decoder code;
  • ✅ High cohesion, low coupling: clear logic makes maintenance easy.

Principle Two: Use Interfaces + Factories to Decouple Module Dependencies

In real projects, modules need to be decoupled; I usually handle this with abstract interfaces + factories:

// Interface definition
class IEncoder {
public:
    virtual void init(const EncoderConfig&amp; config) = 0;
    virtual void encode(Frame frame) = 0;
    virtual void close() = 0;
};

// Different implementations
class H264Encoder :public IEncoder { ... };
class VP9Encoder :public IEncoder { ... };

// Factory
std::shared_ptr&lt;IEncoder&gt; CreateEncoder(const std::string& codec) {
    if (codec == "h264") return std::make_shared&lt;H264Encoder&gt;();
    if (codec == "vp9") return std::make_shared&lt;VP9Encoder&gt;();
    throw std::runtime_error("Unsupported codec");
}

Thus, the caller only depends on the <span>IEncoder</span> interface and does not care about the specific implementation:

auto encoder = CreateEncoder("h264");
encoder-&gt;init(cfg);
encoder-&gt;encode(frame);

Easy to extend + testable + highly maintainable, a three-in-one solution.

3. The Module Structure of My Current Real Project Looks Like This:

Below is a simplified directory structure of our audio and video processing system:

/src
├── common/             # Utility classes, logging, configuration, thread pool, etc.
├── interface/          # Exposed API interfaces (HTTP/gRPC)
├── pipeline/           # Main data processing control module
├── decoder/            # Decoding module
│   ├── ivideo_decoder.h
│   └── ffmpeg_decoder.cpp
├── encoder/            # Encoding module
├── io/                 # File/network input and output
├── tests/              # Unit tests for all modules
└── main.cpp            # Startup program

Each module is further subdivided into submodules, and all modules communicate only through interfaces, with no circular dependencies. We use CMake’s <span>add_subdirectory</span> to manage each module, allowing all modules to be compiled and tested independently.

Summary of My Class and Module Design Habits

Principle Description
Single Responsibility A class should do one thing
Interface First Abstraction takes precedence over implementation
Composition Over Inheritance Reduce complex inheritance chains
Dependency Injection Module dependencies are passed in as parameters
High Cohesion, Low Coupling Clear module boundaries, can be used independently

To Every C++ Colleague:

Many people ask: “Why do I always feel like the project deteriorates as I go along?”

My answer is: the project structure and class design determine how long it can last from day one.

Writing clear classes is not about showing off skills; it is about being responsible for your future self. Defining module boundaries is not a hassle; it is for the smooth progress of the team in the coming years.

Classes are local architectures, and modules are global projects; designing them well is the most important “refactoring homework” for every C++ developer.

—END—

Leave a Comment