In many cases, developers must use transactions when performing various operations on the server. For example - a transfer of money, or other measurable value, and much more. With such operations, I really do not want to receive an error that will interrupt the process and violate the data integrity.
What is a "transaction" anyway? Wikipedia says:
A database transaction symbolizes a unit of work performed within a database management system (or similar system) against a database, and treated in a coherent and reliable way independent of other transactions. A transaction generally represents any change in a database. Transactions in a database environment have two main purposes:
- To provide reliable units of work that allow correct recovery from failures and keep a database consistent even in cases of system failure, when execution stops (completely or partially) and many operations upon a database remain uncompleted, with unclear status.
- To provide isolation between programs accessing a database concurrently. If this isolation is not provided, the programs' outcomes are possibly erroneous.
Now, consider a situation where an error can occur, leading to very unpleasant consequences if you do not use transactions.
I made a small project in which there are two entities:
Users can transfer money to each other. When transferring, the sufficiency of the amount on the balance of the transferor is checked, as well as many other checks. If a situation occurs when the money has been debited from the sender's balance but not transferred to the recipient's account, or vice versa, we will see either a very sad, angry person, or we will not see a very happy one (depends on the transfer amount).
Great, with the fact that transactions are important and need to be sorted out (hopefully everyone agrees with this). But how do you apply them?
First, let's look at the options for queries with errors and without errors that will occur if you use PostgreSQL.
The usual set of queries without errors:
By the way - I did not write this request by hand, but pulled it out of the ORM logs, but it reflects the essence. Everything is pretty simple and straightforward. To build the queries, TypeORM was used, which we will return to a little later.
The ORM and Postgres settings are set by default, so each operation will be performed in its own transaction, but to take advantage of this advantage, you need to write one query in which all the logic associated with the database will take place at once.
Below is an example of the execution of multiple queries executed in one transaction:
The key difference with the previous example of requests is that in this case all requests are executed in one transaction, and therefore, if an error occurs at some stage, the entire transaction will be rolled back with all the requests inside it. More or less like this:
And here, by the way, is the code that produced all the previous SQL queries. It contains a flag, when set, an error occurs at the most inopportune moment:
Fine! We saved ourselves from losses or very upset users (at least in matters related to money transfers).
What's next? What other ways are there to write a transaction? It just so happened that the person whose article you are currently reading (this is me) really loves one wonderful framework when he has to write a backend. The name of this framework is Nest.js. It works on the Node.js platform, and the code in it is written in Typescript. This great framework has support, almost out of the box, for the very TypeORM. Which (or which?) I, as it happens, also really like. I didn't like only one thing - a rather confusing, as it seems to me, overly complicated approach to writing transactions.
This is the official example for writing transactions:
Second way to create transactions from documentation:
In general, the point of this approach is as follows: you need to get a
transactionEntityManager: EntityManager - an entity that will allow you to execute queries within a transaction. And then use this entity for all actions with the base. Sounds good, as long as you don't have to deal with using this approach in practice.
To begin with, I don't really like the idea of injecting dependencies directly into the methods of service classes, as well as the fact that the methods written in this way become isolated in terms of using the dependencies injected into the service itself. All the dependencies necessary for the method to work will have to be dropped into it. But the most annoying thing is that if your method calls other services embedded in yours, then you have to create the same special methods in those third-party services. And pass
transactionEntityManager in them. At the same time, it should be borne in mind that if you decide to use the approach through decorators, then when you transfer the
transactionEntityManager from one service to the second, and the method of the second service will also be decorated - in the second method you will receive the
transactionEntityManager that is not passed as a dependency, and the one that is created by the decorator, which means two different transactions, which means unfortunate users.
Start from examples
Below is the code for a controller action that handles user requests:
In it, we need to have access to the
connection object to create a
transactionManager. We could do as the TypeORM documentation advises - and just use the
getConnection function as shown above:
But it seems to me that such code will be more difficult to test, and this is simply wrong (great argument). Therefore, we will have to pass the
connection dependency into the controller constructor. It's very lucky that Nest allows you to do this by simply describing the field in the constructor with the appropriate type:
Thus, we come to the conclusion that in order to be able to use transactions in Nest when using TypeORM, it is necessary to pass the
connection class into the controller / service constructor, for now we just remember this.
Now let's look at the
makeRemittanceWithTypeOrmV1 method of our
The whole project is synthetic, but to show the unpleasantness of this approach - I moved the
savePurse method used to save the wallet into a separate
appServiceV2 service, and used this service with this method inside the considered
makeRemittanceWithTypeOrmV1 method. You can see the code of this method and service below:
Actually, in this situation, we get the following SQL queries:
If we send a request for an error to occur, we will clearly see that the internal transaction from
appServiceV2 is not rolled back, and therefore our users are indignant again.
Here we conclude that for a standard approach to trunking, you need to have special methods into which you will need to pass
If we want to get rid of the need to explicitly inject the
transactionEntityManager into the corresponding methods, then the documentation advises us to look at decorators.
By applying them, we get this kind of controller action:
Now it has become simpler - there is no need to use the
connection class, neither in the constructor, nor by calling the global method TypeORM. Perfectly. But the method of our service should still receive a dependency -
transactionEntityManager. This is where those decorators come to the rescue:
We have already figured out the fact that simply using a third-party service method breaks our transactions. Therefore, we used the new method of the third-party service
transactionEntityManager, which looks like this:
As you can see from the code, in this method we also used decorators - this way we achieve uniformity across all methods in the project (yep yep), and also get rid of the need to use
connection in the constructor of controllers using our service
With this approach, we get the following requests:
And, as a consequence, the destruction of the transaction and application logic on error:
The only working way, which the documentation describes, is to avoid using decorators. If you use decorators in all methods at once, then those of them that will be used by other services will inject their own
transactionEntityManagers, as happened with our
appServiceV2 service and its
savePurseInTransaction method. Let's try to replace this method with another:
For the consistency of our methods, and getting rid of the hierarchy that has appeared, which is manifested in the fact that some methods can call others, but still others will not be able to call the first - we will change the method of the
appService class. Thus, having received the first option from the documentation.
Well, it seems we still have to inject this
connection into the controller constructors. But the proposed way of writing code with transactions still looks very cumbersome and inconvenient. What to do? Solving this problem, I made a package that allows you to use transactions in the simplest way. It is called nest-transact.
What is he doing? Everything is simple here. For our example with users and money transfers, let's look at the same logic written with nest-transact.
The code of our controller has not changed, and since we have made sure that we cannot do without
connection in the constructor, we will specify it:
Its difference from the action, in the case of using the first method from the documentation:
Is that we can use the usual methods of services without creating specific variations for transactions in which it is necessary to pass
transactionManager. And also - that before using our service business method, we call the
withTransaction method on the same service, passing our
transactionManager to it. Here you can ask the question - where did this method come from? Hence:
And here is the request code:
And with the error:
But you already saw it at the very beginning.
To make this magic work, you need to complete two steps:
- Our service must inherit from the class
- Our service must have a special class
moduleRef: ModuleRefin the list of constructor dependencies
It's all. By the way, since dependency injection by the framework itself has not gone anywhere - you don't have to explicitly throw
moduleRef. For testing only.
You might be thinking - Why should I inherit from this class? What if my service will have to inherit from some other one? If you thought, then I suggest calculating how many of your services are inherited from other classes and are used in transactions.
Now how does it work? The appeared
withTransaction method - recreates your service for this transaction, as well as all the dependencies of your service and the dependencies of dependencies - everything, everything, everything. It follows that if you somehow store some state in your services (but what if?) - then it will not be there when creating a transaction in this way. The original instance of your service still exists and when you call it, everything will be as before.
In addition to the previous example, I also added a greedy method: transfer with commission, which uses two services at once in one controller action:
This method makes the following requests:
From which we can see that all requests still occur in one transaction and it will work correctly.
Summing up, I would like to say - when using this package in several real projects, I got a much more convenient way of writing transactions, of course - within the Nest.js + TypeORM stack. I hope you find it useful too. If you like this package and decide to give it a try, little wish - give it an asterisk on GitHub. It's not difficult for you, but it's useful for me and this package. I will also be glad to hear constructive criticism and possible ways to improve this solution.