Building a TODO List App with Datomic Pro - [Part 1]

Are you looking for a way to get started learning Datomic, or are you picking Datomic up again after some time away from it? If so, this tutorial is for you. By building a straightforward TODO list app with Clojure and Datomic, we will identify the mental model of Datomic and demonstrate how to use its theoretical insights in a practical context. Your journey through the tutorial will equip you with the skills to continue exploring Datomic on your own. You will learn key characteristics of what makes Datomic special:

As we go along, we will primarily reference docs.datomic.com and list other resources for further reading and learning and the end of each part of the tutorial.

Datomic todo-app

Who is this Tutorial For?

This tutorial is designed for Clojure and Datomic beginners or someone picking up Datomic again. If you are totally new to Clojure, you should seek out some more introductory content before coming back here.

This guide aims to provide you with the skills and knowledge to get Datomic’s mental model and gain confidence to continue explore Datomic by yourself.

Objectives of the Tutorial

By the end of this tutorial, you will:

  1. Installation: Download the software, run a transactor and restore a backed up database.
  2. Create a basic Clojure project and connect to a running Datomic: By the end of the tutorial you’ll have a simple Clojure project with Datomic running.
  3. How Datomic models data: Through the tutorial we’ll start from explaining the concept of datom and how it’s a crucial thing for Datomic.
  4. How run Datomic queries: Explore some of the api’s to execute queries and the basics of the Datalog engine.
  5. Universal Schema in Datomic: How Datomic treats data as first class and how to add new schemas to an existing database.
  6. Transact data to Datomic: Transact new data to the database.
  7. Build a toy app: Build a simple application with some requirements using Datomic.
  8. Time in Datomic: Datomic is an evolving database, new facts occur as time goes on but that doesn’t mean that older facts no longer exist. By the end of the tutorial you’ll have more clear understanding on how Datomic models time and how to leverage it to make queries in different moments of the database life.

This structured approach ensures you gain both theoretical and practical insights, making it easier to gain confidence to continue explore Datomic.

Technology Requirements

In a new terminal, verify that the requirements are installed properly by running the following commands.

clojure --version
;; Output
Clojure CLI version 1.12.0.1479 ;; the version might be different in your machine.

It's recommended to install the latest stable version of Clojure.

java --version
;; Output
java 21.0.4 2024-07-16 LTS

It should work with Java 11+.

What is a Datomic database?

A Datomic database is a single universal relation of facts called datoms. Datoms can be either assertions indicating that something is known to be true at a point in time or retractions indicating that something is no longer known to be true. A fact is something that occurred, time is part of the definition thus we can reason about the database with the time at hand. "At this moment of time my database is this value", the queries I make will return the same result until I pass a different value of the database. Comparing it to a server oriented database, things could have changed between query one and query two, because we are asking questions in terms of a place, depends on what's in the place when you ask.

Datomic is a database that is fundamentally different, as we go over the tutorial we'll exemplify what is stated above. The docs website contains a very complete explanation on the architecture of Datomic, the tutorial will only explain some concepts briefly and it's encouraged to complement them reading the documentation.

Quick Intro to Datomic's Data Modelling

Data is modeled as entities, attributes, values, transaction, and op information all in a single tuple [entity, attribute, value, transaction op]. In Datomic this is called a “datom” (An atomic fact in a database).

We’ll refer to this tuple as [e a v t op].

Given the following map

(def db-example
  [{:list/name "life"
    :list/items [{:item/text "travel"
                  :item/status :item.status/:todo}
                 {:item/text "buy coffee"
                  :item/status :item.status/:todo}]}
   {:list/name "learn"
    :list/items [{:item/text "clojure"
                  :item/status :item.status/:todo}
                 {:item/text "datomic"
                  :item/status :item.status/:todo}]}])

we can represent it in a Datomic database like this;

datoms table example

values in the image are simplified

