cswork / libraryOpen practice workspace

Articles

TypeORM Interview Questions: Transactions, Lazy Relations, and Migration Safety

Practice TypeORM transaction managers, QueryRunner cleanup, lazy relation query timing, and safe migrations with rollback traces and deployment examples.

A revealing TypeORM interview question is a short code review: a transaction callback updates two entities, throws an error, and only one update rolls back. To explain it, trace which manager executes each query. Being inside an async callback does not enroll a global repository in its transaction.

Evidence boundary: official TypeORM documentation establishes the API behavior. The rollback traces, query-count exercises, and deployment plans below are original practice and engineering inference, not candidate reports. The current documentation identifies TypeORM 1.0; check the installed package and driver before applying a newer option to an older codebase.

Which manager actually owns a transaction?

Official fact: TypeORM's transaction documentation requires transactional operations to use the manager provided to the callback. It also explains that isolation support depends on the database and driver.

Original code-review exercise: assume two independent rows exist, and there is no automatic transaction propagation mechanism in the application.

const globalAuditRepo = dataSource.getRepository(Audit);

await dataSource.transaction(async (manager) => {
  await manager.update(Account, { id: 7 }, { label: "reviewed" });
  await globalAuditRepo.insert({ accountId: 7, message: "reviewed" });
  throw new Error("reject request");
});

The account update runs through the callback manager. The audit insert is submitted through a repository created from the global data source. The latter is not made transactional by indentation or by the fact that its promise is awaited.

Inference: if that global insert obtains an independent connection and commits successfully, the account change can roll back while the audit remains. It might instead block or fail because of locks, constraints, or pool limits. “The second write definitely survives” would overstate the example. The defect is that the two operations do not have the intended shared transaction contract.

A focused repair makes ownership explicit:

await dataSource.transaction(async (manager) => {
  await manager.update(Account, { id: 7 }, { label: "reviewed" });
  await manager.getRepository(Audit).insert({
    accountId: 7,
    message: "reviewed",
  });
  throw new Error("reject request");
});

Now both operations are submitted through the same transactional manager. The error should reach the transaction wrapper rather than be swallowed inside the callback. Verify the observable rows after rejection, not merely that the function threw.

How would you prove the rollback boundary?

Question: “What test catches this bug reliably?”

Original test design: create existing account 7, count its audit rows, perform the operation with a deliberate error after both writes, and read the final state from outside the failed transaction. Assert both the unchanged account label and unchanged audit count.

QueryIntended connection scopeExpected after rollback
Account updateCallback managerOriginal label
Audit insertCallback managerNo new audit row
Verification readsFresh scope after rejectionObserve committed state only

Do not put the verification reads inside the callback that is about to fail. Those reads can see uncommitted state and prove the wrong thing. Likewise, a mock asserting manager.update was called cannot establish actual database rollback.

Inference: run the meaningful integration test against the production database family when locks, isolation, or DDL behavior are involved. SQLite is convenient for a small fixture, but it does not reproduce every PostgreSQL or MySQL transaction behavior.

A follow-up may ask about services that hide repositories in constructors. Accept the manager or an explicitly scoped unit of work at the service boundary. Avoid a helper that receives a transactional manager and then ignores it in favor of its global field. The abstraction should make the correct path easy to inspect.

When do you need a QueryRunner?

Question: “Why not call startTransaction on any manager?”

Official fact: a QueryRunner represents a single database connection. The transaction guide shows manual connect, start, commit or rollback, and release. A pooled connection must be returned after use.

Inference: use the callback API for a normal bounded unit of work. Use a QueryRunner when the application needs explicit connection and lifecycle control. More manual control also creates more failure paths to manage.

Describe the lifecycle in order: obtain the runner, connect, start the transaction, execute using its manager, commit on success, roll back when appropriate, and release in a finally path. If connection or transaction startup fails, cleanup still matters; do not assume every failure occurs after startTransaction succeeded.

For a pool of ten connections, ten leaked runners can leave a service unable to obtain a connection even while the database is healthy. “Requests hang” then needs pool-acquisition evidence, not only a slow-query investigation.

A sensible diagnostic ledger records acquisition duration, transaction duration, release outcome, and the error stage. Avoid logging credentials or full sensitive query values. Distinguish a slow statement from a connection that was never available to execute it.

Does await make external work transactional?

Question: “Can we send the receipt email inside the callback so it rolls back with the order?”

Awaiting an email request does not bring the email provider into the database transaction. If the email succeeds and a later database statement fails, the recipient can receive a receipt for an order that never committed.

Original failure trace: reserve inventory, send receipt, insert payment record, encounter a constraint violation. Database rollback can restore the enrolled database changes. It cannot unsend the receipt. The problem remains even when all database repositories use the right manager.

Inference: write an outbox entry in the transaction when the domain needs a durable notification obligation. A separate worker delivers after commit and handles duplicate delivery with a stable identifier. This is an application pattern, not an automatic TypeORM promise of exactly-once email.

