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);
}
}
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.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.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.