Upgrading TypeORM 0.3 to 1.0 in Production: A NestJS Case Study

A backend service I work on runs on TypeORM. Earlier this year we jumped from 0.x to the new 1.0 release. The old line had gone almost five years without a breaking change, and a class of quietly-wrong-answer bugs had piled up in it: queries that returned bad data instead of raising an error. The 1.0 release fixes them by making the bad queries fail loudly instead. It also forced an @nestjs/typeorm upgrade at the same time, since the NestJS glue layer for TypeORM crashes at startup on the new TypeORM if you are still on the old version. But the catch with any major ORM upgrade is that every entity, every query builder, every database factory in the test suite gets touched.

The team had two constraints. "Do not block the rest of the work" and "do not break production." On a service that deploys every day, with a long backlog of feature work that the product team had committed to, an upgrade that needed the whole team to stop and migrate together would have been the wrong shape. We had to find the order that let the migration happen alongside the feature work, with the test suite green at every commit and production unaware that anything had changed.

The order we ended up with: a staging branch that absorbed the breaking changes, the test-harness migration ahead of the runtime, the Docker image pinned in parallel, and the eight or so smaller follow-ups that landed after the main upgrade so nobody had to read a five-thousand-line PR.

Two terms before we go further

  • TypeORM is a TypeScript ORM for SQL databases. It maps decorated TypeScript classes to tables and gives you a query builder, an entity manager, and a migration runner. The 0.x to 1.0 upgrade is the kind of release that touches every file in the codebase that talks to the database.
  • Test harness, in this post, is the infrastructure around the test suite: the factories that build entities, the per-test database setup and teardown, the mocked services, the rate-limit mocks, the fixtures. None of it ships to production. All of it has to be green for CI to pass.

Four places the upgrade broke

The breaking surface on the upgrade landed in four places.

Factory APIs. TypeORM's setSeederFactory shape changed. Every factory we had (about thirty of them, one per major entity) needed a new signature.

Repository methods. A handful of methods that had a "first one wins" behaviour in 0.x became "explicit single or throw" in 1.0. findOne() without an options argument is no longer valid; you have to pass findOne({ where: { id } }) or use findOneBy({ id }). This was a global codemod.

Entity manager transactions. The transaction API tightened. Some callsites that had been relying on implicit transaction propagation needed explicit manager.withRepository(...) calls.

Decorator metadata. @PrimaryGeneratedColumn got stricter about its options. A handful of entities with custom configurations needed a small rewrite.

None of those were hard individually. All four at once, on a codebase with hundreds of entities, was the upgrade.

The order we ran it in

A three-lane timeline across two weeks. Lane 1: the Docker base image pinned to node:22.17.1 on a parallel branch in week 0, then left inert on the staging branch through weeks 1 and 2. Lane 2: the test-harness migration (setSeederFactory shape change, about thirty factories) landed in week 1 while the runtime was still on 0.x. Lane 3: the runtime upgrade (typeorm@1.0.0, findOne to findOneBy codemod, transaction-API tightening) landed on the staging branch in week 1, absorbed daily feature-PR rebases across week 2, and merged to main at the end of week 2.
Three pieces of work, three landing windows, one merge to main. The Docker base spent a week proving itself inert, the test harness landed while the runtime was still on 0.x, and the runtime upgrade lived on a staging branch long enough for every feature PR to rebase onto it.

Each step below has its own commit (or branch) and was reviewable on its own.

One. Pin the new Docker image and Node version on a parallel branch.

TypeORM 1.0 requires Node 22. Our production image was on Node 20. Before any code changed, the new image had to be built and tested. We landed Node 22.17.1 + production stage yarn setup for typeorm@1.0.0 as the first commit on the upgrade branch:

# Dockerfile (shortened)
FROM node:22.17.1-bookworm-slim AS builder
WORKDIR /app
COPY package.json yarn.lock .yarnrc.yml ./
COPY .yarn .yarn
RUN yarn install --immutable

FROM node:22.17.1-bookworm-slim AS production
WORKDIR /app
COPY --from=builder /app/node_modules ./node_modules
COPY . .
RUN yarn install --immutable --production
CMD ["node", "dist/server.js"]

The build target uses the production-stage yarn setup because the new TypeORM brings transitive dependencies that the old --immutable flag was tolerant of and the new one is not. We ran the new image against the old code for a week in staging to make sure the runtime change itself was inert.

Two. Migrate the test harness before the runtime.

