Composing Transactions, by Example
Example 1
Imagine that you are building a database that records the following two actions that a person might perform:
- A person can purchase a house, but only while living.
- 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:
- 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. - 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 todb-before
, producingdb-after
. - 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:
- 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. - Transact the data for each action along with two entity predicates, one for each action. These can check
db-after
(ordb-before
) to verify that a person is/was living, and return false to abort the transaction if the person is/was dead. - 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.:
- Can a person get married and purchase a house in a single transaction?
- 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:
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
.add-audit-data
takes db-before and transaction data, uses anaction-type
to determine the type of action, and then calls a multimethod to generate action-specific audit data.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:
- Check
is-alive?
, aborting if the person is not alive inbefore-db
. - 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:
is-alive?
(which will now act against theafter-db
).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:
- A grant can be approved, but only if it has not yet been approved or denied.
- 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 logic of entity predicates is a good fit here, and there are some different ways you might compose them.
Takeaways
Datomic provides multiple powerful tools for composing transactions that enforce domain invariants:
- Datomic composes the data returned by transaction functions with set union.
- Datomic composes the data returned by entity predicates with logical and.
- 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.