Datomic is a single table that accumulates datoms, the "e" position is the entity identifier, all of the datoms that have the 45418 in the "e" relate to the same entity, we call this number eid. The "a" is self explanatory, refers to the attribute we are talking about. The "v" has the value of such attribute. The "t" is the transaction, it tells when the datom was persited and it's possible that many datoms share the same "t", in the example we se the number 34321 in 6 places, that tells us that those 6 datoms were transacted at the same moment. Lastly, "op" is a boolean that tells if that datom remains true, we will talk more about this when introducing retraction, to briefly explain it, when the "op" is false means that the "v" is no longer a reality.

Datomic makes first-class relationships

The arrows ilustrate that number 45422 points to the "travel" datom and that 45427 points to the "datomic" datom. Datomic provides these references for users to make the relationships.

Attribute "a" can be defined as cardinality one or cardinality many. "One" means only one datom for a given eid can be true or absence of value. "Many" means that multiple datoms for a given eid can hold the true "op", :list/items is an example of that, we see [45418 :list/items 45422 344317 true] and [45418 :list/items 45424 344319 true]. In these :list/items datoms we have number 45422 and number 45424 in the "v" position, both are part of the "life" list.

What about the “t”?

When data is transacted to the database Datomic will create a t datom that will contain the time information about when those “datoms” were added. Datomic will place it in the “t” spot of [e a v t]

Building the App

We want to build an app that will do everything we typically expect of a TODO app (create list items, mark items completed, delete items), plus a couple of powerful features that Datomic makes it trivial to enable them. For example, have you ever wanted add some time filters so that you could ask your app, "How was my life yesterday?" and display the evolution of the statuses of an item, a history log of changes. In Part 1, we'll focus on the foundation that will allow us to add more features as we go.

Parts

  1. Install Datomic and explore it using the REPL
  2. Create an HTTP server and render a simple UI
  3. Create, Read, Update, Delete - Lists and Items
  4. Add filters, time based, by status and display the history of status transitions
  5. Deploy application to the web

Technologies

We'll keep dependencies small.

Project Structure

Choose a place in your file system to create a new project, in this tutorial we’ll use the path when a new terminal is open. We’ll follow this structure.

.
├── deps.edn
└── src
    └── todo_db.clj

How to follow the code blocks?

The tutorial focuses in explaining key concepts about Datomic and exemplifying them with code examples that run in the Clojure REPL. When there is a code block with ;; REPL comment at the top it means that the code should run the the REPL. If it doesn't have that comment it's just an example to assist an explanation.

Code Examples Repository

The code can be found in datomic-tutorials

Installation

Open a new terminal and download datomic-pro

curl https://datomic-pro-downloads.s3.amazonaws.com/1.0.7277/datomic-pro-1.0.7277.zip

unzip the file

unzip datomic-pro-1.0.7277.zip -d .

Run Transactor with Default Properties

The transactor is a process with the ability to commit transactions for a given database.

In the same terminal, run the following command to start the transactor:

datomic-pro-1.0.7277/bin/transactor config/samples/dev-transactor-template.properties
;; Output
Launching with Java options -server -Xms1g -Xmx1g -XX:+UseG1GC -XX:MaxGCPauseMillis=50
System started

Now that we have a running transactor we can start working on the code.

REPL Explorations

Let's do some REPL exploration and get some practice with Datomic essentials:

  1. Setup a connection to Datomic using the Clojure library
  2. Define and install schema
  3. Transact novel data
  4. Examples using query

Add the following to deps.edn.

{:deps {org.clojure/clojure {:mvn/version "1.12.0"}
        com.datomic/peer {:mvn/version "1.0.7277"}
        io.pedestal/pedestal.jetty {:mvn/version "0.7.1"}
        org.slf4j/slf4j-simple {:mvn/version "2.0.10"}
        hiccup/hiccup {:mvn/version "2.0.0-RC3"}}}

For now, we'll focus in the com.datomic/peer library. The rest will come handy when building the browser UI.

Define and transact schemas

Schemas follow the same rules as transacting any other data, they are also datoms.

