Add an Entity & Migration
Change the database schema the safe way — edit an entity, then generate, review, and run the migration yourself.
Changing the database schema on this backend is really two separate jobs, and keeping them separate is the whole point of this page. First you edit a TypeScript entity — the class that maps to a table. That part is just code, and it's easy. Then you turn that code change into a migration: a real SQL file, with an up() that applies the change and a down() that reverses it, that gets committed alongside your code and run against every database.
The reason it works this way is one setting: TypeORM's synchronize is false everywhere. TypeORM can auto-reshape your tables to match your entities on boot, but on this project that's switched off in DatabaseModule (libs/shared/src/database/database.module.ts). Nothing touches the schema until you generate a migration, read the SQL it produced, and run it. No silent drops, no surprise column renames in production — every schema change is a reviewed artifact in git.
Migrations are developer-driven — an agent never runs them
This is the rule that matters most. A coding agent (or any automation) edits the entity and then hands off. It must never run pnpm migration:generate or pnpm migration:run. Generating and applying migrations is a deliberate, human step — you review the SQL with your own eyes before it touches a database. If you're following along with an AI pair, expect it to stop after Step 1.
Step 1: Edit (or add) the entity
An entity is a class decorated with @Entity('table_name') that TypeORM maps to a database table — each property becomes a column. To add a field, you add a property. Every entity on this backend extends the shared DatabaseEntity base (which gives it a UUID id) and is decorated with @WithTimestamps() (which appends created_at / updated_at / deleted_at), so you don't write any of that boilerplate yourself.
Here we add a single nullable officePhone column to the staff-profile entity. Two conventions are doing quiet work: the DB column name is snake_case (office_phone, set explicitly via name:), and the field is nullable: true so it won't break the rows that already exist.
@Entity('staff_profile')
@WithTimestamps()
export class StaffProfileEntity extends DatabaseEntity implements StaffProfile {
@Column({ name: 'first_name' }) firstName: string;
// ...existing columns...
@Column({ name: 'office_phone', nullable: true }) officePhone?: string; // new
}If you're adding a brand-new entity rather than a column, two things matter. The file must end in *.entity.ts — the migration CLI discovers entities by the glob apps/*/src/app/**/*.entity{.ts,.js}, so the suffix is what makes it visible. And its module must register it with TypeOrmModule.forFeature([...]) so the rest of the app can inject its repository.
You almost never need a 'deleted' flag
Every entity already has a deletedAt column via @WithTimestamps(), and the repository's softDelete* methods plus TypeORM's default filtering hide deleted rows from normal queries for free. Don't add an isDeleted boolean — soft delete is already built in. See The Database for how the base entity and repository fit together.
A schema field rarely lives alone. If the column should appear in API responses or request bodies, update the matching *.types.ts interface and any DTOs that expose it — that's covered in API Conventions.
Step 2: Generate the migration
Now the entity and the database disagree — the code wants an office_phone column the table doesn't have yet. The generator's job is to diff your entities against the database and write the SQL that closes the gap.
You run this yourself. With no name it auto-generates a friendly <adjective>_<noun>_<timestamp> filename; pass a name to make the file describe its purpose, which is what you usually want.
pnpm migration:generate # auto-named: swift_falcon_20260625…
pnpm migration:generate add-office-phone # named — preferredUnder the hood that calls scripts/generate-migration.sh, which invokes the TypeORM CLI against typeorm.config.cli.ts and drops a new timestamped file into database/migrations/. That config is separate from the app's runtime config on purpose: it loads .env.local, scans the entity glob above, and reads or writes migration files — it's the only thing that ever generates schema SQL.
Generation reads your local database — so keep it current
The diff is computed against whatever database .env.local points at. If your local DB is behind (you skipped someone else's migrations), the generator can emit stray drops or renames trying to "fix" the difference. Run pnpm migration:run to catch up first, so the only diff left is your change.
Step 3: Review the generated SQL
This is the step the whole workflow exists to protect, so slow down and actually read the file. A generated migration is plain SQL wrapped in up() and down() — for our one-column change it's small and easy to verify by eye.
export class AddOfficePhone1776600000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "staff_profile" ADD "office_phone" character varying`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "staff_profile" DROP COLUMN "office_phone"`,
);
}
}Read it against a short checklist before you trust it:
- Only your change is there — no drops, renames, or constraint churn you didn't ask for. Anything extra is the "stale local DB" smell from Step 2.
down()cleanly reversesup()— here, drop the column it added. This is your undo button.- Defaults and nullability are right for existing rows — a
NOT NULLcolumn with no default will fail on a table that already has data.
A migration is code — edit it if you need to
The generator gives you a draft, not gospel. If you need a backfill, a sensible default, or a data fix-up, hand-edit the up() / down() before running it. Just make sure down() still reverses whatever up() ends up doing.
Step 4: Run it locally
With the SQL reviewed, apply it to your local database. If something looks wrong after, revert rolls back the most recent migration using that down() you just checked.
pnpm migration:run # apply pending migrations locally
pnpm migration:revert # undo the last one (local)Re-run the same change a few times if you're iterating: revert, tweak the entity, delete the old generated file, regenerate, run. Locally it's cheap and reversible — that's the time to get it right.
Staging and prod migrate themselves on merge
You only run migrations against your local database. Merging to staging (or main) triggers a CI workflow that reads that environment's DATABASE_URL and runs the migration for you, and the deploy is held until it succeeds — so code never goes live against an un-migrated database. The migration:run:staging / migration:run:prod scripts exist only for break-glass manual use; reach for them deliberately, not by habit.
Step 5: Commit the entity and migration together
The entity change and its migration file are one logical change — commit them in the same commit so they're never separated. A git add -A picks up both the edited *.entity.ts and the new file under database/migrations/.
git add -A
git commit -m "feat(staff): add office_phone to staff profile"Two things round it out. CI runs migration checks on the PR, so the generated file must be in the diff or those checks fail. And the PR template has a Migration / Breaking Changes section — note the schema change there so reviewers know a table is moving.
The flow
Five steps, and only the middle three are yours to run by hand:
| Step | Command | Who runs it |
|---|---|---|
| Edit the entity | (code change) | you or an agent |
| Generate | pnpm migration:generate <name> | you |
| Review the SQL | (read the file) | you |
| Apply locally | pnpm migration:run | you |
| Commit | git add -A + commit | you or an agent |
Where to go next
The Database
The base entity, the thin repository pattern, and how soft delete works.
Add a Resource Module
Scaffold the controller, service, and repository that wrap a new entity.
Add a Year-Scoped Entity
If the entity belongs to one academic year — the campusAcademicYearId column, registry line, and lock gates.
API Conventions
Where DTOs and *.types.ts interfaces fit when a column becomes part of the API.
Config & Environment
How DATABASE_URL and .env.local feed both the app and the migration CLI.
Add a Background Job
Offload slow work to the worker app with a typed BullMQ queue — define the contract, enqueue from a service, add the processor.
Add a Year-Scoped Entity
The full path for campus-operational data that belongs to one academic year — API entity to Education Hub page, with the year handled in exactly three places.