NestJS course Β· Module 3: TypeORM and Databases

Transactions - safe operations on tributes

6 min read
In this lesson6

The legionary Marcus hands a golden chalice to Gaius. In code that is two operations: subtract the chalice from Marcus's inventory, add it to Gaius's. You perform the first one, and at that very moment the database server goes down. The result? The chalice has vanished from the Empire's treasury. Marcus no longer has it, Gaius never received it - it evaporated between two writes.

The treasury cannot afford that. We need a way to tell the database: "these two operations are one order - perform both or neither." That way is a transaction.

Atomicity and the rest of ACID

The property we are after is called atomicity - a transaction is indivisible like an atom: it cannot be performed halfway. It is the first letter of the acronym ACID, which describes a transaction's guarantees:

  • Atomicity - all the operations, or none of them,
  • Consistency - the database moves from one valid state to another, never staying in between,
  • Isolation - transactions running in parallel do not peek at each other's unfinished changes,
  • Durability - once committed, the changes survive even a power failure.

Remember the A above all - the rest follows from it, and it is what our chalice is about.

The transaction lifecycle

Every transaction walks the same road, whatever the database or the language. First BEGIN - we announce the start, and from that moment the database records changes on the side, as impermanent. Then we perform the operations - our two writes. Next we check that everything succeeded. And finally one of two roads: COMMIT confirms the whole thing and only now do the changes become permanent, or ROLLBACK undoes everything as if the transaction had never happened.

This scheme - begin, perform, verify, commit or roll back - is worth holding in your head before we look at code. All the rest of this lesson is two ways of writing it down in TypeORM.

The first way: a managed transaction

The simplest variant is dataSource.transaction(). You pass a function, and TypeORM opens the transaction before calling it and closes it afterwards:

1await this.dataSource.transaction(async (manager) => {
2  await manager.update(Tribute, tributeId, { legionariusze: { id: toLegionaryId } });
3  await manager.increment(Legionary, { id: toLegionaryId }, 'tributeCount', 1);
4  await manager.decrement(Legionary, { id: fromLegionaryId }, 'tributeCount', 1);
5});

The function receives one argument: manager - the equivalent of a repository, but bound to this particular transaction. That is the most important detail of this code: every operation must go through manager. If you reached inside for a plain this.tributeRepository.save(...), that write would run outside the transaction - and would not be undone on error.

So how does the database know whether to commit or roll back? From exceptions. If the function runs to its end peacefully, TypeORM performs a COMMIT. If anything inside throws - it performs a ROLLBACK and rethrows the exception. You write no error-handling line at all - and that is what I recommend as the default choice.

The second way: QueryRunner, or full control

Sometimes you need to steer the transaction by hand - to do something between operations, say, or to react to one specific error. Then you reach for a QueryRunner: an object representing one exclusive database connection, on which you call each stage yourself.

1const queryRunner = this.dataSource.createQueryRunner();
2
3await queryRunner.connect();
4await queryRunner.startTransaction();
5
6try {
7  await queryRunner.manager.update(Tribute, tributeId, {
8    legionariusze: { id: toLegionaryId },
9  });
10  await queryRunner.manager.increment(
11    Legionary, { id: toLegionaryId }, 'tributeCount', 1,
12  );
13
14  await queryRunner.commitTransaction();
15} catch (error) {
16  await queryRunner.rollbackTransaction();
17  throw error;
18} finally {
19  await queryRunner.release();
20}

Let's walk this code step by step, because every line matches one stage of the lifecycle you met above. createQueryRunner() creates the object, connect() reserves a connection from the pool for it, and startTransaction() is our BEGIN. We perform operations through queryRunner.manager - again the same condition as before: only what goes through this runner's manager belongs to the transaction. The try block ends with commitTransaction(), while catch calls rollbackTransaction() and rethrows, so the layer above knows the transfer failed.

The last part matters most. release() must sit in finally, because the connection has to return to the pool in every scenario - after success and after failure alike. Skipping it will not break a single transfer; the consequence shows up later, when the pool runs out of free connections and the whole application stalls. This is the most common mistake with manual transactions.

Note also what release() does not do: it neither commits nor rolls back anything. If you release a runner without committing, the transaction is discarded - freeing the connection is not saving the changes.

Isolation - transactions side by side

That leaves the I from ACID. When two transactions run at the same time, the database must decide how much one sees of the other's unfinished work. You pass the level of that separation as an argument:

1await queryRunner.startTransaction('SERIALIZABLE');

The default level is usually 'READ COMMITTED' - you see only changes others have already committed. 'SERIALIZABLE' is the strictest level: transactions execute as if queued, one after another. It gives the strongest guarantees, but at the cost of throughput - the database more often forces one transaction to back off and retry. That is why we raise the level deliberately and only where it is truly needed, for instance on treasury balance operations.

Summary

