Back to flin
flin

Entities, Not Tables: How FlinDB Thinks About Data

Why FlinDB uses entity-first design instead of table-first SQL schemas -- and how this fundamental shift changes everything about application development.

Thales & Claude | March 25, 2026 11 min flin
flinflindbentitiesschemadata-model

The relational model has dominated data storage for fifty years. Tables, rows, columns, foreign keys, joins. It is a powerful abstraction -- and a terrible match for how developers actually think about their applications.

When a developer thinks about a blog, they think: "I have Users and Posts. A User writes many Posts. A Post has a title, body, and publication date." They do not think: "I need a users table with columns id INTEGER PRIMARY KEY, name VARCHAR(255), and a posts table with a user_id foreign key referencing users(id)."

The relational model forces developers to translate their mental model -- entities with properties and relationships -- into a mechanical model -- tables with columns and join keys. This translation is where most of the accidental complexity in application development lives.

FlinDB eliminates the translation entirely. You think in entities. You write in entities. FlinDB stores entities. There is no impedance mismatch because there is no mismatch.

The Fundamental Difference

In SQL, you define a table -- a container for rows. Each row is an untyped bag of columns. The table has no behavior. It does not know what it represents. It is just a grid of data.

CREATE TABLE users (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    name TEXT NOT NULL,
    email TEXT NOT NULL UNIQUE,
    age INTEGER,
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

In FLIN, you define an entity -- a first-class concept in the language. The entity knows its fields, their types, their defaults, their constraints, and their relationships to other entities.

entity User {
    name: text
    email: text @unique
    age: int?
}

These look superficially similar. The difference is what happens next.

When you add a row to a SQL table, you get a row. It has no identity beyond its primary key. It has no history. It has no behavior. It does not know that email is supposed to be unique unless you check the constraint definitions separately.

When you save an entity in FlinDB, you get an object with a rich lifecycle:

user = User { name: "Thales", email: "[email protected]" }
save user

// user now has: user.id // 1 (auto-assigned) user.created_at // 2026-01-13T10:00:00Z user.updated_at // 2026-01-13T10:00:00Z user.version // 1

// Update creates a new version user.name = "Juste Thales" save user user.version // 2

// History is always available user.history // [version 1, version 2] user @ -1 // The previous version ```

An entity is not a row. It is a versioned, timestamped, historically-aware object with an identity that persists across changes.

Field Types: Semantic, Not Mechanical

SQL field types describe storage formats. VARCHAR(255) means "a string of up to 255 characters." INTEGER means "a number stored as a 64-bit integer." These types tell the database how to store bits on disk. They tell the developer nothing about what the data means.

FLIN field types are semantic. They describe what the data is, not how it is stored.

TypeDescriptionExample
textUTF-8 string"Hello"
int64-bit integer42
number64-bit float3.14
boolBooleantrue
timeUTC timestampnow
moneyCurrency value1000 CFA
fileFile referenceUploaded file
semantic textText with AI embeddingsAI-searchable content

The money type is a good example. In SQL, you would store a price as DECIMAL(10,2) or INTEGER (cents) and handle currency conversion in your application code. In FLIN, money is a first-class type that understands currency. 1000 CFA is not just the number 1000 -- it is one thousand CFA francs, and FlinDB knows the difference.

The semantic text type is even more striking. In SQL, if you want AI-powered semantic search, you need to install a vector extension (pgvector), set up an embedding pipeline, manage vector indexes, and write similarity queries in a custom syntax. In FLIN, you write:

entity Product {
    name: text
    description: semantic text
}

That single semantic modifier tells FlinDB to automatically generate vector embeddings for the description field, store them in an internal vector index, and enable similarity search. No extension. No pipeline. No custom syntax.

Optional Fields and Defaults

SQL handles nullability with NULL and NOT NULL constraints. Defaults are specified with DEFAULT clauses. Both are defined at the table level, divorced from the data's meaning.

FLIN handles both at the entity level with intuitive syntax:

entity Todo {
    title: text
    done: bool = false           // Default: false
    created: time = now          // Default: current time
    priority: int = 1            // Default: 1
    notes: text?                 // Optional (can be none)
    due_date: time? = none       // Optional with explicit none
}

The ? suffix marks a field as optional. Default values are specified with =. This is not syntactic sugar over SQL -- it is a fundamentally different way of expressing data requirements. When you read a FLIN entity definition, you understand the data model immediately. When you read a SQL CREATE TABLE statement, you have to mentally parse constraint definitions and default clauses.

Relationships: References, Not Foreign Keys

In the relational model, relationships are expressed through foreign keys -- a column in one table that contains the primary key of another table. The database does not understand that this represents a relationship. It just knows that one integer column must match values in another integer column.

In FlinDB, relationships are expressed as entity references:

entity User {
    name: text
}

entity Post { title: text body: text author: User // Direct reference to a User entity } ```

The author: User field is not a foreign key column. It is a typed reference to a User entity. FlinDB understands this relationship at a fundamental level:

// Create entities
user = User { name: "Thales" }
save user

post = Post { title: "Hello World", body: "...", author: user } save post

// Navigate the relationship post.author // Returns the full User entity, not an ID post.author.name // "Thales"

// Query through relationships user_posts = Post.where(author == user) ```

No joins. No ON clauses. No LEFT OUTER JOIN versus INNER JOIN decision. You reference an entity, and the reference works. You query through a reference, and the query works.

The underlying Rust implementation stores references efficiently:

pub fn resolve_reference(
    &self,
    entity_type: &str,
    entity_id: u64,
    field_name: &str,
) -> DatabaseResult<Option<EntityInstance>>

When you access post.author, ZeroCore calls resolve_reference("Post", post_id, "author"), which reads the stored entity ID from the field, looks up the referenced User entity by type and ID (an O(1) operation), and returns the full entity instance. The developer never sees any of this machinery.

Many-to-Many Without Junction Tables

In SQL, many-to-many relationships require a junction table -- a third table that exists solely to connect two other tables:

CREATE TABLE post_tags (
    post_id INTEGER REFERENCES posts(id),
    tag_id INTEGER REFERENCES tags(id),
    PRIMARY KEY (post_id, tag_id)
);

This junction table has no meaning in the application's domain. It is purely mechanical scaffolding. In FlinDB, many-to-many relationships are expressed directly:

entity Tag {
    name: text
}

entity Post { title: text tags: [Tag] // List of Tag references }

// Usage post = Post { title: "FlinDB Tutorial", tags: [Tag.find(1), Tag.find(2)] } save post

// Query post.tags // Returns list of Tag entities ```

The [Tag] syntax declares a list of entity references. ZeroCore handles the storage internally, using an encoded list format:

// Lists are stored as encoded text for queryability
// Integer lists: __LIST__:1,2,3
// String lists: __LIST_STR__:admin,editor,viewer

No junction table. No join query. No N+1 problem to solve. The list is stored as part of the entity and resolved on access.

Self-References and Hierarchies

Hierarchical data -- categories, organizational charts, comment threads -- is notoriously difficult in the relational model. You end up with recursive CTEs, nested set models, or materialized path columns. Each approach has trade-offs and none is intuitive.

In FlinDB, self-references are natural:

entity Category {
    name: text
    parent: Category?         // Optional self-reference
}

// Build a tree electronics = Category { name: "Electronics" } save electronics

phones = Category { name: "Phones", parent: electronics } save phones

smartphones = Category { name: "Smartphones", parent: phones } save smartphones ```

The parent: Category? field is an optional reference to the same entity type. ZeroCore handles the storage and retrieval, and the graph query engine (implemented in Session 166) provides tree traversal operations:

// Find path between entities
db.find_path("Category", root_id, leaf_id)?;

// Traverse relationships db.traverse("Category", start_id, "parent", max_depth)?;

// Find connected components db.connected_components("Category", "parent")?; ```

These graph operations work on any entity with references, not just self-references. FlinDB treats all relationships as edges in a graph, which means the same traversal algorithms work for any relationship pattern.

The Version History Model

The most fundamental difference between FlinDB and a relational database is temporal versioning. In SQL, an UPDATE statement destroys the previous value. Gone. Unless you manually built an audit log, a history table, or an event sourcing system, the old data is irrecoverable.

In FlinDB, every save creates a new version:

user = User { name: "Juste" }
save user                      // Version 1

user.name = "Juste G." save user // Version 2

user.name = "Juste Gnimavo" save user // Version 3 ```

All three versions exist. You can access any of them:

user @ -1                     // Version 2: "Juste G."
user @ -2                     // Version 1: "Juste"
user @ "2026-01-13"           // State at a specific date
user.history                  // All versions

This is not a feature bolted onto a relational model. It is the storage model itself. ZeroCore stores entities as vectors of versioned instances:

// Data storage: entity_type -> id -> versions
data: HashMap<String, HashMap<EntityId, Vec<VersionedEntity>>>,

The current version is the last element in the vector. Previous versions are earlier elements. Temporal queries index into this vector directly -- no table scans, no subqueries, no window functions.

Delete vs Destroy: The CRUDD Model

FlinDB introduces a distinction that SQL lacks: the difference between deleting data and destroying it.

user = User.find(1)
delete user       // Soft delete: sets deleted_at, preserves history
user = User.find(1)
destroy user      // Hard delete: permanent removal, GDPR compliance

delete is soft -- it marks the entity as deleted (sets deleted_at) but preserves all version history. The entity disappears from queries but can be recovered. This is the default, because most deletions in real applications are mistakes or temporary states.

destroy is hard -- it permanently removes the entity and all its history. This is for compliance (GDPR right to erasure) or when you genuinely need to reclaim storage.

Both operations trigger cascade behavior on related entities. And both are fully supported in the Rust engine:

fn handle_on_delete_or_destroy(
    &mut self,
    entity_type: &str,
    entity_id: u64,
    is_destroy: bool,
) -> DatabaseResult<()> {
    match behavior {
        OnDeleteBehavior::Cascade => {
            if is_destroy {
                self.destroy(&ref_type, id)?;
            } else {
                self.delete(&ref_type, id)?;
            }
        }
        OnDeleteBehavior::SetNull => { /* ... */ }
        OnDeleteBehavior::Restrict => { /* ... */ }
    }
}

Why Entity-First Matters

The entity-first design is not just syntactic convenience. It changes the developer's relationship with data.

In a table-first model, data is inert. It sits in rows. You operate on it with external queries. The table does not know what it represents, and the developer must maintain the mapping between the application's domain model and the storage model in their head.

In an entity-first model, data is alive. It has identity, history, relationships, and behavior. The entity definition is both the schema and the documentation. When you read entity User { name: text, email: text @unique }, you understand everything about how Users work in this application.

This is especially powerful for the audience FLIN serves. Students in Abidjan, Dakar, and Lagos learning to code for the first time do not need to learn SQL, ORMs, migrations, and foreign key constraints before they can build their first application. They define entities, save them, and query them. The database disappears behind the language.

And for professional developers building with FLIN, the entity-first model eliminates an entire category of bugs: the ones that arise from the mismatch between the application model and the storage model. There is no mismatch because they are the same thing.

---

This is Part 2 of the "How We Built FlinDB" series, documenting how we built a complete embedded database engine for the FLIN programming language.

Series Navigation: - [056] FlinDB: Zero-Configuration Embedded Database - [057] Entities, Not Tables: How FlinDB Thinks About Data (you are here) - [058] CRUD Without SQL - [059] Constraints and Validation in FlinDB - [060] Aggregations and Analytics

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles