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.