Datomic is an immutable, time-aware database that takes a radically different approach to data modeling compared to traditional relational databases. Instead of tables with rows and columns, Datomic stores individual facts called datoms. Each datom is a tuple of four elements:
[entity-id attribute value transaction-id]Think of it this way: instead of saying “there is a row in the users table with name=Alice and email=alice@example.com”, Datomic says:
[42 :user/name "Alice" 100]
[42 :user/email "alice@example.com" 100]Entity 42 has a name “Alice” and an email “alice@example.com”, both asserted in transaction 100.
For more information about the Datomic Architecture please visit the official documentation.
This guide walks you through Datomic schema modeling concepts step-by-step, using a game studio database as a running example. We model users, games, play sessions, achievements, and more—comparing each step with its SQL equivalent so you can build intuition.
What is a Schema in Datomic? #
In relational databases, a schema defines tables, columns, and their types. In Datomic, there are no tables. Instead, a schema defines attributes—the possible facts you can state about entities. An entity can have any combination of attributes; there is no fixed “shape” imposed by a table.
Because schema is itself just data (stored as entities with special attributes), you define it by transacting ordinary data maps.
Part 1: Your First Schema—A Simple “User” Entity #
The SQL Way #
In SQL, you would create a users table:
CREATE TABLE users (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(255) NOT NULL UNIQUE,
email VARCHAR(255) NOT NULL,
active BOOLEAN DEFAULT TRUE
);This defines a rigid structure: every user row has exactly these columns, and NULL is used to represent missing data.
The Datomic Way #
In Datomic, you define individual attributes rather than tables. Each attribute definition is a map with three required keys and an optional documentation string:
| Key | Purpose | Required? |
|---|---|---|
:db/ident |
Unique keyword name for the attribute | Yes |
:db/valueType |
The data type of the value | Yes |
:db/cardinality |
One value or many values per entity | Yes |
:db/doc |
Human-readable documentation | No |
Here is the schema for our user entity:
;; User attributes
[{:db/ident :user/username
:db/valueType :db.type/string
:db/cardinality :db.cardinality/one
:db/unique :db.unique/identity
:db/doc "The user's unique username"}
{:db/ident :user/email
:db/valueType :db.type/string
:db/cardinality :db.cardinality/one
:db/doc "The user's email address"}
{:db/ident :user/active?
:db/valueType :db.type/boolean
:db/cardinality :db.cardinality/one
:db/doc "Whether the user account is active"}]Key Differences from SQL #
- No table declaration: Attributes are global. Any entity can have a
:user/username. The namespaceuser/is a convention, not enforcement. - No
NULL: If a user does not have an email, you simply do not assert:user/emailfor that entity. There is no concept of a null column. - No auto-increment ID: Datomic assigns entity IDs automatically. You use temporary IDs (
"tempid") during transactions, and Datomic resolves them to permanent IDs. - Schema is data: You transact schema definitions just like any other data—using the same
d/transactfunction.
Transacting Data #
Once the schema is installed, you can create a user:
;; EDN - Creating a user
[{:user/username "alice_gamer"
:user/email "alice@example.com"
:user/active? true}]This creates a new entity with three datoms. Datomic assigns it an entity ID automatically.
Part 2: Value Types—Choosing the Right Data Type #
Datomic supports a fixed set of value types. Choosing the right one is important because, unlike some schema attributes, :db/valueType cannot be changed after an attribute is created.
Complete Value Types Reference #
| Datomic Type | Java Type | Example | SQL Equivalent |
|---|---|---|---|
:db.type/string |
java.lang.String |
"Hello World" |
VARCHAR / TEXT |
:db.type/long |
java.lang.Long |
42 |
BIGINT |
:db.type/boolean |
java.lang.Boolean |
true |
BOOLEAN |
:db.type/double |
java.lang.Double |
3.14159 |
DOUBLE PRECISION |
:db.type/float |
java.lang.Float |
3.14 |
REAL |
:db.type/bigdec |
java.math.BigDecimal |
99.99M |
DECIMAL / NUMERIC |
:db.type/bigint |
java.math.BigInteger |
999999999999999999N |
NUMERIC |
:db.type/instant |
java.util.Date |
#inst "2026-03-15T10:30:00.000Z" |
TIMESTAMP |
:db.type/uuid |
java.util.UUID |
#uuid "f47ac10b-58cc-4372-a567-0e02b2c3d479" |
UUID |
:db.type/uri |
java.net.URI |
#uri "https://example.com" |
VARCHAR (with convention) |
:db.type/keyword |
clojure.lang.Keyword |
:game.genre/rpg |
ENUM (or VARCHAR) |
:db.type/bytes |
byte[] |
(binary data) | BYTEA / BLOB |
:db.type/ref |
Entity reference | [:user/username "alice_gamer"] |
FOREIGN KEY |
:db.type/symbol |
clojure.lang.Symbol |
'my-symbol |
(no equivalent) |
:db.type/tuple |
clojure.lang.PersistentVector |
[100 200] |
(no direct equivalent) |
Practical Type Choices for the Game Studio #
Let’s expand our schema with attributes that showcase different types:
;; Game attributes — demonstrating various value types
[;; String: game title (UNIQUE)
{:db/ident :game/title
:db/valueType :db.type/string
:db/cardinality :db.cardinality/one
:db/unique :db.unique/identity
:db/doc "The title of the game"}
;; String: description (longer text)
{:db/ident :game/description
:db/valueType :db.type/string
:db/cardinality :db.cardinality/one
:db/doc "A short description of the game"}
;; Long: an integer counter
{:db/ident :game/max-players
:db/valueType :db.type/long
:db/cardinality :db.cardinality/one
:db/doc "Maximum number of concurrent players"}
;; Double: a rating score
{:db/ident :game/rating
:db/valueType :db.type/double
:db/cardinality :db.cardinality/one
:db/doc "Average user rating (0.0 - 5.0)"}
;; Boolean: published status
{:db/ident :game/published?
:db/valueType :db.type/boolean
:db/cardinality :db.cardinality/one
:db/doc "Whether the game is published and available"}
;; Instant: timestamps
{:db/ident :game/release-date
:db/valueType :db.type/instant
:db/cardinality :db.cardinality/one
:db/doc "The official release date"}
;; UUID: external identifier
{:db/ident :game/external-id
:db/valueType :db.type/uuid
:db/cardinality :db.cardinality/one
:db/unique :db.unique/identity
:db/doc "External UUID for API integrations"}
;; URI: a link
{:db/ident :game/website
:db/valueType :db.type/uri
:db/cardinality :db.cardinality/one
:db/doc "Official website URL"}]The SQL equivalent would be:
CREATE TABLE games (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
title VARCHAR(255) NOT NULL UNIQUE,
description TEXT,
max_players BIGINT,
rating DOUBLE PRECISION,
published BOOLEAN,
release_date TIMESTAMP,
external_id UUID UNIQUE,
website VARCHAR(2048)
);Working with Dates and Times (:db.type/instant)
#
Datomic’s instant type maps to java.util.Date which stores millisecond-precision UTC timestamps. In EDN, instants use the #inst reader literal:
;; Creating a game with a release date
[{:game/title "Dragon's Quest Online"
:game/max-players 1000
:game/rating 4.5
:game/published? true
:game/release-date #inst "2025-06-15T00:00:00.000Z"
:game/external-id #uuid "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
:game/website #uri "https://dragons-quest.example.com"}]Important considerations:
- Datomic stores instants as UTC timestamps at millisecond precision.
- If you need timezone information, store it as a separate string attribute (e.g.,
:event/timezone "Europe/London"). - For date-only values (no time component), use midnight UTC by convention:
#inst "2025-06-15T00:00:00.000Z". - For sub-millisecond precision, consider storing as a
:db.type/longrepresenting nanoseconds since epoch.
Working with Money and Currencies (:db.type/bigdec)
#
For monetary values, always use :db.type/bigdec. Floating point types (double, float) introduce rounding errors that are unacceptable for financial calculations.
;; Price attributes using BigDecimal
[{:db/ident :game/price
:db/valueType :db.type/bigdec
:db/cardinality :db.cardinality/one
:db/doc "The price of the game in the base currency"}
{:db/ident :game/currency
:db/valueType :db.type/string
:db/cardinality :db.cardinality/one
:db/doc "ISO 4217 currency code (e.g., USD, EUR, GBP)"}]Example data:
[{:game/title "Dragon's Quest Online"
:game/price 49.99M ;; M suffix denotes BigDecimal in EDN
:game/currency "USD"}]Currency modeling strategies:
- Simple: Store amount and currency code as separate attributes (shown above).
- Per-region pricing: Use a component entity for each price point (covered in Part 6).
- Enumerated currency: Model currency as an enum entity rather than a plain string (covered in Part 4).
The equivalent SQL for comparison:
ALTER TABLE games ADD COLUMN price DECIMAL(19, 4);
ALTER TABLE games ADD COLUMN currency VARCHAR(3);Part 3: Cardinality—One vs Many #
Cardinality controls whether an attribute holds a single value or a set of values for each entity.
:db.cardinality/one — Single Value
#
Most attributes are cardinality-one. If you assert a new value for a cardinality-one attribute on an existing entity, the new value replaces the old one (the old value is retracted automatically).
;; First transaction: sets the email
[{:user/username "alice_gamer"
:user/email "alice@old-email.com"}]
;; Later transaction: updates the email (old value is retracted)
[{:user/username "alice_gamer"
:user/email "alice@new-email.com"}]After both transactions, the entity only has alice@new-email.com as its email. But because Datomic is immutable, the historical value alice@old-email.com is still accessible via the as-of or history database filters.
:db.cardinality/many — Set of Values
#
Cardinality-many attributes hold an unordered set of values. Asserting a new value adds to the set; it does not replace existing values.
;; A game can belong to multiple genres
{:db/ident :game/tags
:db/valueType :db.type/string
:db/cardinality :db.cardinality/many
:db/doc "Freeform tags associated with the game"};; Assert tags for a game
[{:game/title "Dragon's Quest Online"
:game/tags #{"rpg" "multiplayer" "fantasy"}}]
;; Add more tags later—these are ADDED to the existing set
[{:game/title "Dragon's Quest Online"
:game/tags #{"open-world"}}]
;; Result: #{"rpg" "multiplayer" "fantasy" "open-world"}SQL Comparison #
Cardinality-many in SQL typically requires a junction table:
-- SQL equivalent of :game/tags with cardinality/many
CREATE TABLE game_tags (
game_id BIGINT REFERENCES games(id),
tag VARCHAR(255),
PRIMARY KEY (game_id, tag)
);
INSERT INTO game_tags VALUES (1, 'rpg');
INSERT INTO game_tags VALUES (1, 'multiplayer');
INSERT INTO game_tags VALUES (1, 'fantasy');In Datomic, you get this for free—no junction table, no extra schema.
Important: Cardinality-Many Stores Sets, Not Lists #
Cardinality-many values are unordered sets. Duplicate values are ignored, and there is no guaranteed ordering. If you need an ordered list, you have several options:
- Use a tuple type (for small, fixed-size sequences).
- Model each item as a separate entity with an explicit ordinal attribute.
- Store the ordered data as a serialized string or bytes.
Part 4: Enumerated Values #
Many domains have a fixed set of allowed values—game genres, user roles, platform types. In SQL you might use an ENUM type or a lookup table. In Datomic, the idiomatic approach is to create entities with :db/ident and reference them via :db.type/ref attributes.
Defining Enum Values #
;; Step 1: Define the attribute that references the enum
[{:db/ident :game/genre
:db/valueType :db.type/ref
:db/cardinality :db.cardinality/one
:db/doc "The primary genre of the game"}
{:db/ident :game/platforms
:db/valueType :db.type/ref
:db/cardinality :db.cardinality/many
:db/doc "Platforms the game is available on"}]
;; Step 2: Create the enum entities (they are just entities with :db/ident)
[{:db/ident :game.genre/rpg}
{:db/ident :game.genre/fps}
{:db/ident :game.genre/strategy}
{:db/ident :game.genre/puzzle}
{:db/ident :game.genre/simulation}
{:db/ident :game.genre/adventure}
{:db/ident :game.genre/mmorpg}
{:db/ident :game.platform/pc}
{:db/ident :game.platform/playstation}
{:db/ident :game.platform/xbox}
{:db/ident :game.platform/nintendo-switch}
{:db/ident :game.platform/mobile}]Using Enums in Transactions #
[{:game/title "Dragon's Quest Online"
:game/genre :game.genre/mmorpg
:game/platforms #{:game.platform/pc
:game.platform/playstation
:game.platform/xbox}}]Notice how :game/platforms uses cardinality-many with refs—a game can be on multiple platforms, and each platform is an enumerated value.
SQL Comparison #
-- SQL approach 1: ENUM type
CREATE TYPE game_genre AS ENUM (
'rpg', 'fps', 'strategy', 'puzzle',
'simulation', 'adventure', 'mmorpg'
);
ALTER TABLE games ADD COLUMN genre game_genre;
-- SQL approach 2: Lookup table with junction table for many-to-many
CREATE TABLE platforms (
id SERIAL PRIMARY KEY,
name VARCHAR(50) UNIQUE
);
CREATE TABLE game_platforms (
game_id BIGINT REFERENCES games(id),
platform_id BIGINT REFERENCES platforms(id),
PRIMARY KEY (game_id, platform_id)
);Why Enums via :db/ident Are Efficient
#
Enum entities with :db/ident are stored in memory on every Datomic compute node. Referencing them is extremely efficient—Datomic stores just the entity ID in the datom, not the full keyword string. This makes them ideal for values that are referenced frequently across many entities.
Guideline: Use :db/ident enums for small, stable, domain-specific value sets (dozens to low hundreds). Do not use :db/ident for large or unbounded sets—use a regular unique identity attribute instead.
Adding Enums Later #
Because enums are just entities, adding new values is simply a transaction:
;; A new genre appears!
[{:db/ident :game.genre/battle-royale}]No schema migration, no ALTER TYPE, no downtime.
Part 5: Identity and Uniqueness #
:db/ident vs :db/unique
#
These serve different purposes:
-
:db/ident: Associates a keyword name with an entity. Used for schema attributes and enumerated values. Stored in memory on all nodes. Not for general domain entities. -
:db/unique: Enforces uniqueness on a regular attribute. Used for domain-level unique identifiers like usernames, email addresses, or product codes.
Two Flavors of Uniqueness #
:db.unique/identity — Upsert Behavior
#
When you transact an entity with a :db.unique/identity attribute that matches an existing entity, Datomic merges the new facts into the existing entity (upsert).
{:db/ident :user/username
:db/valueType :db.type/string
:db/cardinality :db.cardinality/one
:db/unique :db.unique/identity
:db/doc "The user's unique login name"};; First transaction: creates a new entity
[{:user/username "alice_gamer"
:user/email "alice@example.com"}]
;; Second transaction: finds the existing entity by username and updates it
[{:user/username "alice_gamer"
:user/email "alice@new-domain.com"
:user/active? true}]
;; This does NOT create a duplicate. It updates entity where :user/username = "alice_gamer":db.unique/value — Strict Uniqueness
#
:db.unique/value enforces that no two entities can have the same value, but it does not support upsert. Attempting to transact a duplicate will throw an exception.
{:db/ident :user/email
:db/valueType :db.type/string
:db/cardinality :db.cardinality/one
:db/unique :db.unique/value
:db/doc "The user's unique email address — no two users can share an email"}Lookup Refs #
Once you have a unique identity attribute, you can refer to entities by their unique value instead of their entity ID. These are called lookup refs:
;; Instead of using a numeric entity ID:
{:db/id 17592186045418, :user/email "alice@new-domain.com"}
;; Use a lookup ref:
{:db/id [:user/username "alice_gamer"], :user/email "alice@new-domain.com"}Lookup refs work anywhere an entity ID is expected—in transactions, queries, and the pull API.
UUIDs and Squuids #
Datomic includes a semi-sequential UUID generator called squuid (d/squuid). Squuids have a leading time component, which helps align read patterns with recency in the indexes.
{:db/ident :game/external-id
:db/valueType :db.type/uuid
:db/cardinality :db.cardinality/one
:db/unique :db.unique/identity
:db/doc "External API identifier — generated with d/squuid"};; In application code:
(require '[datomic.api :as d])
[{:game/title "Puzzle Planet"
:game/external-id (d/squuid)}]SQL Comparison #
-- :db.unique/identity ≈ UNIQUE + ON CONFLICT DO UPDATE (upsert)
CREATE UNIQUE INDEX idx_users_username ON users(username);
-- :db.unique/value ≈ UNIQUE constraint (strict)
ALTER TABLE users ADD CONSTRAINT uq_email UNIQUE (email);Part 6: References—Modeling Relationships #
References (:db.type/ref) are the backbone of relationship modeling in Datomic. Unlike SQL foreign keys, Datomic references are automatically indexed in both directions, making traversal equally efficient in either direction.
Simple Reference (One-to-Many) #
A play session belongs to one user and one game:
;; Play session attributes
[{:db/ident :session/user
:db/valueType :db.type/ref
:db/cardinality :db.cardinality/one
:db/doc "The user who played this session"}
{:db/ident :session/game
:db/valueType :db.type/ref
:db/cardinality :db.cardinality/one
:db/doc "The game that was played"}
{:db/ident :session/started-at
:db/valueType :db.type/instant
:db/cardinality :db.cardinality/one
:db/doc "When the play session started"}
{:db/ident :session/duration-minutes
:db/valueType :db.type/long
:db/cardinality :db.cardinality/one
:db/doc "Duration of the session in minutes"}
{:db/ident :session/score
:db/valueType :db.type/long
:db/cardinality :db.cardinality/one
:db/doc "Score achieved in this session"}]SQL equivalent:
CREATE TABLE play_sessions (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL REFERENCES users(id),
game_id BIGINT NOT NULL REFERENCES games(id),
started_at TIMESTAMP NOT NULL,
duration_minutes BIGINT,
score BIGINT
);Creating Data with References #
You reference other entities using lookup refs, entity IDs, or nested maps:
;; Using lookup refs to reference existing entities
[{:session/user [:user/username "alice_gamer"]
:session/game [:game/title "Dragon's Quest Online"]
:session/started-at #inst "2026-03-15T14:30:00.000Z"
:session/duration-minutes 120
:session/score 15000}]Many-to-Many References #
A game can have many achievements, and a user can unlock many achievements. We model this with cardinality-many references:
;; Achievement schema
[{:db/ident :achievement/name
:db/valueType :db.type/string
:db/cardinality :db.cardinality/one
:db/unique :db.unique/identity
:db/doc "Unique name of the achievement"}
{:db/ident :achievement/description
:db/valueType :db.type/string
:db/cardinality :db.cardinality/one
:db/doc "Description of how to earn this achievement"}
{:db/ident :achievement/points
:db/valueType :db.type/long
:db/cardinality :db.cardinality/one
:db/doc "Points awarded for this achievement"}
;; A game has many achievements
{:db/ident :game/achievements
:db/valueType :db.type/ref
:db/cardinality :db.cardinality/many
:db/doc "Achievements available in this game"}
;; A user has unlocked many achievements
{:db/ident :user/achievements
:db/valueType :db.type/ref
:db/cardinality :db.cardinality/many
:db/doc "Achievements this user has unlocked"}]SQL equivalent requires a junction table:
CREATE TABLE achievements (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(255) UNIQUE NOT NULL,
description TEXT,
points BIGINT
);
CREATE TABLE game_achievements (
game_id BIGINT REFERENCES games(id),
achievement_id BIGINT REFERENCES achievements(id),
PRIMARY KEY (game_id, achievement_id)
);
CREATE TABLE user_achievements (
user_id BIGINT REFERENCES users(id),
achievement_id BIGINT REFERENCES achievements(id),
PRIMARY KEY (user_id, achievement_id)
);In Datomic, you get the equivalent of junction tables for free with cardinality-many refs.
Sample Data with References #
;; Create achievements and link them to a game in one transaction
[{:game/title "Dragon's Quest Online"
:game/achievements [{:achievement/name "First Blood"
:achievement/description "Defeat your first enemy"
:achievement/points 10}
{:achievement/name "Dragon Slayer"
:achievement/description "Defeat the final dragon boss"
:achievement/points 100}
{:achievement/name "Social Butterfly"
:achievement/description "Add 10 friends to your party"
:achievement/points 25}]}]
;; User unlocks some achievements
[{:user/username "alice_gamer"
:user/achievements #{[:achievement/name "First Blood"]
[:achievement/name "Dragon Slayer"]}}]Part 7: Reverse References—Bidirectional Navigation #
In Datomic, every reference is automatically bidirectional. If entity A references entity B via attribute :x/y, you can navigate from B back to A using the reverse reference :x/_y (note the underscore prefix on the name part).
Forward vs Reverse Navigation #
;; Forward: "What game did this session belong to?"
(d/pull db [:session/game] session-entity-id)
;; => {:session/game {:db/id 12345}}
;; Reverse: "What sessions does this game have?"
(d/pull db [:session/_game] game-entity-id)
;; => {:session/_game [{:db/id 99001} {:db/id 99002} ...]}The underscore convention works in the Pull API and Entity API:
;; Using the Entity API
(def game (d/entity db [:game/title "Dragon's Quest Online"]))
;; Forward navigation: get the game's achievements
(:game/achievements game)
;; => #{#:db{:id 201} #:db{:id 202} #:db{:id 203}}
;; Reverse navigation: get all sessions for this game
(:session/_game game)
;; => #{#:db{:id 301} #:db{:id 302}}Why You Don’t Need Bidirectional Schema #
In SQL, if you want to navigate from a game to its sessions, you query SELECT * FROM play_sessions WHERE game_id = ?. The foreign key is on the sessions table, and you traverse it with a WHERE clause.
In Datomic, you define the reference only once:
;; Only this attribute exists in the schema:
{:db/ident :session/game
:db/valueType :db.type/ref
:db/cardinality :db.cardinality/one}Yet you can traverse in both directions equally efficiently because Datomic maintains indexes in both the EAVT (entity→attribute→value) and VAET (value→attribute→entity) directions.
There is no need to add a :game/sessions attribute that points back to sessions. That would be redundant and create a maintenance burden where you must keep both directions in sync.
SQL Comparison #
-- In SQL, "reverse navigation" is just a query with a WHERE clause:
-- Forward: Get the game for a session
SELECT * FROM games WHERE id = (SELECT game_id FROM play_sessions WHERE id = ?);
-- Reverse: Get all sessions for a game
SELECT * FROM play_sessions WHERE game_id = ?;In Datomic, both directions are built-in and indexed. No extra queries or indexes needed.
Important: Naming Convention #
Do not start the name portion of your attribute keywords with an underscore. For example, avoid :game/_internal-score because the underscore prefix is reserved for reverse reference navigation. If you use underscores in the middle (:game/max_players), that is fine.
Part 8: Component Entities—Parent-Child Ownership #
Sometimes an entity only makes sense as part of another entity. For example, a line item only exists within an order. Datomic models this with component references using the :db/isComponent true flag.
What Components Give You #
- Cascade retraction: When you retract the parent entity, all component children are automatically retracted.
- Nested pull: The pull API automatically follows component references, returning nested data.
- Nested transaction: You can create child entities inline within the parent’s transaction map.
Example: Game Pricing Tiers (Component) #
A game might have regional pricing—each price point only makes sense in the context of a game.
;; Price tier attributes
[{:db/ident :price/region
:db/valueType :db.type/string
:db/cardinality :db.cardinality/one
:db/doc "Region code (e.g., US, EU, JP)"}
{:db/ident :price/amount
:db/valueType :db.type/bigdec
:db/cardinality :db.cardinality/one
:db/doc "Price amount in the region's currency"}
{:db/ident :price/currency
:db/valueType :db.type/string
:db/cardinality :db.cardinality/one
:db/doc "ISO 4217 currency code"}
;; Component reference: prices belong to a game
{:db/ident :game/prices
:db/valueType :db.type/ref
:db/cardinality :db.cardinality/many
:db/isComponent true
:db/doc "Regional pricing for this game (component entities)"}]Transacting Nested Components #
;; Create a game with inline price components
[{:game/title "Dragon's Quest Online"
:game/prices [{:price/region "US"
:price/amount 49.99M
:price/currency "USD"}
{:price/region "EU"
:price/amount 44.99M
:price/currency "EUR"}
{:price/region "JP"
:price/amount 5980M
:price/currency "JPY"}]}]Pulling Component Data #
Components are automatically expanded by the pull API’s * wildcard:
(d/pull db '[*] [:game/title "Dragon's Quest Online"])
;; =>
;; {:db/id 12345
;; :game/title "Dragon's Quest Online"
;; :game/prices [{:db/id 12346
;; :price/region "US"
;; :price/amount 49.99M
;; :price/currency "USD"}
;; {:db/id 12347
;; :price/region "EU"
;; :price/amount 44.99M
;; :price/currency "EUR"}
;; {:db/id 12348
;; :price/region "JP"
;; :price/amount 5980M
;; :price/currency "JPY"}]}Cascade Retraction #
;; Retracting the game automatically retracts all its prices
(d/transact conn [[:db/retractEntity [:game/title "Dragon's Quest Online"]]])
;; All three price entities are also retracted!Component vs Non-Component: A Decision Guide #
| Relationship | Component? | Reasoning |
|---|---|---|
| Game → Price tiers | Yes | Prices don’t exist without the game |
| Order → Line items | Yes | Line items belong to exactly one order |
| Game → Achievements | Maybe | Achievements might be shared across games, or might be game-specific |
| Session → User | No | Users exist independently of sessions |
| Session → Game | No | Games exist independently of sessions |
| User → Profile picture | Yes | The picture entity only exists for this user |
SQL Comparison #
-- SQL uses ON DELETE CASCADE for similar behavior:
CREATE TABLE game_prices (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
game_id BIGINT NOT NULL REFERENCES games(id) ON DELETE CASCADE,
region VARCHAR(10) NOT NULL,
amount DECIMAL(19,4) NOT NULL,
currency VARCHAR(3) NOT NULL
);Part 9: Composite Tuples—Multi-Field Uniqueness #
Sometimes uniqueness depends on a combination of attributes. In SQL, you create a composite unique index. In Datomic, you use composite tuples.
Example: Unique Leaderboard Entries #
A leaderboard entry should be unique per user, game, and season:
;; First, define the individual attributes
[{:db/ident :leaderboard/user
:db/valueType :db.type/ref
:db/cardinality :db.cardinality/one
:db/doc "The user on the leaderboard"}
{:db/ident :leaderboard/game
:db/valueType :db.type/ref
:db/cardinality :db.cardinality/one
:db/doc "The game for this leaderboard entry"}
{:db/ident :leaderboard/season
:db/valueType :db.type/string
:db/cardinality :db.cardinality/one
:db/doc "The season identifier (e.g., 2026-Q1)"}
{:db/ident :leaderboard/high-score
:db/valueType :db.type/long
:db/cardinality :db.cardinality/one
:db/doc "The user's highest score for this game/season"}
;; Composite tuple: auto-composed from the three attributes above
{:db/ident :leaderboard/user+game+season
:db/valueType :db.type/tuple
:db/tupleAttrs [:leaderboard/user :leaderboard/game :leaderboard/season]
:db/cardinality :db.cardinality/one
:db/unique :db.unique/identity
:db/doc "Composite key ensuring one entry per user/game/season"}]Now when you transact a leaderboard entry, Datomic automatically populates the composite tuple. If an entry with the same user, game, and season already exists, it will be upserted (because the composite has :db.unique/identity).
;; Upserts: if alice already has a Dragon's Quest Q1 entry, this updates it
[{:leaderboard/user [:user/username "alice_gamer"]
:leaderboard/game [:game/title "Dragon's Quest Online"]
:leaderboard/season "2026-Q1"
:leaderboard/high-score 25000}]SQL equivalent:
CREATE TABLE leaderboard (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL REFERENCES users(id),
game_id BIGINT NOT NULL REFERENCES games(id),
season VARCHAR(20) NOT NULL,
high_score BIGINT NOT NULL,
UNIQUE (user_id, game_id, season)
);Important Notes on Composite Tuples #
- You never assert or retract the composite attribute yourself. Datomic manages it automatically.
- Composite tuples support 2 to 8 source attributes.
- The source attributes can be of any type, including refs.
- If any source attribute is missing, the composite tuple position is
nil, but the tuple is still maintained.
Part 10: Heterogeneous and Homogeneous Tuples #
Beyond composite tuples, Datomic supports tuples for storing small, structured groups of values directly on an entity.
Heterogeneous Tuples (Fixed Types) #
A heterogeneous tuple has a fixed number of positions, each with a specified type. Useful for things like coordinates or structured small data.
;; Player position in a 2D game world
{:db/ident :player/position
:db/valueType :db.type/tuple
:db/tupleTypes [:db.type/long :db.type/long]
:db/cardinality :db.cardinality/one
:db/doc "Player's [x y] position in the game world"}
;; Game version: [major minor patch]
{:db/ident :game/version
:db/valueType :db.type/tuple
:db/tupleTypes [:db.type/long :db.type/long :db.type/long]
:db/cardinality :db.cardinality/one
:db/doc "Game version as [major minor patch]"};; Using heterogeneous tuples
[{:player/handle "alice_gamer"
:player/position [1024 768]}
{:game/title "Dragon's Quest Online"
:game/version [2 3 1]}]Homogeneous Tuples (Variable Length, Single Type) #
A homogeneous tuple holds a variable number of values (2-8), all of the same type.
;; A game's supported resolutions (as width values)
{:db/ident :game/supported-resolutions
:db/valueType :db.type/tuple
:db/tupleType :db.type/long ;; Note: singular :db/tupleType
:db/cardinality :db.cardinality/one
:db/doc "Supported screen widths"}[{:game/title "Dragon's Quest Online"
:game/supported-resolutions [1280 1920 2560 3840]}]When to Use Tuples vs Separate Attributes #
| Use Case | Approach | Why |
|---|---|---|
| 2D coordinates | Heterogeneous tuple | Small, fixed structure, always used together |
| Version numbers | Heterogeneous tuple | Always \[major minor patch\] |
| Composite unique key | Composite tuple | Datomic manages automatically |
| Address (street, city, etc.) | Separate attributes | Each part is independently queryable |
| Phone numbers (multiple) | Cardinality-many string | Variable count, each is independent |
Part 11: Attribute Predicates and Entity Specs #
Datomic supports lightweight validation through attribute predicates and entity specs.
Attribute Predicates (:db.attr/preds)
#
An attribute predicate is a Clojure function that validates individual attribute values at transaction time.
;; Define the predicate function (must be on the classpath)
(defn valid-rating? [rating]
(<= 0.0 rating 5.0))
;; Attach it to the attribute
{:db/ident :game/rating
:db/valueType :db.type/double
:db/cardinality :db.cardinality/one
:db.attr/preds ['my.ns/valid-rating?]
:db/doc "Rating must be between 0.0 and 5.0"}If a transaction tries to assert a rating of 6.5, the predicate fails and the entire transaction is rejected.
Entity Specs (:db.entity/attrs and :db.entity/preds)
#
Entity specs let you validate combinations of attributes on an entity. They are triggered when an entity has a specific attribute.
;; Define a spec: any entity with :game/title must also have :game/genre
{:db/ident :game/title
:db.entity/attrs [:game/genre :game/max-players]
:db.entity/preds ['my.ns/valid-game?]};; The predicate receives the full entity map
(defn valid-game? [ent]
(pos? (:game/max-players ent)))Part 12: History and :db/noHistory
#
By default, Datomic keeps the full history of every datom. This is powerful for auditing, time-travel queries, and debugging. But for high-churn attributes where historical values are not useful, you can opt out.
;; Player position changes thousands of times per session—history is noise
{:db/ident :player/position
:db/valueType :db.type/tuple
:db/tupleTypes [:db.type/long :db.type/long]
:db/cardinality :db.cardinality/one
:db/noHistory true
:db/doc "Current position—history not retained to save storage"}
;; But game rating history IS useful for trend analysis
{:db/ident :game/rating
:db/valueType :db.type/double
:db/cardinality :db.cardinality/one
:db/doc "Average rating—history retained by default"}Note: :db/noHistory is a storage optimization. Datomic may still retain some historical values temporarily. It is not a privacy or compliance mechanism—do not rely on it for data erasure.
Part 13: Schema Change and Evolution #
One of Datomic’s strengths is schema flexibility. But there are rules.
What You Can Change #
| Change | Allowed? |
|---|---|
| Add new attributes | Yes, always |
| Add new enum values | Yes, always |
Rename an attribute (:db/ident) |
Yes (changes the name, not the identity) |
Change :db/doc |
Yes |
Change :db/cardinality |
Yes (but be careful with existing data) |
Add :db/unique |
Yes (if existing data has no duplicates) |
Change :db/isComponent |
Yes |
Change :db/noHistory |
Yes |
Add :db.attr/preds |
Yes |
What You Cannot Change #
| Change | Allowed? |
|---|---|
Change :db/valueType |
No, never |
| Remove an attribute | No (you can stop using it) |
If you need a different value type, create a new attribute with the desired type and migrate data.
Example: Evolving the Schema #
;; Initial: game rating as a long (oops, should have been double)
{:db/ident :game/rating-v1
:db/valueType :db.type/long
:db/cardinality :db.cardinality/one
:db/doc "DEPRECATED - Use :game/rating instead"}
;; New: game rating as double (just add a new attribute)
{:db/ident :game/rating
:db/valueType :db.type/double
:db/cardinality :db.cardinality/one
:db/doc "Average user rating (0.0 - 5.0)"}You can then migrate data in application code by reading the old attribute and transacting the new one.
Part 14: Putting It All Together—Complete Game Studio Schema #
Here is the complete schema for our game studio database, combining all the concepts covered in this guide.
;; ============================================================
;; GAME STUDIO DATABASE — Complete Datomic Schema
;; ============================================================
;; ---- Enumerated Values ----
[;; Game genres
{:db/ident :game.genre/rpg}
{:db/ident :game.genre/fps}
{:db/ident :game.genre/strategy}
{:db/ident :game.genre/puzzle}
{:db/ident :game.genre/simulation}
{:db/ident :game.genre/adventure}
{:db/ident :game.genre/mmorpg}
{:db/ident :game.genre/battle-royale}
;; Platforms
{:db/ident :game.platform/pc}
{:db/ident :game.platform/playstation}
{:db/ident :game.platform/xbox}
{:db/ident :game.platform/nintendo-switch}
{:db/ident :game.platform/mobile}
;; User roles
{:db/ident :user.role/player}
{:db/ident :user.role/moderator}
{:db/ident :user.role/admin}
{:db/ident :user.role/developer}
;; Session status
{:db/ident :session.status/active}
{:db/ident :session.status/completed}
{:db/ident :session.status/disconnected}]
;; ---- User Attributes ----
[{:db/ident :user/username
:db/valueType :db.type/string
:db/cardinality :db.cardinality/one
:db/unique :db.unique/identity
:db/doc "Unique login name — supports upsert"}
{:db/ident :user/email
:db/valueType :db.type/string
:db/cardinality :db.cardinality/one
:db/unique :db.unique/value
:db/doc "Unique email — strict, no upsert"}
{:db/ident :user/display-name
:db/valueType :db.type/string
:db/cardinality :db.cardinality/one
:db/doc "Display name shown in-game"}
{:db/ident :user/active?
:db/valueType :db.type/boolean
:db/cardinality :db.cardinality/one
:db/doc "Whether the account is active"}
{:db/ident :user/role
:db/valueType :db.type/ref
:db/cardinality :db.cardinality/one
:db/doc "User's role (enum ref)"}
{:db/ident :user/joined-at
:db/valueType :db.type/instant
:db/cardinality :db.cardinality/one
:db/doc "When the user registered"}
{:db/ident :user/external-id
:db/valueType :db.type/uuid
:db/cardinality :db.cardinality/one
:db/unique :db.unique/identity
:db/doc "External UUID for API integrations"}
{:db/ident :user/achievements
:db/valueType :db.type/ref
:db/cardinality :db.cardinality/many
:db/doc "Achievements unlocked by this user"}
{:db/ident :user/friends
:db/valueType :db.type/ref
:db/cardinality :db.cardinality/many
:db/doc "Other users this user has friended (unidirectional)"}]
;; ---- Game Attributes ----
[{:db/ident :game/title
:db/valueType :db.type/string
:db/cardinality :db.cardinality/one
:db/unique :db.unique/identity
:db/doc "Unique game title"}
{:db/ident :game/description
:db/valueType :db.type/string
:db/cardinality :db.cardinality/one
:db/doc "Game description"}
{:db/ident :game/genre
:db/valueType :db.type/ref
:db/cardinality :db.cardinality/one
:db/doc "Primary genre (enum ref)"}
{:db/ident :game/platforms
:db/valueType :db.type/ref
:db/cardinality :db.cardinality/many
:db/doc "Available platforms (enum refs)"}
{:db/ident :game/max-players
:db/valueType :db.type/long
:db/cardinality :db.cardinality/one
:db/doc "Max concurrent players"}
{:db/ident :game/rating
:db/valueType :db.type/double
:db/cardinality :db.cardinality/one
:db/doc "Average rating 0.0–5.0"}
{:db/ident :game/published?
:db/valueType :db.type/boolean
:db/cardinality :db.cardinality/one
:db/doc "Whether the game is published"}
{:db/ident :game/release-date
:db/valueType :db.type/instant
:db/cardinality :db.cardinality/one
:db/doc "Official release date"}
{:db/ident :game/price
:db/valueType :db.type/bigdec
:db/cardinality :db.cardinality/one
:db/doc "Base price"}
{:db/ident :game/currency
:db/valueType :db.type/string
:db/cardinality :db.cardinality/one
:db/doc "ISO 4217 currency code for the base price"}
{:db/ident :game/external-id
:db/valueType :db.type/uuid
:db/cardinality :db.cardinality/one
:db/unique :db.unique/identity
:db/doc "External UUID"}
{:db/ident :game/website
:db/valueType :db.type/uri
:db/cardinality :db.cardinality/one
:db/doc "Official website URL"}
{:db/ident :game/tags
:db/valueType :db.type/string
:db/cardinality :db.cardinality/many
:db/doc "Freeform tags"}
{:db/ident :game/version
:db/valueType :db.type/tuple
:db/tupleTypes [:db.type/long :db.type/long :db.type/long]
:db/cardinality :db.cardinality/one
:db/doc "Current version [major minor patch]"}
{:db/ident :game/achievements
:db/valueType :db.type/ref
:db/cardinality :db.cardinality/many
:db/doc "Achievements in this game"}
{:db/ident :game/prices
:db/valueType :db.type/ref
:db/cardinality :db.cardinality/many
:db/isComponent true
:db/doc "Regional pricing (component)"}]
;; ---- Regional Price (Component Entity) ----
[{:db/ident :price/region
:db/valueType :db.type/string
:db/cardinality :db.cardinality/one
:db/doc "Region code"}
{:db/ident :price/amount
:db/valueType :db.type/bigdec
:db/cardinality :db.cardinality/one
:db/doc "Price amount"}
{:db/ident :price/currency
:db/valueType :db.type/string
:db/cardinality :db.cardinality/one
:db/doc "ISO 4217 currency code"}]
;; ---- Achievement ----
[{:db/ident :achievement/name
:db/valueType :db.type/string
:db/cardinality :db.cardinality/one
:db/unique :db.unique/identity
:db/doc "Unique achievement name"}
{:db/ident :achievement/description
:db/valueType :db.type/string
:db/cardinality :db.cardinality/one
:db/doc "How to earn this achievement"}
{:db/ident :achievement/points
:db/valueType :db.type/long
:db/cardinality :db.cardinality/one
:db/doc "Points awarded"}]
;; ---- Play Session ----
[{:db/ident :session/user
:db/valueType :db.type/ref
:db/cardinality :db.cardinality/one
:db/doc "Who played"}
{:db/ident :session/game
:db/valueType :db.type/ref
:db/cardinality :db.cardinality/one
:db/doc "What game was played"}
{:db/ident :session/status
:db/valueType :db.type/ref
:db/cardinality :db.cardinality/one
:db/doc "Session status (enum ref)"}
{:db/ident :session/started-at
:db/valueType :db.type/instant
:db/cardinality :db.cardinality/one
:db/doc "When the session started"}
{:db/ident :session/ended-at
:db/valueType :db.type/instant
:db/cardinality :db.cardinality/one
:db/doc "When the session ended"}
{:db/ident :session/duration-minutes
:db/valueType :db.type/long
:db/cardinality :db.cardinality/one
:db/doc "Duration in minutes"}
{:db/ident :session/score
:db/valueType :db.type/long
:db/cardinality :db.cardinality/one
:db/doc "Score achieved"}
{:db/ident :session/platform
:db/valueType :db.type/ref
:db/cardinality :db.cardinality/one
:db/doc "Platform used (enum ref)"}]
;; ---- Leaderboard (Composite Tuple) ----
[{:db/ident :leaderboard/user
:db/valueType :db.type/ref
:db/cardinality :db.cardinality/one
:db/doc "Leaderboard user"}
{:db/ident :leaderboard/game
:db/valueType :db.type/ref
:db/cardinality :db.cardinality/one
:db/doc "Leaderboard game"}
{:db/ident :leaderboard/season
:db/valueType :db.type/string
:db/cardinality :db.cardinality/one
:db/doc "Season identifier"}
{:db/ident :leaderboard/high-score
:db/valueType :db.type/long
:db/cardinality :db.cardinality/one
:db/doc "Highest score"}
{:db/ident :leaderboard/user+game+season
:db/valueType :db.type/tuple
:db/tupleAttrs [:leaderboard/user :leaderboard/game :leaderboard/season]
:db/cardinality :db.cardinality/one
:db/unique :db.unique/identity
:db/doc "Composite unique key"}]Part 15: Sample Data—A Complete Dataset #
Here is a complete set of sample transactions that populate the database with realistic game studio data.
;; ---- Users ----
[{:user/username "alice_gamer"
:user/email "alice@example.com"
:user/display-name "Alice"
:user/active? true
:user/role :user.role/player
:user/joined-at #inst "2024-01-15T00:00:00.000Z"
:user/external-id #uuid "a1a1a1a1-0000-0000-0000-000000000001"}
{:user/username "bob_admin"
:user/email "bob@example.com"
:user/display-name "Bob the Builder"
:user/active? true
:user/role :user.role/admin
:user/joined-at #inst "2023-06-01T00:00:00.000Z"
:user/external-id #uuid "b2b2b2b2-0000-0000-0000-000000000002"}
{:user/username "charlie_dev"
:user/email "charlie@studio.example.com"
:user/display-name "Charlie"
:user/active? true
:user/role :user.role/developer
:user/joined-at #inst "2022-11-20T00:00:00.000Z"
:user/external-id #uuid "c3c3c3c3-0000-0000-0000-000000000003"}]
;; ---- Friends (unidirectional) ----
[{:user/username "alice_gamer"
:user/friends #{[:user/username "bob_admin"]}}
{:user/username "bob_admin"
:user/friends #{[:user/username "alice_gamer"]
[:user/username "charlie_dev"]}}]
;; ---- Games with nested component prices ----
[{:game/title "Dragon's Quest Online"
:game/description "An epic MMORPG set in a fantasy realm"
:game/genre :game.genre/mmorpg
:game/platforms #{:game.platform/pc
:game.platform/playstation
:game.platform/xbox}
:game/max-players 5000
:game/rating 4.5
:game/published? true
:game/release-date #inst "2025-06-15T00:00:00.000Z"
:game/price 49.99M
:game/currency "USD"
:game/website #uri "https://dragons-quest.example.com"
:game/tags #{"mmorpg" "fantasy" "open-world" "multiplayer"}
:game/version [2 3 1]
:game/external-id #uuid "d4d4d4d4-0000-0000-0000-000000000004"
:game/prices [{:price/region "US" :price/amount 49.99M :price/currency "USD"}
{:price/region "EU" :price/amount 44.99M :price/currency "EUR"}
{:price/region "JP" :price/amount 5980M :price/currency "JPY"}
{:price/region "UK" :price/amount 39.99M :price/currency "GBP"}]
:game/achievements [{:achievement/name "First Blood"
:achievement/description "Defeat your first enemy"
:achievement/points 10}
{:achievement/name "Dragon Slayer"
:achievement/description "Defeat the final dragon boss"
:achievement/points 100}
{:achievement/name "Social Butterfly"
:achievement/description "Add 10 friends"
:achievement/points 25}]}
{:game/title "Puzzle Planet"
:game/description "A mind-bending puzzle game"
:game/genre :game.genre/puzzle
:game/platforms #{:game.platform/mobile
:game.platform/nintendo-switch}
:game/max-players 1
:game/rating 4.2
:game/published? true
:game/release-date #inst "2026-01-10T00:00:00.000Z"
:game/price 9.99M
:game/currency "USD"
:game/tags #{"puzzle" "casual" "brain-teaser"}
:game/version [1 0 5]
:game/external-id #uuid "e5e5e5e5-0000-0000-0000-000000000005"
:game/achievements [{:achievement/name "Eureka!"
:achievement/description "Solve the first 10 puzzles"
:achievement/points 15}]}]
;; ---- Play Sessions ----
[{:session/user [:user/username "alice_gamer"]
:session/game [:game/title "Dragon's Quest Online"]
:session/status :session.status/completed
:session/started-at #inst "2026-03-14T18:00:00.000Z"
:session/ended-at #inst "2026-03-14T20:30:00.000Z"
:session/duration-minutes 150
:session/score 18500
:session/platform :game.platform/pc}
{:session/user [:user/username "alice_gamer"]
:session/game [:game/title "Puzzle Planet"]
:session/status :session.status/completed
:session/started-at #inst "2026-03-15T09:00:00.000Z"
:session/ended-at #inst "2026-03-15T09:45:00.000Z"
:session/duration-minutes 45
:session/score 3200
:session/platform :game.platform/mobile}
{:session/user [:user/username "bob_admin"]
:session/game [:game/title "Dragon's Quest Online"]
:session/status :session.status/active
:session/started-at #inst "2026-03-15T14:00:00.000Z"
:session/platform :game.platform/playstation}]
;; ---- User Achievements ----
[{:user/username "alice_gamer"
:user/achievements #{[:achievement/name "First Blood"]
[:achievement/name "Dragon Slayer"]
[:achievement/name "Eureka!"]}}
{:user/username "bob_admin"
:user/achievements #{[:achievement/name "First Blood"]}}]
;; ---- Leaderboard Entries ----
[{:leaderboard/user [:user/username "alice_gamer"]
:leaderboard/game [:game/title "Dragon's Quest Online"]
:leaderboard/season "2026-Q1"
:leaderboard/high-score 25000}
{:leaderboard/user [:user/username "bob_admin"]
:leaderboard/game [:game/title "Dragon's Quest Online"]
:leaderboard/season "2026-Q1"
:leaderboard/high-score 12000}
{:leaderboard/user [:user/username "alice_gamer"]
:leaderboard/game [:game/title "Puzzle Planet"]
:leaderboard/season "2026-Q1"
:leaderboard/high-score 5600}]Part 16: Concept Comparison—SQL vs Datomic Cheat Sheet #
| SQL Concept | Datomic Equivalent |
|---|---|
CREATE TABLE |
Transact attribute definitions |
| Column | Attribute (:db/ident) |
| Column type | :db/valueType |
NOT NULL |
Simply assert the attribute (no nulls in Datomic) |
NULL |
Absence of a datom |
AUTO_INCREMENT / SERIAL |
Automatic entity IDs + optional d/squuid |
UNIQUE constraint |
:db/unique (:db.unique/identity or :db.unique/value) |
FOREIGN KEY |
:db.type/ref attribute |
ON DELETE CASCADE |
:db/isComponent true |
ENUM type |
Entities with :db/ident |
| Junction table | Cardinality-many refs |
| Composite unique index | Composite tuple with :db.unique/identity |
ALTER TABLE ADD COLUMN |
Transact a new attribute |
ALTER TABLE DROP COLUMN |
Not possible (stop using the attribute) |
ALTER COLUMN TYPE |
Not possible (create new attribute, migrate data) |
INSERT |
d / transact = with map form |
UPDATE |
d/transact with same entity ID (cardinality/one replaces) |
DELETE |
:db/retractEntity or :db/retract |
SELECT ... AS OF |
d/as-of database filter |
SELECT ... FROM audit_log |
d/history database filter |
CHECK constraint |
:db.attr/preds |
| Table-level constraint | Entity specs |
Part 17: Design Principles and Best Practices #
1. Namespace Your Attributes #
Always use namespace-qualified keywords. The namespace acts as a logical grouping (similar to a table name) but without the rigidity:
:user/username (not :username)
:game/title (not :title)
:session/score (not :score)
2. Prefer Growing Over Changing #
Datomic’s schema model rewards additive growth. When requirements change, add new attributes rather than trying to modify existing ones.
3. Model Facts, Not Rows #
Think of each datom as an independent fact: “Entity 42 has username alice_gamer.” This mental model frees you from table-think and lets entities organically accumulate attributes over time.
4. Use Enums for Controlled Vocabularies #
Any time you have a fixed set of domain values, model them as enum entities with :db/ident. They are memory-resident, fast to query, and easy to extend.
5. Refs Are Always Bidirectional #
Never create a “back reference” attribute. If :session/game points from session to game, use :session/_game to navigate from game to sessions.
6. Use Components for Owned Entities #
If a child entity has no meaningful existence without its parent, make it a component. This gives you cascade deletion and automatic nested pull.
7. Choose :db.unique/identity for Upsert Semantics
#
If you want transactions to “find or create” by a unique attribute (like a username), use :db.unique/identity. This is one of Datomic’s most powerful features for idempotent data loading.
8. Monetary Values Always Use BigDecimal #
Never use :db.type/double or :db.type/float for money. Use :db.type/bigdec to avoid floating-point rounding errors.
9. Consider :db/noHistory for High-Churn Data
#
Attributes that change very frequently (positions, counters, heartbeats) and whose historical values are not useful should set :db/noHistory true to reduce storage.
10. Plan Your Value Types Carefully #
Since :db/valueType cannot be changed after creation, give thoughtful consideration to each attribute’s type before transacting it into your schema.
Appendix A: Quick Reference — Attribute Definition Keys #
| Key | Type | Required? | Description |
|---|---|---|---|
:db/ident |
Keyword | Yes | Unique name of the attribute |
:db/valueType |
Ref (enum) | Yes | Data type (:db.type/string, etc.) |
:db/cardinality |
Ref (enum) | Yes | :db.cardinality/one or :db.cardinality/many |
:db/doc |
String | No | Documentation string |
:db/unique |
Ref (enum) | No | :db.unique/identity or :db.unique/value |
:db/isComponent |
Boolean | No | Cascade delete for ref attributes |
:db/noHistory |
Boolean | No | Opt out of historical retention |
:db.attr/preds |
List of symbols | No | Validation predicates |
:db/tupleAttrs |
Vector of keywords | No | Source attributes for composite tuples |
:db/tupleTypes |
Vector of types | No | Types for heterogeneous tuples |
:db/tupleType |
Keyword | No | Single type for homogeneous tuples |
Appendix B: Entity Relationship Diagram #
Below is a textual representation of the game studio data model:
┌──────────────────────┐ ┌───────────────────────┐
│ User │ │ Game │
├──────────────────────┤ ├───────────────────────┤
│ username (unique/id)│ │ title (unique/id) │
│ email (unique/v) │ │ description │
│ display-name │ │ genre → enum │
│ active? │ │ platforms →→ enum │
│ role → enum │ │ max-players │
│ joined-at │ │ rating │
│ external-id (uuid) │ │ published? │
│ achievements →→ Ach. │ │ release-date │
│ friends →→ User │ │ price (bigdec) │
└──────────┬───────────┘ │ currency │
│ │ website (uri) │
│ │ tags (many strings) │
│ │ version (tuple) │
│ │ achievements →→ Ach. │
│ │ prices ◆→→ Price │
│ └──────────┬────────────┘
│ │
┌─────┴──────────────────────┐ │
│ Play Session │ │
├────────────────────────────┤ │
│ user → User │ │
│ game → Game ├──────┘
│ status → enum │
│ started-at │
│ ended-at │
│ duration-minutes │
│ score │
│ platform → enum │
└────────────────────────────┘
┌────────────────────────┐ ┌───────────────────────┐
│ Achievement │ │ Price (component) │
├────────────────────────┤ ├───────────────────────┤
│ name (unique/id) │ │ region │
│ description │ │ amount (bigdec) │
│ points │ │ currency │
└────────────────────────┘ └───────────────────────┘
┌──────────────────────────────────────┐
│ Leaderboard │
├──────────────────────────────────────┤
│ user → User │
│ game → Game │
│ season │
│ high-score │
│ user+game+season (composite unique) │
└──────────────────────────────────────┘
Legend:
→ = :db.type/ref, cardinality/one
→→ = :db.type/ref, cardinality/many
◆→→ = :db.type/ref, cardinality/many, isComponent
Appendix C: Further Reading #
- Schema Data Reference — Official Datomic schema specification
- Data Modeling — Official patterns for modeling data in Datomic
- Identity and Uniqueness — Unique constraints, lookup refs, squuids
- Best Practices — Schema growth principles and design guidelines
- Changing Schema — What can and cannot be changed after creation
- Component Entities — Deep dive into component modeling
- Tuples and Database Predicates — Tuple types and validation