Also await all work that must complete before the callback returns. Launching an unawaited promise and returning can let the transaction lifecycle finish before that query executes. Promise.all does not create extra connections within one transactional runner or remove database lock dependencies. Choose concurrency only when its semantics are clear.

When does a lazy relation query execute?

Official fact: TypeORM's eager and lazy relations guide models lazy relations with promises and describes the JavaScript technique as experimental. Eager relations work with find* methods; QueryBuilder requires explicit relation loading such as leftJoinAndSelect.

Original exercise: a repository fetches six orders. Each order has a lazy customer relation. A serializer later awaits order.customer for every order.

Under a simplified assumption of one initial order query and one fresh relation query per order, that is seven queries. It is not a universal promise about TypeORM query counts: caching, previously loaded relations, shared entities, and loading strategy can change the actual SQL.

StageWork visible in application codeEvidence to collect
Fetch ordersOne repository callActual statements and relations loaded
Return entitiesNo explicit relation queryWhether unresolved lazy properties remain
Serialize responseSix relation accessesAdditional statements and executing manager
Repeat serializationSame entity objects or new onesWhether prior promises are reused

Inference: keep relation loading inside the intended database scope and return a plain response object with the required fields. Do not assume an entity returned from a completed transaction carries an indefinitely usable transactional snapshot. Later lazy access must be investigated for connection ownership and transaction state.

The serializer is therefore part of the query plan. A change that adds one relation field to JSON can create additional database work without modifying the original repository call. Query logging and endpoint-level statement counts reveal that boundary more clearly than counting repository methods.

Is eager loading always the solution to N+1?

Eager loading removes some deferred work, but it can also load fields or nested relations an endpoint does not need. Joining two collections can multiply relational rows before entity hydration.

Original comparison: an order has eight lines and five notes. Joining both independent collections can produce forty joined combinations for that order. That does not necessarily mean forty returned order entities; it means the database and ORM may process a much larger intermediate result.

Inference: choose the endpoint's response shape first. Compare an explicit join, a bounded batch query, and a relation loading strategy using actual SQL, row volume, and latency. An answer of “always eager” trades one unmeasured problem for another.

A query count alone is not a performance verdict. Two well-indexed bounded queries can outperform one giant join. Conversely, batching without a parent limit can still return an enormous child set. State the parent page size and maximum relation fan-out before proposing the loading plan.

Can a generated migration safely rename a column?

Official fact: TypeORM migration generation derives changes by comparing entity definitions with the database schema. Generated output needs inspection. A manual migration has explicit up and down behavior.

Original deployment problem: rename populated display_name to preferred_name while old and new application instances run simultaneously. A destructive drop-and-add loses data; an immediate rename can break the old code even if every value survives.

PhaseOld applicationNew applicationData requirement
ExpandReads old fieldCan tolerate both fieldsAdd compatible new field
TransitionStill writes old fieldReads new with defined fallbackKeep writes coherent during overlap
BackfillContinues servingContinues servingCopy historical values in resumable batches
Cut overRetiredUses new fieldVerify reconciliation and writer coverage
ContractNo longer deployedNo old-field dependencyRemove obsolete field later

Inference: define which writers maintain both fields during overlap; application dual writes are not enough if old instances write only the old field. A trigger, coordinated rollout, or another explicit synchronization mechanism may be needed. Record progress so the backfill can restart without overwriting newer values.

“Rollback” has two meanings here. Reverting the application can be safe while both columns remain. Running a destructive down migration after new writes may discard information. Evaluate whether the data transformation is reversible before promising a one-command recovery.

What should you ask before running migrations?

Official fact: TypeORM documents execution and revert commands separately from creating files, and faking migrations records history without performing the underlying schema change. Faking is not a substitute for proving the schema already matches.

Inference: inspect the generated SQL, target database, current migration history, application compatibility, table size, and expected locking. Run one controlled migration actor rather than letting every newly started service instance race to mutate the same schema.

Do not equate a transaction wrapper with universal DDL rollback. Database engines and specific statements differ. A PostgreSQL concurrent index operation, for example, needs a deployment approach consistent with its database restrictions; check the migration transaction mode rather than assuming the default wrapper fits every statement.

An interview answer should end with verification evidence: expected columns and constraints exist, row counts reconcile, old and new application paths behave as planned, and recovery does not depend on reconstructing deleted data from memory.

Practice the underlying failure modes

QuestionWhat it develops
In-Memory Key-Value Store with Nested TransactionsTrace exactly which mutations roll back.
Implement a Database Connection PoolExplain acquisition, ownership, and release.
Migrate a Continuously Used DatabasePlan application compatibility during a live change.
Many-to-Many Schema Design With a Join Table, Composite Primary Key, and QueriesConnect entity relations to the actual schema.
Reason About Indexes, Kafka, and Database LockingSeparate ORM convenience from database behavior.

Try the continuously used database migration and explain which application version can read and write each schema phase before you discuss the CLI command.

Sources and Further Reading