Fullstack CourseLearn by building
Back to week 2

Topic

Transactions & multi-write consistency

Definition

A database transaction groups multiple statements so that either all of them commit together or none of them take effect, preserving consistency whenever a single feature performs more than one related write.

In simpler words

If a feature needs two or more writes to succeed together — like reassigning a ticket and logging who did it — a transaction makes sure a crash in the middle cannot leave only one of them done.

Repository/QueryBuilder calls outside a transaction each commit independently; TypeORM gives you an explicit way to group writes when that independence would be a bug.

After this you can

  • Explain why an uncoordinated multi-write feature can leave inconsistent data
  • Use DataSource.transaction() with the transactional entity manager
  • Spot the common mistake of using the wrong repository inside a transaction

Why multi-write features need a transaction

Definition

Without a transaction, each save/update call commits to the database independently and immediately, so a failure between two related writes leaves the database in a state where one change happened and the other did not.

In simpler words

Reassigning a ticket to a new user while writing an audit-log row for that change is a good example: if the process crashes after the reassignment but before the log write, the audit trail becomes wrong forever.

Any feature described as “update this, and also record that” is a transaction candidate.

A transaction does not make writes faster; it makes them atomic — all-or-nothing — which is a correctness guarantee, not a performance one.

The failure a transaction prevents

// Without a transaction:
await ticketsRepo.update(ticketId, { assigneeId: newUserId }); // succeeds
// ...process crashes here...
await activityRepo.save({ ticketId, action: 'reassigned' }); // never runs
// Result: ticket reassigned, but no audit record — inconsistent

Both writes need to share one all-or-nothing outcome.

Using DataSource.transaction()

Definition

DataSource.transaction() runs a callback inside a single database transaction, passing it a transactional EntityManager that all reads and writes inside the callback must use so they participate in that same transaction.

In simpler words

The transaction commits automatically if the callback resolves, and rolls back automatically if it throws — you rarely write explicit COMMIT/ROLLBACK by hand.

Every repository used inside the callback must come from the passed-in manager (manager.getRepository(Entity)) — a repository injected the normal way runs outside the transaction.

Throwing inside the callback is the correct way to abort — TypeORM rolls back everything the callback did so far.

Reassign + audit log, atomically

await this.dataSource.transaction(async (manager) => {
  const ticketsRepo = manager.getRepository(Ticket);
  const activityRepo = manager.getRepository(TicketActivity);

  await ticketsRepo.update(ticketId, { assigneeId: newUserId });
  await activityRepo.save(
    activityRepo.create({ ticketId, action: 'reassigned' }),
  );
  // both commit together, or neither does
});

this.dataSource is injected with @InjectDataSource() from @nestjs/typeorm.

Where this goes wrong

Definition

A transaction only protects the writes performed through its transactional manager; using an injected, non-transactional repository or a separate query inside the same callback silently escapes the transaction.

In simpler words

The most common bug is calling the ordinary @InjectRepository(Ticket) instance inside a transaction() callback instead of manager.getRepository(Ticket) — that write commits immediately and independently, defeating the whole point.

Always resolve every repository used inside the callback from the manager argument, not from constructor injection.

Keep unrelated, independent writes outside the transaction — wrapping everything in one transaction just to be safe adds contention without adding correctness.

Mistake: mixing injected and transactional repos

// Wrong — this.ticketsRepo is NOT part of the transaction below
await this.dataSource.transaction(async (manager) => {
  await this.ticketsRepo.update(ticketId, { assigneeId: newUserId }); // escapes!
  await manager.getRepository(TicketActivity).save({ ... });
});

// Right — both repos come from the transactional manager
await this.dataSource.transaction(async (manager) => {
  await manager.getRepository(Ticket).update(ticketId, { assigneeId: newUserId });
  await manager.getRepository(TicketActivity).save({ ... });
});

this.ticketsRepo commits on its own the moment update() resolves, regardless of what the transaction later does.

Keep transactions short — no external awaits inside

Definition

A database transaction holds locks and a pooled connection for its duration; awaiting slow external work (HTTP calls, email, queues) inside that transaction extends lock time and exhausts the connection pool under load.

In simpler words

Do DB work atomically, commit, then talk to the outside world — or use an outbox/queue pattern when both must eventually succeed.

Bad: begin transaction → update ticket → await fetch(thirdParty) → write audit → commit. The third-party call holds a DB connection hostage.

Better: transaction for ticket + audit → commit → then publish to RabbitMQ / call the third party. Compensate if the external call fails.

Isolation intuition: default READ COMMITTED is enough for most Nest CRUD; reach for stronger isolation only when you can name the anomaly you are preventing.

Mistake: external I/O inside a transaction

// Wrong
await this.dataSource.transaction(async (manager) => {
  await manager.getRepository(Ticket).save(ticket);
  await fetch('https://billing.example/charge'); // holds DB connection!
  await manager.getRepository(TicketActivity).save(activity);
});

// Right — DB atomicity first, side effects after
await this.dataSource.transaction(async (manager) => {
  await manager.getRepository(Ticket).save(ticket);
  await manager.getRepository(TicketActivity).save(activity);
});
await this.billingClient.charge(...); // outside

Transactions protect database consistency; they are not a general “undo the internet” mechanism.

Keep in mind

  • Ask “does this feature make more than one related write?” — if yes, it is a transaction candidate.
  • Every repository inside a transaction() callback must come from that callback’s manager.
  • Throw to roll back; return normally to commit — do not manage commit/rollback by hand unless using the lower-level QueryRunner API.

Test

Check your understanding

At least 10 questions — mix of concept, syntax, practical, and logic. Score ≥ 80% (enforced by the API) to save progress.

Checking your session…

10 questions · concept 3 · syntax 2 · practical 3 · logic 2

Concept1. Which statement best defines Transactions & multi-write consistency?
Syntax2. Which code-level choice matches Transactions & multi-write consistency?
Practical3. A reviewer spots a bug related to Transactions & multi-write consistency. What is the right fix?
Logic4. Which reasoning correctly explains Transactions & multi-write consistency?
Concept5. Which boundary does correct use of Transactions & multi-write consistency preserve?
Practical6. What is the safest next step when applying Transactions & multi-write consistency?
Syntax7. Which implementation direction matches the rule for Transactions & multi-write consistency?
Logic8. Which consequence follows from applying Transactions & multi-write consistency correctly?
Concept9. Which claim about Transactions & multi-write consistency is true in this Nest + Postgres monorepo?
Practical10. Which team practice best demonstrates Transactions & multi-write consistency?