Write C++ like it's SQL

Write C++ like it's SQL

calendar_today • schedule7 min read

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.

  1. 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.

  1. 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.

  1. “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.

  1. 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.

  1. 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.

  1. 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.

  1. 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?

  1. 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.

  1. 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.

  1. 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.

  1. 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.

  1. 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.

  1. 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.

  1. 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.

2 Comments

0 votes
0
🔥 Join developers growing publicly
Share your knowledge, build in public, and grow your developer presence with a global community.

More Posts

Delivering Database Changes

Steve Fentonverified - Jul 22

Tuesday Coding Tip 06 - Explicit template instantiation

Jakub Neruda - Apr 7

Tuesday Coding Tip 02 - Template with type-specific API

Jakub Neruda - Mar 10

Your AI Doesn't Just Write Tests. It Runs Them Too.

Kevin Martinez - May 12

The Hidden Program Behind Every SQL Statement

lovestaco - Apr 11
chevron_left
1.2k Points • 22 Badges
Stockholm
6Posts
4Comments
5Connections
I’m software engineer with 15+ years of experience building complex developer tools, data-processing... Show more

Related Jobs

View all jobs →

Commenters (This Week)

1 comment
1 comment

Contribute meaningful comments to climb the leaderboard and earn badges!