Skip to main content

Datomic Schema Modeling: A Step-by-Step Guide

Bruno Bonacci
Author
Bruno Bonacci
Tech. Architect, Ex Apple, Distributed System Expert, High-Volume Systems, Low latency.
Table of Contents

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 namespace user/ is a convention, not enforcement.
  • No NULL: If a user does not have an email, you simply do not assert :user/email for 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/transact function.

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/long representing 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:

  1. Simple: Store amount and currency code as separate attributes (shown above).
  2. Per-region pricing: Use a component entity for each price point (covered in Part 6).
  3. 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:

  1. Use a tuple type (for small, fixed-size sequences).
  2. Model each item as a separate entity with an explicit ordinal attribute.
  3. 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
#

  1. Cascade retraction: When you retract the parent entity, all component children are automatically retracted.
  2. Nested pull: The pull API automatically follows component references, returning nested data.
  3. 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
#