Marcus's chalice will never again be lost halfway:

  • a transaction is a group of operations executed atomically - all of them, or none,
  • the acronym ACID describes its guarantees, and the letter A is precisely atomicity,
  • the lifecycle is always the same: BEGIN, operations, verification, COMMIT or ROLLBACK,
  • dataSource.transaction(async manager => {...}) runs that cycle for you: a clean exit means COMMIT, an exception means ROLLBACK,
  • QueryRunner gives full control: createQueryRunner, connect, startTransaction, then commitTransaction in try, rollbackTransaction in catch and release in finally,
  • in both variants the operations must go through the transaction's manager - a plain repository works outside it,
  • the isolation level decides how much transactions see of each other's unfinished work.

In the next lesson you will meet seeders - the quartermasters who equip a fresh database with starting data. For now remember: a transaction is one order made of many moves - the database will carry it out whole, or pretend it never heard it.

Code for this lesson: src/transactions.ts
1// Transactions - Safe Operations on Tributes
2import { Injectable } from '@nestjs/common';
3import { DataSource, QueryRunner, EntityManager } from 'typeorm';
4
5console.log("Transactions - atomic operations on the empire's data!");
6
7// ===========================================
8// 1. Transaction with QueryRunner
9// ===========================================
10
11@Injectable()
12export class TributeTransferService {
13  constructor(private dataSource: DataSource) {}
14
15  async transferTribute(
16    fromProvince: string,
17    toProvince: string,
18    amount: number,
19  ): Promise<void> {
20    // Create a QueryRunner
21    const queryRunner = this.dataSource.createQueryRunner();
22    await queryRunner.connect();
23    await queryRunner.startTransaction();
24
25    try {
26      // Subtract from the source province
27      await queryRunner.query(
28        'UPDATE provinciae SET tribute = tribute - $1 WHERE name = $2',
29        [amount, fromProvince],
30      );
31
32      // Add to the destination province
33      await queryRunner.query(
34        'UPDATE provinciae SET tribute = tribute + $1 WHERE name = $2',
35        [amount, toProvince],
36      );
37
38      // Commit the transaction
39      await queryRunner.commitTransaction();
40      console.log('Transfer completed: ' + amount + ' from ' + fromProvince + ' to ' + toProvince);
41    } catch (error) {
42      // Rollback on error
43      await queryRunner.rollbackTransaction();
44      console.error('Transfer cancelled: ' + error.message);
45      throw error;
46    } finally {
47      // Release connection
48      await queryRunner.release();
49    }
50  }
51}
52
53// ===========================================
54// 2. Transaction with EntityManager
55// ===========================================
56
57@Injectable()
58export class PromotionService {
59  constructor(private dataSource: DataSource) {}
60
61  async promoteLegionary(
62    legionaryId: number,
63    newRank: string,
64  ) {
65    return this.dataSource.transaction(async (manager: EntityManager) => {
66      // Find the legionary
67      const legionary = await manager.findOneBy('Legionary', {
68        id: legionaryId,
69      });
70
71      if (!legionary) {
72        throw new Error('Legionary non inventus!');
73      }
74
75      const oldRank = legionary.rank;
76
77      // Update rank and experience
78      legionary.rank = newRank;
79      legionary.experience += 1;
80      await manager.save('Legionary', legionary);
81
82      // Save promotion history
83      await manager.save('PromotionHistory', {
84        legionaryId,
85        oldRank,
86        newRank,
87        date: new Date(),
88      });
89
90      console.log('Promotion: ' + oldRank + ' -> ' + newRank);
91      return legionary;
92    });
93  }
94}
95
96// ===========================================
97// 3. ACID properties
98// ===========================================
99
100// Atomicity  - all or nothing
101// Consistency - data always consistent
102// Isolation   - transactions don't collide
103// Durability  - committed = permanent
104
105console.log("\n=== TRANSACTIONS SUMMARY ===");
106console.log("QueryRunner: connect -> startTransaction -> commit/rollback -> release");
107console.log("dataSource.transaction(manager => {...}) - simpler syntax");
108console.log("ACID - atomicity, consistency, isolation, durability");
109console.log("try/catch/finally - always handle errors!");
110

Spotted a mistake in this lesson?

Check yourself

Answer the questions from this lesson. Pick an answer to see right away whether it is correct.

  1. 1. What is a database transaction?

  2. 2. What does the letter 'A' stand for in the ACID acronym describing transaction properties?

Hands-on tasks in the game

  • Vertical ordering

    Arrange the stages of a database transaction lifecycle:

  • Code editor

    Create a transferTributes method that uses queryRunner.startTransaction(), performs two saves and commits (or rolls back on error)

  • Click in order

    Arrange the elements of a QueryRunner transaction code in the correct order:

  • Horizontal ordering

    Arrange the elements of starting a transaction in the correct order:

  • Code editor

    Write a service method using dataSource.transaction(async manager => { ... }), which creates a tribute and updates a province

Useful articles