Skip to content

One unit of work per business operation

Collect the whole operation's changes, then write them in one transaction; anything that commits on its own is outside the unit.

Predict first

Before you read: predict

ExamHall's SubmitAttempt now runs on MikroORM. Before the request, attempt 7's row has status open and the answer table is empty. One request runs this code on its own fork, em, and nothing else touches the database:

After the request, what do attempt 7's status and the answer table hold?

Code for this question
const attempt = await em.findOne(Attempt, 7);attempt.status = 'submitted';em.create(Answer, { attemptId: 7, questionId: 1, choice: 'B' });await em.flush();
Pick one

The 30-second version

A unit of work records every change one business operation makes and writes them together. MikroORM's EntityManager is one: it watches what it loads, and flush() compares, then runs every pending change in one transaction and commits it. With Prisma, there is no tracking: the transaction client tx is passed down.

Collect the whole operation's changes, then write them in one transaction; anything that commits on its own is outside the unit.

How it works

Step 1 of 12

One operation, one unit. A unit of work keeps the list of objects one business operation changed and writes them out together (Fowler).

Analogy: A drop zone's manifest clerk (skydiving)

At the drop zone In code
a jumper who swaps to a smaller canopy is changed on the load sheet, and the pilot sees it with no separate note a loaded entity's change written at flush (steps 2–3)
a jumper never written on the sheet does not board a new object needs create or persist (step 4)
the load climbs as one lift: everyone on the sheet goes, or, if the lift is scrubbed, no one one flush, one transaction (step 5)
sending each jumper up on a lift of her own a flush per call (step 6)
one line per jumper on the sheet the identity map (step 8)

In real terms: before trusting a use case, count the commits one request makes; more than one means more than one unit.

Breaks down when: a clerk notices a jumper who is half-checked; an EntityManager writes whatever it holds.

Contrasting case: a single em.insert for a log line: one statement, already atomic, needs no unit around it.

Examples, run for real

4.1 ExamHall: three flushes in one submit

EXH-4160: ExamHall's SubmitAttempt called three repositories, and each ended its method with await this.em.flush(). The grading service restarted between the answers and the outbox row:

// submit-attempt.js (as shipped)
await answers.saveAll(7, [{ questionId: 1, choice: 'B' }, { questionId: 2, choice: 'D' }]);  // flushes
await restartPoint();                                                                        // the restart, in the test
await outbox.add('attempt.submitted', 7);                                                     // flushes
await attempts.markSubmitted(7);                                                              // flushes

MR !4460 removes the repositories' flushes; the use case flushes once, after the last call:

// submit-attempt.js (MR !4460)
await answers.saveAll(7, [{ questionId: 1, choice: 'B' }, { questionId: 2, choice: 'D' }]);
await restartPoint();
await outbox.add('attempt.submitted', 7);
await attempts.markSubmitted(7);
await em.flush();
4.1 EXH-4160 as shipped, restart after the answers: answers 2 · outbox 0 · attempt 7 open · commits 1
4.1 EXH-4160 as shipped, no restart: answers 2 · outbox 1 · attempt 7 submitted · commits 3
4.1 MR !4460, restart after the answers: answers 0 · outbox 0 · attempt 7 open · commits 0
4.1 MR !4460, no restart: answers 2 · outbox 1 · attempt 7 submitted · commits 1

As shipped, the restart left two answers for an attempt still open, with no event: the answers' flush had committed (step 6). After the MR, the same restart leaves nothing, and a clean run commits once, the event row included (steps 5, 12).

4.2 What the MR does not decide

Which operations belong in one unit is a product question: here, "the answers, the event and the status change together". A long job that loads thousands of rows is a different shape: it flushes and clears per batch (tip 2).

Try it

Answer first, then check. Nothing is saved.

Question 1 of 6

A streaming service publishes episode 1. Before the request, its title is Pilot and the release table is empty. One request runs this code on its own fork, em, which both repositories are given. The code is below.

The request fails on its last line.

Afterwards, what do episode 1's title and the release table hold?

Code for this question
class EpisodeRepo {  constructor(em) { this.em = em; }  async rename(id, title) { const e = await this.em.findOne(Episode, id); e.title = title; }}class ReleaseRepo {  constructor(em) { this.em = em; }  async schedule(episodeId, region) { this.em.create(Release, { episodeId, region }); await this.em.flush(); }}await new EpisodeRepo(em).rename(1, 'Pilot (HD)');await new ReleaseRepo(em).schedule(1, 'eu');throw new Error('CDN refused the manifest');
Pick one

Question 2 of 6

A streaming service adds subtitle tracks inside one Prisma-style transaction. db.ts stands in for the ORM's client and its transaction type, typed as the ORM's docs type them. tracks.ts comes in three versions; in each, publish.ts passes tx to its first call and forgets it on the second, written for that version's signature. The files are below.

Which version does tsc reject, so the forgotten tx is caught before anything runs?

