A lot of C/C++ developers build complex data structures as if database theory never happened. And then they spend months maintaining pointer contracts that a schema could have made obvious.
Let’s take a graph structure as an example.
A common pattern looks like this:
struct Node;
struct Edge;
struct Node {
uint32_t id;
std::vector<Edge*> out_edges;
void* payload; // depends on node type
uint32_t type;
};
struct Edge {
Node* from;
Node* to;
void* payload; // depends on edge type
uint32_t type;
};
Looks flexible. In reality, it is a tiny undocumented database with manual referential integrity.Everyone must remember things like:
- which Node::type maps to which payload struct
- which Edge::type maps to which payload struct
- whether payload == nullptr is allowed
- who owns node payloads
- who owns edge payloads
- whether deleting a node invalidates edge payloads
- how to serialize pointer graphs without losing meaning
And when requirements change, the whole thing turns into archaeology. Now let’s model the same problem like people who know schemas exist.
Start with identity:
using NodeId = uint32_t;
using EdgeId = uint32_t;
Then the core tables:
struct NodeRow {
NodeId id;
};
struct EdgeRow {
EdgeId id;
NodeId from;
NodeId to;
};
That alone already gives you something huge:
- edges are explicitly relations between nodes
- serialization is trivial
- deletion rules can be formalized
- validation is possible
Now add typed data associated with nodes:
struct TransformNode {
NodeId node;
float x, y, z;
};
struct LabelNode {
NodeId node;
std::string text;
};
struct ColorNode {
NodeId node;
uint8_t r, g, b, a;
};
And typed data associated with edges:
struct WeightEdge {
EdgeId edge;
float weight;
};
struct CapacityEdge {
EdgeId edge;
uint32_t max_flow;
};
struct StyleEdge {
EdgeId edge;
uint32_t line_style;
};
Now the model tells the truth.
A node may have a transform, or a label, or both, or neither. An edge may have a weight, a capacity, or some other attribute. No void*. No “if type == 3 cast to Foo”. No comment-based schema.
Need multiplicity rules?
Make them explicit:
- one TransformNode row per node
- zero or one WeightEdge row per edge
- many LabelNode rows if your model allows multilingual labels
- no EdgeRow may reference missing nodes
That is database thinking.
And it buys you real benefits even in plain C++:
- strong typing instead of tribal knowledge
- explicit foreign keys instead of raw pointers
- easy serialization
- easy validation
- better data locality with arrays of records
- queryable structure
Examples of useful queries:
- find all edges whose to node is missing a transform
- find all nodes with outgoing edges but no label
- find all weighted edges crossing between two node groups
- find dangling relations after a delete
- find all data attached to a given node or edge
Try doing that cleanly in a pointer soup.
This is why I keep saying:
Many C/C++ codebases are just badly normalized databases with manual
memory management.
The irony is that game developers already stumbled into one good answer:
ECS. (or Entity-Component-System)
Entities are IDs. Components are typed tables. Systems are queries.
That idea is not limited to games. Graphs, CAD tools, compilers, pipelines, simulation systems, scene graphs, workflow engines — all of them can benefit from the same data-first thinking.
The real lesson is simple:
Before you design your structs like a C programmer, design your data
like a database engineer. Then make it fast like a systems programmer.
“Nice theory… but won’t this be slow?”
That’s the first reaction when you suggest modeling C++ data like a database. Fair question. Also mostly wrong.
Let’s take the graph model from the previous post and talk about how you actually make it fast.
First mistake people imagine:
“Tables + IDs = like SQL = slow joins”
No. You are not building a database engine. You are building arrays with structure.
- Storage: SoA beats pointer soup
Instead of scattered heap objects:
Node* -> Edge* -> Node* -> payload*
You store dense arrays:
std::vector<NodeRow> nodes;
std::vector<EdgeRow> edges;
std::vector<TransformNode> transforms;
std::vector<WeightEdge> weights;
Now:
- sequential memory
- predictable access
- prefetch-friendly
- no pointer chasing
This alone often wins before you optimize anything else.
Aside of pure performance the inspection and debugging of such data organization is way simpler than chasing void* pointer soup.
- IDs are cheaper than pointers (in practice)
A pointer graph:
- random memory access
- poor cache locality
- hard to batch
An ID-based graph:
NodeId -> index lookup -> contiguous data
Yes, you pay an indirection. But you remove cache misses. On modern CPUs, that’s usually a net win.
- “Joins” are just loops
Example:
find all edges with weights where from node has a transform
You don’t need SQL.
You do:
for (auto& w : weights) {
auto& edge = edges[w.edge];
if (has_transform[edge.from]) {
// process
}
}
That’s it.
- no virtual dispatch
- no pointer casting
- no branching on “type”
- no cache-miss lottery
Just tight loops over arrays. Eventually you may even come up with an idea of implementing a language integrated query (LINQ) framework.
- Build indices where needed
If something is frequent, index it:
std::vector<std::vector<EdgeId>> edges_from_node;
or:
std::unordered_map<NodeId, TransformNode>
or even better:
- sorted vectors + binary search
- dense bitsets
- sparse sets
You choose the structure per access pattern. Not everything needs to be O(1). Everything needs to be predictable.
- Optional data without branching hell
Instead of:
if (node->payload && node->type == TRANSFORM)
You use:
- presence in table
- or a bitset mask
if (has_transform[node_id]) { ... }
Branch becomes predictable. Data becomes separate. Code becomes honest.
- Memory management becomes trivial
Pointer model:
- who owns what?
- who deletes what?
- what invalidates what?
Table model:
- vectors own memory
- IDs stay stable (or use handles/generations)
- bulk free is trivial - serialization is dump-and-load, computing diffs is trivial (multiplayer game state synchronization anyone?)
You stop fighting lifetime bugs.
- You can still go “full performance mode”
Nothing stops you from:
- struct-of-arrays layouts
- SIMD over columns
- chunking (archetypes like ECS)
- custom allocators / arenas
- memory mapping
- compression of columns
In fact, this model makes those optimizations easier, not harder.
The uncomfortable truth
Pointer-heavy designs feel “low level”. But they often produce:
- worse cache behavior
- harder reasoning
- fragile invariants
- slower real-world performance
Because modern performance is not about pointer flexibility. It’s about data layout and predictability.
The mental shift
Stop thinking:
“What does this object point to?”
Start thinking:
“Where does this fact live, and how do I iterate it efficiently?”
Database-style modeling is not about SQL.
It’s about:
- explicit structure
- normalized data
- predictable access
And that’s exactly what high-performance C++ needs. Next time you reach for *void + enum** …ask yourself: Am I designing a data structure?
Or quietly building a database without admitting it?
Database-style modeling is powerful in C++. But it is not always the right tool. This matters, because every good idea becomes harmful when people turn it into a religion.
So when should you not model everything as tables + IDs?
- When behavior matters more than data
Sometimes the central thing is not “what facts exist?”
Sometimes the central thing is:
“What operation should this object perform?”
Example:
struct Shape {
virtual float area() const = 0;
virtual void draw(Renderer&) const = 0;
};
If you have a small, stable set of objects with rich behavior, polymorphism may be cleaner than forcing everything into component tables.
A table model is great for querying data. An object model is often better for encapsulating behavior.
- When the hierarchy is real and stable
If your domain naturally has a closed hierarchy:
Circle
Rectangle
Triangle
Then std::variant can be excellent:
using Shape = std::variant<Circle, Rectangle, Triangle>;
No void*. No fake database. No dynamic allocation required. Just a type-safe tagged union.
This is much better than pretending every tiny hierarchy needs an ID table.
- When object identity is not important
Tables shine when identity and relationships matter. But not everything has identity.
A Vec3, Color, Matrix, Rect, Range, or BoundingBox is usually just a value. Do not give every value object an ID. That way lies madness.
Sometimes the right model is simply:
struct Color {
uint8_t r, g, b, a;
};
Not every struct is secretly a table row.
- When the data is tiny and local
If a structure is:
- small
- short-lived
- private to one algorithm
- not serialized
- not shared across systems
Then simple direct structs are often best.
Example:
struct Candidate {
float score;
uint32_t index;
};
No schema needed. No registry. No component store.
Just code.
- When traversal is the whole point
Some structures are naturally pointer-shaped.
Examples:
- intrusive linked lists
- trees with parent pointers
- arena-allocated AST nodes
- graph algorithms optimized around adjacency
A table representation can still work, but it may not be worth the extra ceremony.
If the dominant operation is “walk this exact shape,” direct references can be perfectly reasonable.
The mistake is not using pointers. The mistake is using pointers as an undocumented schema.
- When lifetime is simple and ownership is obvious
If ownership is clear:
std::unique_ptr<Node> root;
and the whole structure dies together, then a pointer graph may be fine.
Especially with arenas.
A compiler AST is a good example:
- build it
- analyze it
- maybe transform it
- destroy it all at once
That can be simple and fast.
- When the abstraction boundary is behavior-based
Sometimes the whole point is to hide the implementation.
Example:
struct StorageBackend {
virtual void write(std::span<const std::byte>) = 0;
virtual std::vector<std::byte> read(Key) = 0;
};
You do not want callers reaching into tables here.
You want an interface.
Data-first design is not an excuse to destroy encapsulation.
So what is the rule?
Use table/ID/database-style modeling when:
- identity matters
- relationships matter
- serialization matters
- validation matters
- many systems touch the same data
the model evolves over time
Use object/value/polymorphic design when:
- behavior matters more than relationships
- data is local
- hierarchy is small and stable
- ownership is simple
- encapsulation matters more than introspection
The real enemy is not OOP. The real enemy is informal contracts.
*void + enum + comment** is not a design pattern.
It is a schema that gave up.
Good C++ is not “always objects.” Good C++ is not “always tables.”
Good C++ is choosing the representation that makes illegal states hard, common operations fast, and future maintenance survivable.