NamePurposeRequired?
:db/identspecifies a unique programmatic name for an entity (normally a schema entity)Required for schema entities
:db/cardinalityspecifies whether an attribute associates a single value or a set of valuesRequired
:db/valueTypespecifies the type of value that can be associated with an attributeRequired
:db/uniquespecifies a uniqueness constraint for the values of an attributeOptional

To know more, take a look at the full list of attributes available for schemas in the official docs.

Let's define the uses cases and properties of the application and then create a schema that matches the requirements.

  1. Group todo items to a list and be able to have multiple lists
  2. Lists and items can be created and deleted
  3. Items should be ordered (e.g last created at)
  4. Todo items need to have an status (e.g done)
  5. Item status can be updated

The relationship can be, "lists have many items", in other words the cardinality of items in terms of list is "many".

A Word On Refs

When :db.type/ref is defined, the reference can be to any other entity in the database, Datomic doesn't restrict to reference only to a specific entity, that's a bussiness domain defined in the application.

;; REPL
(def schema
  [{:db/ident       :list/name
    :db/valueType   :db.type/string
    :db/cardinality :db.cardinality/one
    :db/unique      :db.unique/identity
    :db/doc         "List name"}
   {:db/ident       :list/items
    :db/valueType   :db.type/ref;; reference
    :db/cardinality :db.cardinality/many
    :db/doc         "List items reference"}
   {:db/ident       :item/status
    :db/valueType   :db.type/keyword
    :db/cardinality :db.cardinality/one
    :db/doc         "Item Status"}
   {:db/ident       :item/text
    :db/valueType   :db.type/string
    :db/cardinality :db.cardinality/one
    :db/doc         "Item text"}])

Learn more about defining schema in Datomic here

To transact a schema you need to:

  1. Define the db-uri
    ;; REPL
    (def db-uri "datomic:dev://localhost:4334/todo")
    
  2. Create the database
    ;; REPL
    (d/create-database db-uri)
    
  3. Establish a connection
    ;; REPL
    (def conn (d/connect db-uri))
    
  4. Transact the schema with d/transact.
    ;; REPL
    @(d/transact conn schema)
    

d/transact returns a promise, we use @ to deref it

With the schema transacted we are able to store some lists and items.

How does Datomic transacts data?

Datomic represents transaction data as data structures. This is a significant difference from SQL databases, where requests are submitted as strings. Using data instead of strings makes it easier to build requests programatically. Remember that a datom is [e a v t], when transacting data we need to provide the first 3 and Datomic will create the “t”. Datomic has two forms of structures, list and maps.

This is the structure of a list form [op entity-id attribute value]

(d/transact conn [[:db/add "42" :list/name "life"]])

The map form is a convenient shorthand when making several assertions about the same entity. The map has an optional :db/id key identifying the entity, plus any number of attribute/value pairs. When creating a new entity we can exclude the :db/id and Datomic will generate a new one.

(d/transact conn [{:list/name "life"}])

Transact a New List

Now transact a new list with name "life"

;; REPL
@(d/transact conn [{:list/name "life"}])

;; Result
{:db-before datomic.db.Db@c5277e1, 
 :db-after datomic.db.Db@b415356f, 
 :tx-data
  [#datom[13194139534316 50 #inst "2025-03-25T19:54:47.185-00:00" 13194139534316 true]
   #datom[17592186045421 72 "life" 13194139534316 true]],
 :tempids {-9223300668110598127 17592186045421}}

The result of a transaction contains;

The tx-report enables straight forward comparisons before and after data is transacted. We can use the :db-before to make queries to the past. Remember, with Datomic the db's are values and we can pass those values around, queries will return the same result if it's executed to the same db value. Db values don't mutate, you can take your time and process them, there is no need to lock the world.

To showcase a quick example of that, create another list learn, but this time we are going to save the result in a var and then access to the result values.

;; REPL
(def result @(d/transact conn [{:list/name "learn"}])) ;; new list
(def my-list-q
  '[:find ?list
    :in $
    :where [?list :list/name "learn"]])