This is the step that decided whether the upgrade would block the team or not. The test harness is what runs in CI on every PR. If the test harness is half-migrated, nobody can ship anything. So the test harness goes first, in its own PR, with the old TypeORM still in production:

// database/factories/address.factory.ts (before)
import { define } from "typeorm-seeding";
import { Address } from "../../modules/address/address.entity";

define(Address, (faker) => {
  const address = new Address();
  address.street = faker.location.street();
  address.city = faker.location.city();
  return address;
});

// database/factories/address.factory.ts (after)
import { setSeederFactory } from "typeorm-extension";
import { Address } from "../../modules/address/address.entity";

export default setSeederFactory(Address, (faker) => {
  const address = new Address();
  address.street = faker.location.street();
  address.city = faker.location.city();
  return address;
});

About thirty factories, all the same shape, all migrated in one commit. We landed the test harness work as a series of "complete TypeORM 1.0 test-harness migration" commits. Runtime was still on 0.x, test setup was ready for 1.0. The two coexisted because the factories are dev-time only.

Three. Bump the runtime on a staging branch.

The actual package.json change. Roughly fifteen lines changed in the manifest, a hundred or so callsites changed across the source for the findOnefindOneBy codemod and the transaction-API tightening. We landed it on a staging branch that lived for about a week.

{
  "dependencies": {
    "typeorm": "^1.0.0",
    // ... transitive updates for things that wanted Node 22
  }
}

During that week, every other PR rebased onto the staging branch as it was merged. Conflicts happened (entity decorators and query builders both got touched), but they stayed bounded. We had a Slack channel for "my PR is breaking on the staging rebase, who else has touched this file" and it never had more than one or two messages a day.

Four. Fix the small tests that quietly broke.

Once the runtime was on 1.0, a small set of tests broke for reasons the code diff itself did not show. The framework was behaving slightly differently now, and the tests were catching it. A rate-limit mock had assumed the old library called it at one specific moment; the new library called it a moment later. A recaptcha mock had relied on a method name that had been renamed. Some test suites we had not touched in a long time were quietly assuming old data-object shapes and stopped compiling once the underlying types changed.

We landed these as a series of small commits with names like "test: align auth assertions and complete rate-limit mock" and "test: fix constructor drift in the compile-failing test suites." Each one was a few hours of work and a small PR. Nothing dramatic.

Five. Ship the security fixes we had queued up.

This one was a bonus. The TypeORM upgrade gave us a single PR window where every service was already being touched. We pulled in a handful of unrelated security findings (bugs where an attacker could swap in another user's ID, and write endpoints that let one user modify another user's data) and shipped them in the same staging branch. "While we are in here" is a real shipping pattern; we used it once, deliberately, on the boundary the upgrade had opened anyway.

Two conflicts we planned for and didn't need

Two conflicts I want to call out because they are the ones I was expecting and did not get.

Production rollback. We had a plan. The plan was "deploy the new image, watch the error rate, roll back if anything spikes." We did not need it. The week of staging burn-in caught everything that would have spiked.

Migration runner. TypeORM 1.0 has a different migrations API than 0.x. We were prepared to rewrite our migration files. We did not need to. The existing migrations are run-once historical records that the new runner reads cleanly without changes. The new migrations going forward use the new API, but the catalogue of historical migrations is untouched.

What we'd do again on the next one

  1. Test harness first, runtime second. If the harness is broken, every other PR is blocked. If the runtime is broken, only the upgrade PR is blocked.
  2. Pin the new base image on a parallel branch. Node version upgrades that come bundled with the main one should be tested as their own change before the code change lands on top.
  3. Codemod the global API changes in one commit. findOne()findOneBy() is hundreds of callsites. One mechanical commit beats fifty thoughtful ones.
  4. Keep the staging branch alive for a week. Let other PRs rebase onto it. The conflicts are real but they are bounded if the staging branch is fresh.
  5. Pull in the "while we are in here" fixes deliberately. Big PRs are scary, but a major version upgrade is the one PR window where every file gets touched anyway. The marginal cost of an extra security fix is much lower in that window than in any other.
On this page 6 sections
  1. Two terms before we go further
  2. Four places the upgrade broke
  3. The order we ran it in
  4. Two conflicts we planned for and didn't need
  5. What we'd do again on the next one
  6. We Could Run Your Major Dependency Upgrade
Type to search. to navigate. Enter to open. Esc to close.