API Design Knowledge: Relations, Cascade & Deletion Strategies
Overview
Section titled βOverviewβThis document captures architectural knowledge for two of the most commonly mishandled areas in API design involving relational data:
- Junction Table Management β should
ManyToManypivot tables use hard or soft delete? - 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.
Junction Table Management
Section titled βJunction Table ManagementβWhat is a Junction Table?
Section titled βWhat is a Junction Table?β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 tableCREATE 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) β OK2. Remove tag: UPDATE SET is_deleted=true β "deleted" but row remains3. Re-assign tag: INSERT (order_id=1, tag_id=10) β β DUPLICATE KEY ERRORThe 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) β OK2. Remove: DELETE WHERE (order_id=1, tag_id=10) β row gone3. Re-assign: INSERT (order_id=1, tag_id=10) β OK, no conflictReason 2 β Query Complexity
Section titled βReason 2 β Query ComplexityβEvery query joining a soft-deleted junction table must filter out deleted rows:
-- Every JOIN requires this extra conditionSELECT * FROM orders oJOIN orders_tags ot ON ot.order_id = o.id AND ot.is_deleted = falseJOIN tags t ON t.id = ot.tag_idMiss 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 oJOIN orders_tags ot ON ot.order_id = o.idJOIN tags t ON t.id = ot.tag_idReason 3 β ORM Default Behaviour
Section titled βReason 3 β ORM Default Behaviourβ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.
When Should a Junction Table Use Soft Delete?
Section titled βWhen Should a Junction Table Use Soft Delete?β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 entityCREATE 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.
Decision Rule
Section titled βDecision Ruleβ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)Implementation in this Codebase
Section titled βImplementation in this Codebaseβ_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);Cascade API Design
Section titled βCascade API DesignβThe Two-Dimension Framework
Section titled βThe Two-Dimension Frameworkβ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:
Omitted key β Keep existing children
Section titled βOmitted key β Keep existing childrenβ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.Explicit empty array β Delete all children
Section titled βExplicit empty array β Delete all childrenβ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.Array with items β Sync to desired state
Section titled βArray with items β Sync to desired stateβ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 when | Use Soft Delete when |
|---|---|
| Child is a value object (meaningless without parent) | Child has independent business value |
| No audit or history requirements | Needed for audit trails or compliance |
| No foreign keys pointing to this child from other tables | Other tables reference this child by FK |
| Re-creation is safe (idempotent) | Historical record must be preserved |
| Examples: gallery images, temporary tags | Examples: order items, assignment logs, approval records |
Default Strategy in this Codebase
Section titled βDefault Strategy in this Codebaseβ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.
Full Decision Tree
Section titled βFull Decision Treeβ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 succeedsPractical Examples from this Codebase
Section titled βPractical Examples from this CodebaseβOrder β Line Items (OneToMany, Soft Delete)
Section titled βOrder β Line Items (OneToMany, Soft Delete)β// OrderItem has business value β soft delete is correctawait this.orderService.update(id, { items: [{ id: 'item-A', is_primary: true }]}, currentUser);// item-B β is_deleted=true, deleted_reason='delete with cascade'Order β Tags (ManyToMany, Hard Delete)
Section titled βOrder β Tags (ManyToMany, Hard Delete)β// Junction table: orders_tags (only 2 FK columns)// addAndRemove() performs hard delete on junction rows automaticallyawait this.orderService.update(id, { tags: [{ id: 'tag-priority' }]}, currentUser);// Removed tag junction rows are permanently deletedEmployee β 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 historyawait this.employeeService.update(id, { projects: [{ id: 'proj-A' }]}, currentUser, { softDeleteOneToMany: true });Summary
Section titled βSummaryβ| Scenario | Strategy | Reason |
|---|---|---|
| Pure junction table (M2M pivot) | Hard Delete | Avoids duplicate key; clean queries; ORM default |
| Junction table with business columns | Soft Delete | It is a domain entity with audit value |
| OneToMany child β value object | Hard Delete | No historical significance |
| OneToMany child β business entity | Soft Delete | Preserves audit trail and FK integrity |
| Client omits children key | No-op | REST PATCH: omitted = untouched |
Client sends empty array [] | Delete all | Explicit client intent to clear |
| Client sends partial array | Delete orphans | Sync to desired final state |