Skip to content

API Design Knowledge: Relations, Cascade & Deletion Strategies

This document captures architectural knowledge for two of the most commonly mishandled areas in API design involving relational data:

  1. Junction Table Management β€” should ManyToMany pivot tables use hard or soft delete?
  2. Cascade API Design β€” how should a PATCH endpoint interpret and handle child (OneToMany) data?

These decisions affect data integrity, query complexity, ORM compatibility, and auditability. Understanding the trade-offs prevents subtle bugs that are hard to trace in production.


A junction table (also called a pivot or bridge table) represents a Many-to-Many relationship between two entities. It typically holds only the two foreign keys forming a composite primary key.

-- Pure junction table
CREATE TABLE orders_tags (
order_id UUID NOT NULL REFERENCES orders(id),
tag_id UUID NOT NULL REFERENCES tags(id),
PRIMARY KEY (order_id, tag_id)
);

Should Junction Tables Use Hard Delete or Soft Delete?

Section titled β€œShould Junction Tables Use Hard Delete or Soft Delete?”

Short answer: Hard Delete is the correct default for pure junction tables.

Reason 1 β€” Composite Primary Key Constraint Violation

Section titled β€œReason 1 β€” Composite Primary Key Constraint Violation”

Junction tables use a composite PK to guarantee uniqueness. With soft delete:

1. Assign tag: INSERT (order_id=1, tag_id=10) β†’ OK
2. Remove tag: UPDATE SET is_deleted=true β†’ "deleted" but row remains
3. Re-assign tag: INSERT (order_id=1, tag_id=10) β†’ ❌ DUPLICATE KEY ERROR

The fix requires checking for a soft-deleted row before deciding to INSERT or UPDATE β€” adding unnecessary complexity.

With hard delete:

1. Assign: INSERT (order_id=1, tag_id=10) β†’ OK
2. Remove: DELETE WHERE (order_id=1, tag_id=10) β†’ row gone
3. Re-assign: INSERT (order_id=1, tag_id=10) β†’ OK, no conflict

Every query joining a soft-deleted junction table must filter out deleted rows:

-- Every JOIN requires this extra condition
SELECT * FROM orders o
JOIN orders_tags ot ON ot.order_id = o.id AND ot.is_deleted = false
JOIN tags t ON t.id = ot.tag_id

Miss a single AND ot.is_deleted = false and deleted associations reappear as valid data β€” a silent bug.

With hard delete, the join is always clean:

SELECT * FROM orders o
JOIN orders_tags ot ON ot.order_id = o.id
JOIN tags t ON t.id = ot.tag_id

TypeORM’s built-in Many-to-Many tools (addAndRemove(), cascade save) operate with hard delete on the junction table. Fighting this default requires workarounds that break standard ORM usage patterns.


Use soft delete only when the junction table has evolved into an entity with its own business data:

-- This is no longer a pure junction table β€” it's an Assignable entity
CREATE TABLE employee_projects (
employee_id UUID NOT NULL,
project_id UUID NOT NULL,
assigned_by UUID, -- extra business data
assigned_date DATE, -- extra business data
unassigned_reason TEXT, -- extra business data
is_deleted BOOLEAN DEFAULT false,
deleted_at TIMESTAMPTZ
);

When a table carries columns beyond the two FKs, it has transitioned from a junction table to a domain entity that deserves soft-delete treatment for audit purposes.

Does the junction table have columns beyond the two foreign keys?
β”‚
β”œβ”€ NO β†’ Pure junction table β†’ Hard Delete βœ…
β”‚
└─ YES β†’ Has business data β†’ Soft Delete βœ…
(treat it as a domain entity, not a pivot)

_syncManyToManyRelations() follows this rule by using TypeORM’s addAndRemove(), which performs hard delete on junction rows. The method also accounts for soft-deleted records on the related entity side (not the junction itself) to avoid re-adding associations to deleted records:

const hasSoftDelete = inverseEntity.columns.some((c) => c.propertyName === 'is_deleted');
const currentIds = hasSoftDelete
? rawItems.filter((item) => item.is_deleted !== true).map((i) => i.id)
: rawItems.map((i) => i.id);

When a PATCH endpoint receives a payload containing child records, the system must answer two questions independently:

Dimension 1: Should orphans be deleted?
↓
Dimension 2: How should they be deleted?

Dimension 1 β€” Interpreting the Payload (Delete or Keep?)

Section titled β€œDimension 1 β€” Interpreting the Payload (Delete or Keep?)”

REST PATCH semantics distinguish between two client intents:

PATCH /orders/:id
{ "name": "Updated Order Name" }

