I think your example is great! It is a small, simple and common flow, that quickly gets really complicated. I cannot consume the video right now, but the captions mentions transactions (as in transitions between consistent states), which is a key item in all this.
To try implement this, let’s go through the different functions in the flow you mention.
IsEmailInUse
- the only way to answer this would be to query all the email adresses stored in the system (preferably indexed in some way).
GeneratePasswordHash
- you claim this is impure - yes, but it has also other important properties that helps us here (this is highly implementation specific) but essentially you can run this function “anywhere” as long as you know the password to hash, and the result, before it is persisted, is not that important (you can generate a new hash if the previous one was lost).
InsertUser
needs to know if the user is already in the db.
InsertDefaultCollections
must know if the collections for the user is already created to be able to do the right thing.
The most naive way to solve this problem would be to:
- Lock the DB for all changes (and reads) but your own.
- Do the look ups nescessary to know what operations that really needs to be run.
- Apply the changes.
- Unlock the DB.
This is only possible in a single threaded system. When the database is on the other side of a vast “network ocean” (in terms of time to answer) and the system is undeterministic (the network can be down etc) and the process can crash and leave transactions open - locking the whole system.
Also, what happens if there is an error and the system crashes while in the transaction lock, etc etc (Kyle Kingsbury, aka Aphyr has made some serious testing of these things in things that claims to handle distributed systems well, and found serious flaws in many of them).
To gain reasonable performance we need to apply changes while other things are happening in the database. The major problem is the changes that could happen between the look ups and the application of changes (the transaction). But how?
We need to be able to read state and then create a transaction instruction. When applied, the transaction should be applied atomically, all or nothing, to guarantee the system to be in a consistent state. In practice this means that if there where a change in the database that affected our transaction, the transaction should fail, for instance there was already an email in the system when we was about to create that same one. When there is a break of consistency, we have to generate a new transaction instruction which takes in data from the state the system is in now.
If we ignore the external side effects for now, and only looks into the persistence layer, what would a possible solution be?
For IsEmailInUse
we could have some index in the process (a hashset or even a bloom filter) that made it possible to question if a certain email was availiable in the state we know. This could be cached in the application, but doesn’t have to, as long as we have can ask if an email exists or not and the know that if this situation changes, the transaction will fail and we can retry it later, eventually be correct.
For the transaction instructions to work correctly, the update statement must be able to essentially compare and swap to a previously empty email when applied. Please note that this is a less extensive requirement than to look all the emails in the database up to see if this one was already taken.
The hash of password can be precalculated and inserted, but fail together with the rest of the transaction if there is a breach in the other requirements which is enough for us as the flow is specified now.
The user - can be handled as with the email - if the preparing logic found that the user does not exist, it should be created, under the consistency check that the user is not already created when applying the transaction (this could be invalidated by the compare and swap of the email, and perhaps other things).
Default Collections - same thing - are there default collections. If the collections already exists, the instruction to create them should be omitted from the transaction instruction.
Essentally you add an extra step, a compilation of constraints if you want, that creates update instructions that can either succeed or fail and thereby cancelling the whole transaction.
With quite some work this can be extended to work over multiple transactions (sometimes called a “saga”). The saga handling should be able to recover from system crashes (as should the one-transaction flow described above do).
Side effects could actually be seen as similar to the transactions in the saga pattern, but with even more complicated retry logics.
OK, how to create this?
We need to be able to compile transaction rules that are quick to apply but still has atomic guarantee. The entity requesting the transaction must be made aware of the result (succees/fail) of the transaction to be able to make retries etc.
The most reasonable way to do this is to have some kind of transaction/change log, to be able to keep indexes (of any structure) up to date in our application server (this is what Datomic do!) and we need sane ways to query the data in unforeseen ways (to manually add various indexes as the need appears becomes unmaintainable).
Regarding mocking/testing/clean core. Datomic has an in-memory database which is very, very useful for testing and makes it easy to compile transaction instructions, which can be used to run the logics of the application.
To chain several transactions, this is called “Saga pattern” and is mentioned shortly in Tim Ewalds video on Reifying time in Datomic.
Side effects (that are not part of the transaction mechanisms/repeatable reads from database): You will have to represent the state of the side effect in an explicit way. You will need to figure out how to handle all the cases (including time outs/no answer) for the side effects and the transaction instruction compilation process must be able to handle al the possible cases.
There can be a need for rollbacks if, for instance we have a ledger system somewhere, and later fail a saga, then the already issued ledgers has to be reversed.
That’s all there is to it!
Unfortunately very few distributed applications models these things exhaustively and carefully enough, IMO. Distributed systems with consistency guarantees are hard to get right, for all the wrong reasons.