Code for this question
// db.ts (stand-in: the client and its transaction type)export interface Tx { track: { create(data: { episodeId: number; lang: string }): Promise<void> } }export interface Db extends Tx { transaction(fn: (tx: Tx) => Promise<void>): Promise<void> }export declare const db: Db; // tracks.ts (version 1)import { db, type Tx } from './db';export async function addTrack(episodeId: number, lang: string, client: Tx = db) { await client.track.create({ episodeId, lang }); } // tracks.ts (version 2)import { db, type Tx } from './db';export async function addTrack(episodeId: number, lang: string, tx?: Tx) { await (tx ?? db).track.create({ episodeId, lang }); } // tracks.ts (version 3)import type { Tx } from './db';export async function addTrack(tx: Tx, episodeId: number, lang: string) { await tx.track.create({ episodeId, lang }); } // publish.ts (versions 1 and 2)import { db } from './db';import { addTrack } from './tracks';export const publish = () => db.transaction(async (tx) => { await addTrack(1, 'en', tx); await addTrack(1, 'fr'); }); // publish.ts (version 3)import { db } from './db';import { addTrack } from './tracks';export const publish = () => db.transaction(async (tx) => { await addTrack(tx, 1, 'en'); await addTrack(1, 'fr'); });
Pick one

Question 3 of 6

In an interview you said a published episode's events are outbox rows in the same unit as the status change. The interviewer shows the team's PublishEpisode and asks what the database holds after one call. Before it, episode 1's status is draft and the outbox table is empty. The request runs this on its own fork, em, and then ends; the fork is dropped. The code is below.

What do episode 1's status and the outbox table hold afterwards?

Code for this question
const episode = await em.findOne(Episode, 1);episode.status = 'live';em.create(Outbox, { topic: 'episode.published', ref: 1 });await em.flush();em.create(Outbox, { topic: 'search.reindex', ref: 1 });return episode;
Pick one

Question 4 of 6

A streaming service adds subtitle tracks to episode 1. addTrack takes an optional db and falls back to the request's fork, em. Before the request, the track table is empty. The request runs this on em. The code is below.

The request fails on its last line, and its fork is dropped.

Afterwards, which tracks does the track table hold, and how many commits did the request send?

Code for this question
async function addTrack(episodeId, lang, db = em) {  db.create(Track, { episodeId, lang });}await em.transactional(async (tx) => {  await addTrack(1, 'en', tx);  await addTrack(1, 'fr');});throw new Error('search index refused the episode');
Pick one

Question 5 of 6

A box office holds seat A1. Before the request, A1's status in the seat table is free. One request runs this on its own fork, em. The code is below.

What does it print, and what is A1's status in the table afterwards?

Code for this question
const first = await em.findOne(Seat, 'A1');first.status = 'held';const again = await em.findOne(Seat, 'A1');console.log(again.status);await em.flush();
Pick one

Question 6 of 6

A ledger service posts one entry per request. limits.check throws when account 1 is over its daily limit, as it is today. Before the requests, the entry table is empty. The code is below; the two calls at its end are two requests, one after the other.

Which change to the line const em = shared; leaves only request 2's entry in the table?

Code for this question
const shared = orm.em.fork();async function post(account, cents) {  const em = shared;  em.create(Entry, { account, cents });  await limits.check(account);  await em.flush();}await post(1, -500).catch(() => {});await post(2, 500);
Pick one

Go deeper

Pro tips
  1. Count the commits one request makes. Turn on MikroORM's query log (debug: ['query']) in development and read one request's statements: more than one commit for one business operation means more than one unit. When: reviewing any use case that calls several repositories (steps 5–6).
  2. In a long job, flush and clear per batch. The identity map keeps every entity the EntityManager loaded until em.clear(), so a job that walks a large table on one fork holds all of it. When: writing an import, a backfill or any loop over many rows. Verify (RAN, tips.mjs): after loading 200 episodes on one fork the identity map holds 200 entities; with a flush and em.clear() after each batch of 50, it holds at most 50.
Source
  • Martin Fowler, Unit of Work (the row's first source; the catalog page, not the book): https://martinfowler.com/eaaCatalog/unitOfWork.html · fetched 2026-10-14. Teaching only; every key here is executed. "Maintains a list of objects affected by a business transaction and coordinates the writing out of changes and the resolution of concurrency problems." (step 1). The book, Patterns of Enterprise Application Architecture, keys nothing. Valid as of 2026-10-14.
  • MikroORM docs, Unit of Work and Transactions (the row's second source): https://mikro-orm.io/docs/unit-of-work · fetched 2026-10-14. "The following code WILL update your database with the changes made to the Author object, even if you did not call em.persist():" and "Only the fields that actually changed are updated." (steps 2–3); "When you call em.flush(), all computed changes are queried inside a database transaction (if supported by given driver)." (step 5). Valid as of 2026-10-14.
  • MikroORM docs, Identity Map and Request Context (the row's second source): https://mikro-orm.io/docs/identity-map · fetched 2026-10-14. "MikroORM uses identity map in background, so you will always get the same instance of one entity." (step 8); "You should always keep unique identity map per each request." (step 9). Valid as of 2026-10-14.
  • MikroORM docs, Transactions and Concurrency: https://mikro-orm.io/docs/transactions · fetched 2026-10-14. "em.transactional(cb) and the @Transactional() will flush the inner EntityManager before transaction commit." (step 7). Valid as of 2026-10-14.
  • Prisma docs, Transactions (the row's third source; the URL redirected to the page below): https://www.prisma.io/docs/orm/fundamentals/transactions · fetched 2026-10-14. "A helper that runs inside the caller’s transaction takes tx as a parameter:" (step 11); "calling db.transaction(...) again inside a callback does not nest" (linked, pat-repository-over-orm step 16). The page now shows the db.transaction(async (tx) => …) form of Prisma ORM; P2's db.ts stand-in follows it. Valid as of 2026-10-14.

Checked against the tools and docs as of 2026-10-14.