Fullstack CourseLearn by building
Back to week 3

Topic

Soft deletes

Definition

A soft delete marks a row as removed by recording a deletion timestamp instead of removing the row from the table, so the record and its history remain queryable while normal reads exclude it by default.

In simpler words

Instead of erasing a row, you stamp it “deleted at this time” and teach normal queries to skip stamped rows — the data is still there if you need it back.

Consider an API that currently hard-deletes tickets. Use that as the contrast case for understanding what a soft delete would change.

After this you can

  • Contrast hard delete with soft delete
  • Sketch the entity and service change a soft delete needs
  • Name a cost soft deletes introduce elsewhere in the schema

What changes versus today’s hard delete

Definition

TypeORM supports soft deletion through a @DeleteDateColumn entity column plus repository softRemove/restore methods, which set or clear that column instead of issuing a DELETE statement, while default find operations automatically exclude rows with a non-null deletion timestamp.

In simpler words

The row keeps existing; only the meaning of “found” changes for ordinary queries.

TicketsService.remove today calls ticketsRepo.delete({ id }), which issues a real DELETE — the row and its history are gone.

Adding a deletedAt column and switching remove to softRemove would keep the row while hiding it from list/get by default.

Today: hard delete

async remove(id: string) {
  const result = await this.ticketsRepo.delete({ id });
  if (!result.affected) throw new NotFoundException('Ticket not found');
}

No trace of the ticket remains after this runs — no restore, no audit trail.

Soft-delete sketch

@DeleteDateColumn({ name: 'deleted_at' })
deletedAt?: Date | null;

async remove(id: string) {
  const ticket = await this.ticketsRepo.findOne({ where: { id } });
  if (!ticket) throw new NotFoundException('Ticket not found');
  await this.ticketsRepo.softRemove(ticket);
}

A migration must add the column; withDeleted: true on a query is the deliberate way to see removed rows again.

Deciding when a soft delete is worth it

Definition

Soft deletion trades simpler recovery and auditability for extra query discipline, because every unique constraint, join, and report must account for rows that still exist but should usually stay hidden.

In simpler words

Prefer it when accidental or reversible deletion matters more than keeping the table minimal.

A unique index — say, on an external reference number — now needs to consider whether soft-deleted rows should still occupy that uniqueness.

Soft delete is a schema change like any other — it ships through a migration, not by editing the entity and hoping something else catches up.

Reading soft-deleted rows on purpose

this.ticketsRepo.find({ withDeleted: true });
// or
this.ticketsRepo.restore(id);

Explicit opt-in keeps the default list/get behavior honest for everyday callers.

Count leaks and unique constraints

Definition

Soft-deleted rows still occupy the table, so any query that forgets deletedAt IS NULL — especially COUNT used for pagination totals — will disagree with the visible page and break page math.

In simpler words

Unique constraints also still see soft-deleted rows unless you design partial unique indexes that ignore them.

Pagination bug: data query filters deletedAt IS NULL, count query does not → empty last pages or “ghost” totals (see Week 4 challenge).

Re-creating a ticket with the same unique external key can fail because a soft-deleted row still holds the uniqueness slot — consider partial unique indexes WHERE deleted_at IS NULL.

Default TypeORM find with @DeleteDateColumn excludes soft-deleted rows; raw QueryBuilder and .count() often do not unless you add the filter yourself.

Mistake: mismatched filters

// Wrong
const data = await repo.find({ where: { deletedAt: IsNull() }, skip, take });
const total = await repo.count(); // includes soft-deleted

// Right — same predicate for both
const where = { deletedAt: IsNull() };
const [data, total] = await repo.findAndCount({ where, skip, take });

One shared where/query builder keeps meta.total honest.

Keep in mind

  • Soft delete is a migration-backed schema decision, not a service-only change.
  • Audit every unique constraint and report query once soft-deleted rows can exist.
  • Keep restore/withDeleted access explicit and deliberate, not the default.
  • Pagination totals must use the same soft-delete filter as the page query.

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…

15 questions · concept 5 · syntax 3 · practical 4 · logic 3

1. What is a soft delete?
Concept
2. Which TypeORM decorator marks soft delete?
Syntax
3. How do default finds treat soft-deleted rows?
Practical
4. What does this repo TicketsService.remove currently do?
Logic
5. Why soft delete tickets/notes in product scenarios?
Concept
6. What must match between page query and count?
Practical
7. What is softRemove/restore for?
Syntax
8. Is a cron purge the same as soft delete?
Logic
9. Can soft-deleted rows still exist physically?
Concept
10. Which mistake breaks list UX?
Practical
11. What does this decorator enable?
Conceptintermediate
@DeleteDateColumn()
deletedAt?: Date;
12. Does the soft-removed ticket appear in rows?
Syntaxintermediate
await repo.softRemove(ticket);
const rows = await repo.find();
13. What does withDeleted:true return?
Practicaladvanced
repo.find({ withDeleted: true });
14. Why will list totals look wrong here?
Logicadvanced
const [data] = await repo.findAndCount();
const total = await repo.count({ withDeleted: true });
15. What does restore do to a soft-deleted row?
Conceptintermediate
await repo.restore(id);

Checking your session…