(d/q my-list-q (:db-after result)) ;; IMPORTANT line
;; Result
#{[17592186045423]} ;; the result number might be different for you, it's okay.
;; REPL
(d/q my-list-q (:db-before result))
;; Result
#{}

With (def result @(d/transact conn [(new-list "learn")]), you can run the same query with (:db-after result) or (:db-before result), the first returns the eid of the list in the database, the second is empty because at that point in time the list didn't existed.

This is a simple example of the out-of-the-box support for making queries at different moments of time by leveraging a database as a value rather than a connection.

learn more about Datomic time model

Transact New Items

With a list transacted, let's start adding some items. We'll follow the same pattern: create a function new-item that receives a list-name and the todo string. We are also including the db as we want to get the eid of the list to make the correct relationship.

Add new-item function

;; REPL
(defn new-item [db list-name item-text]
  {:db/id (d/entid db [:list/name list-name])
   :list/items [{:db/id (d/tempid :db.part/user)
                 :item/text item-text
                 :item/status :item.status/todo}]})

To get the eid of an entity we can use d/entid function that receives a db and a lookup ref. We pass the eid to tell Datomic that we want to do an assertion over an existing entity, add a new item. Notice that for the :db/id inside :list/items we make use of tempids by calling the function d/tempid. This is because we are creating a new nested entity. Datomic requires that a nested map either be referenced by a component attribute or include a unique attribute. This constraint prevents the accidental creation of easily-orphaned entities that have no unique identity or relation to other entities.

;; REPL
@(d/transact conn [(new-item (d/db conn) "life" "travel")])

let's populate the life list with more data to run some exploration queries.

;; REPL
(->> ["play drums" "scuba dive" "buy coffee"]
     (map (partial new-item (d/db conn) "life"))
     (d/transact conn))

Add items to the learn list.

;; REPL
(->> ["clojure" "datomic" "sailing" "cook rissotto"]
     (map (partial new-item (d/db conn) "learn"))
     (d/transact conn))

Retract Items

An addition indicates that a datom is true at a point in time, and a retraction indicates that a datom is not true at a point in time. When adding a retraction, it is persiting a datom with the "op" as false, that tells Datomic that the fact can be ignore for the current present, unless explicitly asking to include datoms that are false, we will cover more on this on part 4 when talking about filters.

retract the "buy coffee" item from the "life" list.

;; REPL
(def eid (ffirst
           (d/q '[:find ?item
                  :in $
                  :where [?item :item/text "buy coffee"]]
                (d/db conn)))
@(d/transact conn [[:db/retract eid :item/text]])

datoms table example

to ilustrate the accumulation of datoms in the table we can see that [45424 :item/text "buy coffee" 34322 false] is added. It is placed in that position because Datomic maintains four covering indexes that contain ordered sets of datoms. The picture shows EAVT, which is sorted first by "E" then "A" then "V" and finaly "T", that's why we see the descending ordering in the A column.

How does Datomic queries data?

Datomic uses Datalog as query engine. A query finds values in a database subject to the given constraints, and is specified as edn. Queries are modeled following the same pattern of a datom [e a v t], if we understand this structure queries can become very powerful.

Currently we transacted 2 lists and a few items. Let's start with some simple queries to get things going.

The query engine runs locally in the application server, queries execute with a local db value not against a connection. You can hold the value as much time as you want and the result will always be the same. Running queries through a connection doesn't guarantee that you get the same value every time.

Get lists and their names

;; REPL
(d/q '[:find ?list-name
       :in $
       :where [?list :list/name ?list-name]]
  (d/db conn))
;; Result
#{["life"] ["learn"]}

Get lists + items

;; REPL
(d/q '[:find ?list-name ?items
       :in $
       :where [?list :list/name ?list-name]
              [?list :list/items ?items]]
  (d/db conn))
