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& 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<IEncoder> CreateEncoder(const std::string& codec) {
if (codec == "h264") return std::make_shared<H264Encoder>();
if (codec == "vp9") return std::make_shared<VP9Encoder>();
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->init(cfg);
encoder->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—