Reader Monad for DB Connection in TypeScript
MVC(S)
The Model-View-Controller is usually a standard pattern for building web applications. Whether the view is expressed as HTML or perhaps just plain JSON, it helps to structure the application in an elegant way.
The Model is a set of components responsible for representing the persistence layer, often in the form of some ORM classes. View maintains the recipe for generating the view given the model. Finally controller glues both parts together and deals with the other problems inherent in web development, such as parsing and interpreting query parameters.
Where does the business logic live? As long as the application is trivial, the model representation, combined with the power of ORMs, provides a powerful tool for manifesting the business domain. Then, the actual logic, either leaks to the controller or requires another component in the MVC pattern.
The service could be one of these components. The service sits between the controller and the model, either by contructing higher level model objects (non-ORM), or by simply enriching the ORM objects with an additional computation.
ACID
As the application grows, the services become complex, often forming a hierarchy. Assuming there’s still a database-backed model under the hood, all the services do is run a sequence of database queries to either read or mutate the state of the system.
async function controllerMethod() {
const res1 = await this.service1()
return this.service2(res1)
}
How can we ensure that a controller calling one or more services, maintains the transation boundary? We need to make all the components aware of the current state of the transaction. The current transaction state is usually just an instance of a database connection. And that’s the moment where an implementation detail of the model brutally leaks through the MVC(S) stack.
If you want to make a service transactional, it should accept a database connection as a parameter on every method of a service. This is inconvenient and tightly couples all of the web application code to a chosen database (+ probably a chosen ORM/similar).
async function controllerMethod() {
const tr = await dbPool.transact()
try {
const res1 = await this.service1(tr)
const res2 = await this.service2(tr, res1)
await tr.commit()
return res2
} catch {
await tr.rollback()
}
}
Another way to solve this problem via the state of the request - but the problem is equivalent - now we either inject the request or couple application code with a chosen web framework.
The approach suggested in this blog post is the monad way.
The Monad Way
The Reader monad is a well known pattern in the Haskell community to solve the problem of a shared environment between several components of the system.
The idea is quite simple:
- do not require the environment until you actually need it,
- use monad properties to compose functions that depends on the environment.
In our case, the environment is a database connection - either raw connection, or a transaction.
Point (1) above, is easier said than done, but in practice we can describe the whole computation process as a chain of lazily evaluated functions. Then, the final artefact should be a function that actually executes the program given the environment (database connection).
For TypeScript I have extracted the boilerplate into DBAction library, an example:
async function controllerMethod() {
return this.service1()
.flatMap(res => this.service2(res))
.transact(this.transactor)
}
Let’s break this down:
DBAction
DBAction is an actual reader monad for the database connection. What services return is not a Promise<Result> type (a promise of some kind of result), but DBAction<Result>. After the service is called this.service1(), nothing happens until we either run or transact the DBAction. The value is referentially transparent, so if you call this.service1 with the same input, you’ll always get the same result.
The cost of this approach, is that all the components involved in the chain, must return DBActions. On the implementation side, this means that the moment you actually need the database connection, you should wrap it in a function that takes database connection as its only argument and returns a promise of some result type, e.g.:
const service1 = new DBAction((conn) => conn.query("select..."))
There are two methods available on the DBAction that should help composing the monad:
.map<K>(fn: (item: T) => K)- mapping inner value into another value,.flatMap<K>(fn: (item: T) => DBAction<K>)- mapping inner value into another value, where thefnfunction also require the database connection for the computation.
The DBAction can be:
.run(tr: Transactor): Promise<T>- meaning the transactor will inject plain database connection into the chain,.transact(tr: Transactor): Promise<T>- the transactor will start the transaction and then inject the connection into the chain, finally commit or rollback.
Transactor
The transactor is a component that knows:
- the type of the database connection,
- the details of establishing the database connection, or requesting the connection from the pool,
- the details of executing the query and/or managing the transaction context.
The transactor is database specific and depends on the database driver. There should be a single transactor for the entire application.
As of now, DBAction offers the one for PostgreSQL:
import { Transactor } from "@dumpstate/dbaction/lib/PG"
// `pool` - an instance of `pg` connection pool
const tr = new Transactor(pool)
Utilities
The utilities available in the library, useful for composing the monad:
flatten- tranforms an array ofDBActions into aDBActionof an array of values,pure- wraps a scalar / promise / function returning a promise with aDBAction- useful to lift non-DBAction into a DBAction.chain- builds a sequential chain of DBActions - result of a previous is an argument for the latter,sequence- runs operations concurrently; returns a singleDBAction,query- creates a DBAction for a query string.
Links
- DBAction GitHub Repository.
- Reader Monad.
- Heavily inspired by doobie.