;; Result
#{["life" 17592186045422] ["life" 17592186045426] ["life" 17592186045425]
  ["life" 17592186045424] ["learn" 17592186045428] ["learn" 17592186045429]
  ["learn" 17592186045430] ["learn" 17592186045431]}

A set of vectors repeating the list name? similar to Clojure, at first glance looks different and it's because it is a different way to interact with a database. Let's break it down, first thing we see is the repetition of the list name, e.g ["life" 17592186045425] and the same case for "learn". Our schema is defined as :db.cardinality/many on the :list/items attribute, in other words we are allowing many items being referenced by :list/items, that makes the result make sense, it's telling us that the list "life" has many items, each one being a reference.

Get lists + items different version

;; REPL
(d/q '[:find ?list-name (vec ?items)
       :in $
       :where [?list :list/name ?list-name]
              [?list :list/items ?items]]
     (d/db conn))
;; Result
[["learn" [17592186045428 17592186045429 17592186045430 17592186045431]]
 ["life" [17592186045422 17592186045426 17592186045425 17592186045424]]]

The difference is (vec ?items) which is grouping all of the items of a list. Also the items are numbers and that is because we are just pulling the reference number to the entity (eid), if we want to get the attributes of the Item we need to ask that explicitly.

Get lists + items using pull

Entities do not exist on their own, entities are an association of datoms.

In this section we will introduce the expression pull. Pull is a declarative way to make hierarchical (and possibly nested) selections of information about entities. Pull applies a pattern to a collection of entities, building a map for each entity.

;; REPL
(d/q '[:find (pull ?list [:list/name {:list/items [:item/text]}])
       :in $
       :where [?list :list/name ?list-name]]
     (d/db conn))
;; Result
[[#:list{:name "life"
         :items [#:item{:text "travel"}
                 #:item{:text "play drums"}
                 #:item{:text "scuba dive"}]}
  [#:list{:name "learn"
          :items [#:item{:text "clojure"}
                  #:item{:text "datomic"}
                  #:item{:text "sailing"}
                  #:item{:text "cook rissotto"}]}]]]

Order of Items

As a simplification, we will order items by their entity eid, which corresponds to transaction order (when only one item is added per transaction.) If different sorts are desired, you can sort the items "in post query", using normal Clojure code, or you could assert additional attributes about list items to facilitate different sorts.

Connecting the dots

We have:

With some embellishment of the code, we can have this initial src/todo_db.clj file

(ns todo-db
  (:require
   [datomic.api :as d]))

(def schema
  [{:db/ident       :list/name
    :db/valueType   :db.type/string
    :db/cardinality :db.cardinality/one
    :db/unique      :db.unique/identity
    :db/doc         "List name"}
   {:db/ident       :list/items
    :db/valueType   :db.type/ref;; reference
    :db/cardinality :db.cardinality/many
    :db/doc         "List items reference"}
   {:db/ident       :item/status
    :db/valueType   :db.type/keyword
    :db/cardinality :db.cardinality/one
    :db/doc         "Item Status"}
   {:db/ident       :item/text
    :db/valueType   :db.type/string
    :db/cardinality :db.cardinality/one
    :db/doc         "Item text"}])

(def db-uri "datomic:dev://localhost:4334/todo")

;; INFO: Creates the database, if it does not exist returns false
(d/create-database db-uri) ;; it requires a running transactor

;; INFO: to delete a database use `d/delete-database`
(comment (d/delete-database db-uri))

;; Connect to the database
(def conn (d/connect db-uri))

(comment @(d/transact conn schema))

(defn new-list 
  "receives a `list-name` and returns a new List datom."
  [list-name]
  {:list/name list-name})

(defn new-item
  "recives a `db` a `list-name` and the `item-text` and
   returns a map form of datoms to add items to a list."
  [db list-name item-text]
  {:db/id (d/entid db [:list/name list-name])
   :list/items [{:db/id (d/tempid :db.part/user)
                 :item/text item-text
                 :item/status :item.status/todo}]})

References

Published: 2025-04-30

Tagged: datomic-pro tutorial todo-app