The client said nothing about items. The correct interpretation is β€œdon’t touch my children” β€” this is a partial update. The absence of a key must not trigger deletion.

Client intent: "Update only the name. Leave everything else as-is."
System action: No orphan logic. All children remain in DB.
PATCH /orders/:id
{ "items": [] }

The client deliberately sent an empty array. This is an explicit statement of intent β€” β€œI want zero items for this order.” The system must honour this by removing all existing children.

Client intent: "Clear all items."
System action: Apply orphan deletion strategy to all existing children.
PATCH /orders/:id
{ "items": [{ "id": "item-A", "is_primary": true }] }

The client specified the complete desired set. Children present in DB but absent from the payload are orphans that must be handled.

Client intent: "The final state should have only item-A."
System action: Update item-A, handle item-B (orphan) per deletion strategy.

Dimension 2 β€” Hard Delete vs Soft Delete for Orphans

Section titled β€œDimension 2 β€” Hard Delete vs Soft Delete for Orphans”

Once the system determines that children must be removed, the deletion strategy depends on the nature of the child entity:

Use Hard Delete whenUse Soft Delete when
Child is a value object (meaningless without parent)Child has independent business value
No audit or history requirementsNeeded for audit trails or compliance
No foreign keys pointing to this child from other tablesOther tables reference this child by FK
Re-creation is safe (idempotent)Historical record must be preserved
Examples: gallery images, temporary tagsExamples: order items, assignment logs, approval records

IUpdateOptions encodes the safe default:

export class IUpdateOptions {
hardDeleteOneToMany?: boolean = false; // destructive β€” opt-in only
softDeleteOneToMany?: boolean = true; // safe default β€” preserves history
}

Note: If the children key is omitted from the DTO, orphan handling doesn’t run at all β€” this is the default safe behavior. Omitted keys already achieve this via REST PATCH semantics.

softDeleteOneToMany = true by default because most child entities in an enterprise system (orders, assignments, audit logs) carry business value that must not be permanently erased.


PATCH request received with child data in payload
β”‚
β”œβ”€β”€ Child key OMITTED from payload?
β”‚ └── YES β†’ No orphan handling. Children untouched. (REST PATCH semantics)
β”‚ (Example: { "name": "New Name" } β€” items key not present)
β”‚
└── Child key PRESENT in payload?
β”‚
β”œβ”€β”€ Is it an empty array?
β”‚ └── YES β†’ All existing children must be deleted
β”‚ (Explicit client intent: "clear all children")
β”‚
└── Is it an array with items?
β”‚
└── YES β†’ Find orphans (in DB but not in array)
β”‚
β”œβ”€β”€ Are there orphans?
β”‚ β”‚
β”‚ └── YES β†’ Choose deletion strategy based on child entity type:
β”‚ β”‚
β”‚ β”œβ”€β”€ Is child a pure value object (no business meaning)?
β”‚ β”‚ └── YES β†’ Hard Delete βœ…
β”‚ β”‚
β”‚ └── Does child have business value / FK references?
β”‚ └── YES β†’ Soft Delete βœ… (preserves history)
β”‚
└── No orphans β†’ All children updated, transaction succeeds

// OrderItem has business value β€” soft delete is correct
await this.orderService.update(id, {
items: [{ id: 'item-A', is_primary: true }]
}, currentUser);
// item-B β†’ is_deleted=true, deleted_reason='delete with cascade'
// Junction table: orders_tags (only 2 FK columns)
// addAndRemove() performs hard delete on junction rows automatically
await this.orderService.update(id, {
tags: [{ id: 'tag-priority' }]
}, currentUser);
// Removed tag junction rows are permanently deleted

Employee β€” Projects (Junction with Business Data, Soft Delete)

Section titled β€œEmployee β€” Projects (Junction with Business Data, Soft Delete)”
// employee_projects has assigned_by, assigned_date, unassigned_reason
// This is a domain entity β€” soft delete preserves assignment history
await this.employeeService.update(id, {
projects: [{ id: 'proj-A' }]
}, currentUser, { softDeleteOneToMany: true });

ScenarioStrategyReason
Pure junction table (M2M pivot)Hard DeleteAvoids duplicate key; clean queries; ORM default
Junction table with business columnsSoft DeleteIt is a domain entity with audit value
OneToMany child β€” value objectHard DeleteNo historical significance
OneToMany child β€” business entitySoft DeletePreserves audit trail and FK integrity
Client omits children keyNo-opREST PATCH: omitted = untouched
Client sends empty array []Delete allExplicit client intent to clear
Client sends partial arrayDelete orphansSync to desired final state