«

Composing Transactions, By Example

Example 1

Imagine that you are building a database that records the following two actions that a person might perform:

  1. A person can purchase a house, but only while living.
  2. A person can get married, but only while living.

In addition, your stakeholder is considering an additional requirement that these records include audit data that must be generated by the system. To record these actions, you might use Datomic’s transactions, transaction functions, and entity predicates, which have the following semantics:

  1. A transaction invokes a function that takes the start-of-transaction database value (db-before) and transaction data, and produces a new database value (db-after). Transaction data can include both primitives (requests for assert and retract) and data requesting transaction functions.
  2. Datomic calls transaction functions with db-before plus user arguments, returning more transaction data, recursively until all that remains are primitives. The union of these primitives is then applied in an atomic operation to db-before, producing db-after.
  3. Transaction data can also include special assertions (db/ensure) naming entity predicates. These predicates are invoked against db-after and an entity id, and composed via logical and, i.e. all entity predicates must succeed or the transaction aborts.

There are many ways to record the two actions. Here are some examples:

  1. Use two transaction functions, one for each action. If the person is living as of db-before, the transaction function returns data to record the marriage/purchase plus the system audit data. If the person is dead, the transaction function calls cancel to abort the transaction.
  2. Transact the data for each action along with two entity predicates, one for each action. These can check db-after (or db-before!) to verify that a person is/was living, and return false to abort the transaction if the person is/was dead.
  3. Use a combination of transaction functions and entity predicates.

If you think about the difference between these three approaches, you may decide the original requirements were imprecise, e.g.:

  1. Can a person get married and purchase a house in a single transaction?
  2. Can a person’s death be recorded in the same transaction as a marriage or house purchase?

Comparing the three approaches:

  Only tx functions Only entity predicates Tx functions and entity predicates
Can a person get married and buy a house in the same transaction? Must be yes, transaction functions produce data that is composed by set union Can allow or disallow via entity predicates Can allow or disallow via entity predicates
Can a person’s death be recorded in the same transaction as a marriage or house purchase? Must be yes, transaction functions produce data that is composed by set union Can allow or disallow via entity predicates Can allow or disallow via entity predicates
Generate the system audit data Transaction functions can generate the audit data Entity predicates can at best reject a transaction missing the audit data Transaction functions can generate the audit data

Before committing to one of these approaches, you might want to more precisely nail down the domain requirements. But instead of doing that, let’s make things more difficult by adding a meta-requirement: your system is evolving quickly. These two actions are the first of many, and there will be new ones coming every week. Moreover, the stakeholders sometimes realize that they got the rules for actions wrong, e.g. they initially prohibited users from taking two actions in the same transaction and later decided that multi-action transactions are ok.

Function composition to the rescue. Transaction functions and entity predicates are top-level entry points between transactions and your code. Each can be composed of smaller functions combined in different ways. Continuing the example, you plan the following functions:

  1. action-types takes a database and a transaction entity id, and returns a list of keywords naming the actions recorded in that transaction, e.g. :bought-house or :got-married.
  2. add-audit-data takes db-before and transaction data, uses an action-type to determine the type of action, and then calls a multimethod to generate action-specific audit data.
  3. is-alive? takes a database and entity-id and returns true if entity-id is a person currently living.

Note that these are all pure functions, free of side effects. Unlike SQL, there is no implicit database being mutated in the background – it is up to you to flow all relevant information via function args and returns. Here are a few examples showing how these functions can be composed to meet domain requirements:

If the stakeholder decides that audit data is required and that users can get married / buy a house / die all in the same transaction, you can implement get-married and buy-house as transaction functions that:

  1. Check is-alive?, aborting if the person is not alive in before-db
  2. Call add-audit-data based on the single action type implied by the transaction function’s name.

If instead the stakeholder decides that audit data is not needed and that users can perform only one action in a transaction, then you can invoke two entity predicates:

  1. is-alive? (which will now act against the after-db)
  2. one-action-only? calls action-types and returns false if it returns more than one.

Other variations on the initial requirements can be composed similarly.

Example 2

Next let’s consider a lower stakes example, the grant approval process. Here are the requirements:

  1. A grant can be approved, but only if it has not yet been approved or denied.
  2. A grant can be denied, but only if it has not yet been approved or denied.

This example is somewhat similar to the person example, except now the two transitions are definitively exclusive. This rules out approach one. Transaction function outputs are composed via union, which is inclusive not exclusive. The logical and of entity predicates is a good fit here, and there are a number of different ways you might compose them.

Takeaways

Datomic provides multiple powerful tools for composing transactions that enforce domain invariants:

  1. Datomic composes the data returned by transaction functions with set union.
  2. Datomic composes the data returned by entity predicates with logical and.
  3. Where useful, you can have more compositional control via function composition, calling helper functions from either transaction functions or entity predicates. It is then up to you to flow all needed data through function inputs and outputs.

Set operations, logical operations, and function chaining are all forms of composition, each useful in different contexts. As Example 1 demonstrated, it is up to you to use them in a combination that delivers the semantics you want. Failing to do so (e.g. by attempting to deliver the requirements of example 2 with transaction functions) is a design flaw that results in a system with meaningful, consistent semantics – just not the semantics you wanted.