On this page
backend backendarchitecturedata-integritypatterns
Two-Phase Deletion Pattern
A safe deletion pattern for systems without rollback capability where external
• 3 min read
API calls must succeed before data is permanently removed.
The Problem
When deleting data that must also be deleted from an external service (e.g., Google Calendar), immediate hard-delete is risky:
// ❌ RISKY: No recovery if Google API fails
async deleteBlock(id: number) {
await this.blockRepo.delete(id); // Gone from DB
await this.googleApi.deleteEvent(id); // What if this fails?
} If the Google API call fails, the data is already gone from the database.
The Solution: Two Phases
Phase 1: Soft-Delete (Service Layer)
Mark records as “tentatively deleted” without removing them:
async deleteBlock(id: number) {
// Soft-delete: set deletedAt, keep record
await this.blockRepo.softRemove(block);
// Queue the external API call
await this.eventQueue.add('delete', { blockId: id });
} Phase 2: Hard-Delete (Queue Processor)
After external API confirms, permanently remove:
async processDelete(job: Job) {
const block = await this.blockRepo.findOne({
where: { id: job.data.blockId },
withDeleted: true, // Include soft-deleted
});
// Confirm with external API
await this.googleApi.deleteEvent(block.gcalId);
// Now safe to hard-delete
await this.blockRepo.delete(block.id);
} Phase 3: Safety Net (Sync Layer)
Cleanup orphans that queue processor missed:
async sync() {
// Detect and clean orphaned records
const orphans = await this.findOrphans();
for (const orphan of orphans) {
this.logger.warn('Orphan detected', { id: orphan.id });
await this.blockRepo.delete(orphan.id);
}
} Flow Diagram
User Request → Service Layer (soft-delete) → Queue Job
↓
Queue Processor
↓
Google API OK?
/
Yes No
↓ ↓
Hard-delete Retry/Alert
↓
Sync Layer (safety net) When to Use
| Scenario | Use Two-Phase? |
|---|---|
| External API required | ✅ Yes |
| Database-only delete | ❌ No (direct delete) |
| No rollback mechanism | ✅ Yes |
| Critical user data | ✅ Yes |
Key Implementation Details
Soft-Delete vs Status Field
For some entities, soft-delete (deletedAt) hides them from queries. But
sometimes you need visibility:
// T blocks need itemStatus=Deleted (visible to client)
// NOT deletedAt (hidden from queries)
if (isTBlock(block)) {
block.itemStatus = BlockStatus.Deleted;
// Do NOT set deletedAt - client needs to see cancelled markers
} else {
await this.blockRepo.softRemove(block);
} Orphan Detection
Track both parent and source relationships:
// Gap 1: Direct children (originalId)
await this.blockRepo.delete({ originalId: deletedBlockId });
// Gap 2: Divergence chain (recurringEventId in JSON)
await this.blockRepo.delete({
googleEventData: { recurringEventId: deletedGcalId },
}); Key Lessons
- Separate concerns - Service layer marks, queue confirms, sync cleans
- Defense in depth - Queue processor + sync layer = double safety
- Log orphans - Visibility into missed cleanups
- Status vs deletion - Different semantics for different use cases
- Backward compatibility - Handle existing bad data incrementally