HTTP DELETE and Special Actions — Soft Delete and Custom Operations

DELETE is the simplest HTTP method — it removes a resource. But many real-world APIs need something more nuanced: soft delete (marking as deleted rather than removing), bulk operations, and custom domain-specific actions that do not fit the CRUD mould (publish, archive, approve, reject). These are handled with thoughtful URL design — using HTTP methods correctly and POST for actions that are genuinely actions rather than resource manipulations.

DELETE and Soft Delete

[ApiController]
[Route("api/posts")]
public class PostsController(IPostService service) : ControllerBase
{
    // ── Hard delete — removes from database ────────────────────────────────
    // DELETE /api/posts/42
    [HttpDelete("{id:int}")]
    [ProducesResponseType(204)]
    [ProducesResponseType(404)]
    public async Task<IActionResult> Delete(int id, CancellationToken ct)
    {
        var deleted = await service.DeleteAsync(id, ct);
        return deleted ? NoContent() : NotFound();
    }

    // ── Soft delete — sets IsDeleted = true, returns updated resource ──────
    // DELETE /api/posts/42  (soft delete variant — same URL, different behaviour)
    [HttpDelete("{id:int}")]
    public async Task<ActionResult<PostDto>> SoftDelete(int id, CancellationToken ct)
    {
        var post = await service.SoftDeleteAsync(id, ct);
        return post is null ? NotFound() : Ok(post);   // 200 with updated resource
    }

    // ── Bulk delete ────────────────────────────────────────────────────────
    // DELETE /api/posts
    // Body: { "ids": [42, 43, 44] }
    [HttpDelete]
    [ProducesResponseType(204)]
    public async Task<IActionResult> BulkDelete(
        [FromBody] BulkDeleteRequest request, CancellationToken ct)
    {
        await service.BulkDeleteAsync(request.Ids, ct);
        return NoContent();
    }

    // ── Custom domain actions — POST because these are operations, not CRUD ──
    // POST /api/posts/42/publish
    [HttpPost("{id:int}/publish")]
    [ProducesResponseType(typeof(PostDto), 200)]
    [ProducesResponseType(404)]
    [ProducesResponseType(typeof(ProblemDetails), 409)]  // already published
    public async Task<ActionResult<PostDto>> Publish(int id, CancellationToken ct)
    {
        var post = await service.PublishAsync(id, ct);
        return post is null ? NotFound() : Ok(post);
    }

    // POST /api/posts/42/archive
    [HttpPost("{id:int}/archive")]
    public async Task<ActionResult<PostDto>> Archive(int id, CancellationToken ct)
    {
        var post = await service.ArchiveAsync(id, ct);
        return post is null ? NotFound() : Ok(post);
    }
}
Note: Custom action endpoints like POST /api/posts/42/publish use POST because publishing a post is a command (an operation with side effects) rather than a resource manipulation. Using PUT or PATCH for this would require the client to know the business logic of what “published” state looks like and send it. Using POST on a sub-resource URL makes the API self-documenting: the URL names the action, and POST signals “trigger this action.” This pattern is common in production APIs: /orders/{id}/cancel, /invoices/{id}/send, /users/{id}/verify.
Tip: When implementing soft delete, decide what GET requests return — should deleted resources return 404 (they are logically gone) or 200 with an "isDeleted": true field (they exist but are deleted)? Most APIs return 404 for soft-deleted resources (consistent with hard delete), but admin APIs may need to retrieve deleted resources. Add a query parameter for this: GET /api/posts/42?includeDeleted=true for admin use cases, returning 404 for standard requests.
Warning: Bulk delete operations can be extremely destructive if not properly scoped. Always validate that all IDs in a bulk delete belong to the requesting user’s tenant or have the correct permissions before deleting. Never implement bulk delete without careful authorisation checks — an attacker who can call bulk delete with arbitrary IDs could delete all resources in the system. Add explicit limits on the number of IDs accepted in a single bulk operation (max 100, for example) to prevent runaway delete operations.

ProblemDetails for Error Responses

// ── Return ProblemDetails for consistent error responses ──────────────────
// ProblemDetails is the RFC 7807 standard for API error responses
// ASP.NET Core has built-in support via Problem() helper

[HttpPost("{id:int}/publish")]
public async Task<ActionResult<PostDto>> Publish(int id, CancellationToken ct)
{
    var post = await service.GetByIdAsync(id, ct);
    if (post is null)
        return Problem(
            statusCode: 404,
            title: "Post not found.",
            detail: $"No post with ID {id} exists.",
            instance: HttpContext.Request.Path);

    if (post.IsPublished)
        return Problem(
            statusCode: 409,
            title: "Post already published.",
            detail: $"Post '{post.Title}' is already in the published state.",
            instance: HttpContext.Request.Path);

    var published = await service.PublishAsync(id, ct);
    return Ok(published);
    // ProblemDetails JSON: { "type": "...", "title": "Post not found.", "status": 404, "detail": "...", "instance": "/api/posts/42/publish" }
}

Common Mistakes

Mistake 1 — Using DELETE with a body for bulk delete when GET with body is unreliable

❌ Unreliable — HTTP DELETE with a body is technically allowed but many proxies and clients strip it.

✅ Correct — use POST /api/posts/bulk-delete with a body, or DELETE /api/posts?ids=42,43,44 with query parameters for bulk deletes.

Mistake 2 — Returning 200 with the deleted resource after DELETE (should be 204)

❌ Wrong — returning the deleted resource in the response body after hard delete is redundant.

✅ Correct — hard delete returns 204 No Content; soft delete returns 200 with the updated (now-deleted) resource.

🧠 Test Yourself

Why is POST /api/posts/42/publish a better URL design than PUT /api/posts/42 with { "isPublished": true }?