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.

This repo 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 in this codebase
  • 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…

10 questions · concept 3 · syntax 2 · practical 3 · logic 2

Concept1. Which statement best defines Soft deletes?
Syntax2. Which code-level choice matches Soft deletes?
Practical3. A reviewer spots a bug related to Soft deletes. What is the right fix?
Logic4. Which reasoning correctly explains Soft deletes?
Concept5. Which boundary does correct use of Soft deletes preserve?
Practical6. What is the safest next step when applying Soft deletes?
Syntax7. Which implementation direction matches the rule for Soft deletes?
Logic8. Which consequence follows from applying Soft deletes correctly?
Concept9. Which claim about Soft deletes is true in this Nest + Postgres monorepo?
Practical10. Which team practice best demonstrates Soft deletes?