Resource Design Patterns · Lesson 07
Soft deletion & request validation
Deleting data is easy; recovering it after an accidental delete is painful. Soft deletion separates the logical act of deletion from the physical act of destruction — giving users a recycle bin and giving you a safety net. Pair it with validate_only, and clients can rehearse any mutation before they commit it.
By the end you'll be able to
- Design a soft-delete lifecycle with
delete_time,:undelete,:expunge, andshow_deletedon the Tasks API. - Implement a
validate_onlyflag so clients can dry-run mutations without side effects. - Explain when to use soft deletion versus hard deletion, and how recycle-bin expiry works in production systems.
The recycle-bin model: why real systems don't hard-delete by default
Imagine a developer calls DELETE /v1/projects/proj_42/tasks/task_99 on a Friday afternoon. The task disappears. Two hours later the team realizes it was the only record of a regulatory requirement. Hard deletion has no undo — the data is gone, and the only recovery path is a database restore that costs hours of downtime and risks losing all other work done since the last backup.
Soft deletion solves this by making DELETE mean "move to the recycle bin," not "destroy immediately." The row stays in the database; a delete_time timestamp is stamped on it; normal queries filter it out so it is invisible to day-to-day operations. At any point within a retention window — 30 days is a common default — a user can restore the resource. After the window expires (or immediately if a privileged caller uses :expunge), the data is permanently destroyed.
Google Cloud APIs codify this in AIP-164. The pattern appears in Google Drive, Gmail (the Trash folder), and many internal Google services. Most SaaS products that handle business data implement a variant of it.
The soft-delete lifecycle
Modeling soft deletion on the Tasks API
A soft-deleted task carries two new fields on the resource body:
delete_time— an RFC 3339 timestamp set when the task is soft-deleted. Null (omitted) on active tasks.expire_time— an RFC 3339 timestamp after which the server will automatically expunge the task. Typicallydelete_time + 30 days. Optional — some APIs omit it and rely on a server-side policy instead.
The standard GET, LIST, UPDATE, and PATCH methods all implicitly filter out deleted resources. You must pass show_deleted=true to surface them in LIST. GET on a deleted resource returns it (the caller knows the ID), but PATCH and UPDATE on a deleted resource return 400 FAILED_PRECONDITION — you cannot edit a deleted resource, only undelete or expunge it.
Worked example: the full soft-delete lifecycle on Tasks
Step 1 — Soft-delete a task
# Standard DELETE — no body required
DELETE /v1/projects/proj_42/tasks/task_99
Authorization: Bearer <token>
----- response 200 OK -----
{
"name": "projects/proj_42/tasks/task_99",
"title": "Finalize compliance checklist",
"status": "OPEN",
"delete_time": "2026-06-20T14:00:00Z",
"expire_time": "2026-07-20T14:00:00Z"
}
The server returns the updated resource body — not 204 No Content — so the caller can confirm the delete_time and expire_time. (Some implementations return 204; returning the resource is friendlier for clients.)
Step 2 — List with show_deleted to confirm it is in the bin
GET /v1/projects/proj_42/tasks?show_deleted=true
Authorization: Bearer <token>
----- response 200 OK -----
{
"tasks": [
{
"name": "projects/proj_42/tasks/task_01",
"title": "Update onboarding docs",
"status": "OPEN"
},
{
"name": "projects/proj_42/tasks/task_99",
"title": "Finalize compliance checklist",
"status": "OPEN",
"delete_time": "2026-06-20T14:00:00Z",
"expire_time": "2026-07-20T14:00:00Z"
}
],
"next_page_token": ""
}
Step 3 — Restore with :undelete
POST /v1/projects/proj_42/tasks/task_99:undelete
Authorization: Bearer <token>
Content-Type: application/json
{}
----- response 200 OK -----
{
"name": "projects/proj_42/tasks/task_99",
"title": "Finalize compliance checklist",
"status": "OPEN",
"delete_time": null,
"expire_time": null
}
Both delete_time and expire_time are cleared. The task is fully active again, with the same name and all original fields preserved.
Step 4 — Hard-delete with :expunge
# Requires elevated permission — irreversible
POST /v1/projects/proj_42/tasks/task_99:expunge
Authorization: Bearer <admin-token>
Content-Type: application/json
{}
----- response 200 OK -----
{}
# Subsequent GET returns 404 — the row is gone
GET /v1/projects/proj_42/tasks/task_99
→ 404 NOT_FOUND
:expunge is a custom method (per rdp-03) that permanently destroys the row. Require a separate permission scope — e.g. tasks.expunge — so the ability to soft-delete does not imply the ability to hard-delete.
Request validation: the validate_only flag
Sometimes a client needs to know whether a request would succeed — before committing side effects. A new task form might want to validate the assignee and due date in real time without creating a draft. A bulk import tool might want to pre-check 500 rows for errors before writing any of them.
The validate_only flag (see AIP-163) solves this. When a request includes "validate_only": true in the body (or as a query parameter), the server runs all validation — schema checks, business rules, permission checks — and then stops. It neither creates nor modifies anything. If validation passes, it returns 200 OK with an empty body (or the resource as it would appear if the request were committed). If validation fails, it returns the same errors it would return for a real request.
Think of it like a spell-checker that runs on your draft without sending the email. You see exactly the errors you would get, at the exact time you would get them, with zero risk of accidental mutation.
Worked example: validate_only on CreateTask
# Dry-run: will this task be accepted?
POST /v1/projects/proj_42/tasks
Authorization: Bearer <token>
Content-Type: application/json
{
"validate_only": true,
"task": {
"title": "Prepare quarterly report",
"notes": "Include revenue breakdown per region",
"due_time": "2026-07-01T17:00:00Z",
"assignee": "users/alice",
"labels": ["finance", "q2"]
}
}
----- response 200 OK (nothing created) -----
{}
# Now try with a bad assignee
{
"validate_only": true,
"task": {
"title": "Prepare quarterly report",
"assignee": "users/doesnt-exist"
}
}
----- response 400 INVALID_ARGUMENT -----
{
"error": {
"code": 400,
"status": "INVALID_ARGUMENT",
"message": "Field 'assignee': user 'users/doesnt-exist' not found."
}
}
Under the hood: how it actually works
Soft delete mechanics in the database
The simplest implementation adds a nullable deleted_at column (matching delete_time) and a nullable expires_at column to the resource table. A server-side predicate WHERE deleted_at IS NULL is appended to every standard SELECT except those with show_deleted=true. Almost all major ORM frameworks support this via a global scope — e.g. Rails' default_scope { where(deleted_at: nil) }.
The hard part is not the delete — it is the unique-constraint problem. If you soft-delete a task named "Finalize Q2 report" and then create a new one with the same title in the same project, you now have two rows with the same business identity. Design your uniqueness constraints to include deleted_at in the index (partial index WHERE deleted_at IS NULL), or use a separate "tombstone" table to track deleted names, to prevent ghost-name collisions.
Automatic expiry: a background sweeper
A separate background job (a cron or a message queue consumer) runs periodically — say, every hour — and executes a query like:
This decouples the expiry logic from the request path. The :expunge custom method is the user-triggered version of the same operation, run immediately and on demand.
validate_only implementation pattern
The cleanest implementation extracts all validation logic into a validator function that returns errors without touching the database. The create handler calls: (1) validate(request); (2) if validate_only == true, return 200 {}; (3) otherwise persist(request).
The key guarantee is that validation in validateCreateTask is identical whether or not validate_only is set. Any drift between the dry-run and real paths defeats the purpose of the flag — the client would get a clean dry-run and then a real-run error. This is a common bug in naive implementations.
Related pattern: resource revisions
Soft deletion is about lifecycle. A related but distinct pattern is resource revisions (change history): keeping a numbered snapshot of every version of a resource so you can view or restore any past state — not just "deleted vs active." Revisions are covered in AIP-162. They pair well with soft deletion: an accidental update to a task can be reversed by restoring a revision, while an accidental delete is reversed by :undelete. Together they give users a complete undo history.
"How would you implement data recovery for your API?" is a common design question. Walk through the soft-delete lifecycle: mark-deleted with a timestamp, retention window, undelete method, and background expiry sweeper. Then mention the uniqueness-constraint edge case — it signals you have thought about the implementation, not just the interface. Add validate_only as a bonus to show you think about the client developer experience too.
Foreign key references to soft-deleted rows. If task_99 is soft-deleted but another resource (say, a comment or a dependency) still has a foreign key pointing to it, re-activating the task might resurrect those relationships in an inconsistent state. Decide upfront whether soft-deleting a parent cascades to children (cascade-soft-delete) or blocks (requires children to be deleted first). Document the choice in your API reference.
Do return the updated resource from DELETE (with delete_time and expire_time set) so clients can display a "deleted — undo within 30 days" banner. Don't return 204 No Content on a soft-delete — the client needs the expiry timestamp to show the user how long they have to recover.
🧠 Quick check
1. A soft-deleted task has delete_time set. What does a standard GET /v1/projects/p/tasks/t return?
GET returns the resource even when it is soft-deleted — the caller already knows the ID. Only LIST silently filters deleted resources unless show_deleted=true. Hard deletion (after expiry or :expunge) is when GET returns 404.
2. A validate_only=true request that passes all validation should return:
AIP-163 specifies 200 OK with an empty body on a successful dry-run. The intent is clear: "validation passed; nothing was persisted." Some implementations return the hypothetical resource body, but the canonical form is an empty response to unambiguously signal no side effects occurred.
3. What is the main risk of implementing validate_only with a separate validation path from the real create path?
If the validate_only path and the real path diverge — even slightly — clients get a false sense of safety. They test with validate_only=true, get 200, then do the real request and hit a new error. The validator function must be exactly the same code called in both paths.
4. Which permission design is correct for :expunge?
Soft delete is reversible; expunge is not. Conflating them into the same permission means any actor who can soft-delete can also irrecoverably destroy data. Require a distinct elevated scope (e.g. tasks.expunge) and audit-log every invocation.
✍️ Exercise: design the retention policy for a multi-tenant Tasks API
Your Tasks API is multi-tenant. Different tenants have different compliance requirements: Tenant A (healthcare) must retain deleted records for 7 years before expunging; Tenant B (startup) is happy with 30 days. Design the expire_time calculation and the schema changes needed to support per-tenant retention policies, without exposing the retention logic to end users in the API surface.
Model answer:
- Tenant config table. Add a
tenantstable with asoft_delete_retention_dayscolumn (integer). Default 30. Healthcare tenants set 2555 (7 years). - Server-side expire_time calculation. When a task is soft-deleted, the server computes
expire_time = delete_time + tenant.soft_delete_retention_days. This is never a client-supplied value — the API response returns the computedexpire_timefor display, but clients cannot set it. - Background sweeper filter. The expiry sweeper queries
WHERE deleted_at IS NOT NULL AND expires_at < NOW()— it works identically regardless of per-tenant retention, because the per-tenant logic is already baked into the storedexpires_at. - :expunge restriction for regulated tenants. For tenants with regulatory hold requirements, disable
:expungeentirely or restrict it to a compliance-admin role. Return403 PERMISSION_DENIEDwith an error message explaining the retention hold.
Rubric: Full marks for addressing all four points. Key insight: the retention window belongs in server-side configuration, not in the API contract — callers cannot influence it. Bonus: mentioning that expire_time should be returned in responses so UIs can display "you have X days to recover this."
Key takeaways
- Soft deletion marks a resource as deleted (via
delete_time) without removing the row. A retention window gives users time to recover before auto-expiry. - The lifecycle has four moments: soft-delete (
DELETE), restore (:undelete), immediate hard-delete (:expunge), and automatic expiry (background sweeper). LISTfilters deleted resources by default; passshow_deleted=trueto surface them.GETreturns a deleted resource;PATCH/UPDATEon a deleted resource fails withFAILED_PRECONDITION.validate_only=trueruns all validation without any write. The same validation code must run in both the dry-run and the real path — divergence makes the feature worthless.- Unique-constraint collisions and foreign-key cascades are the two sharpest edges of soft deletion in practice — design for both before shipping.