API Controllers — Thin Adapters Over MediatR

📋 Table of Contents
  1. Thin MediatR Controllers
  2. Common Mistakes

ASP.NET Core controllers in Clean Architecture are intentionally thin — their only job is to map HTTP requests to MediatR commands/queries and HTTP responses. A well-structured controller action is literally one line: return Ok(await mediator.Send(new GetListingByIdQuery(id))). Authentication, validation, error handling, and business logic all live in the Application layer — the controller is just the HTTP adapter.

Thin MediatR Controllers

// ── Api/Controllers/ListingsController.cs ─────────────────────────────────
[ApiController, Route("api/listings")]
public class ListingsController : ControllerBase
{
    private readonly IMediator _mediator;
    public ListingsController(IMediator mediator) => _mediator = mediator;

    // ── GET /api/listings — search ────────────────────────────────────────
    [HttpGet]
    public async Task<ActionResult<PagedResult<ListingSummaryDto>>> Search(
        [FromQuery] string?   keyword  = null,
        [FromQuery] Category? category = null,
        [FromQuery] string?   city     = null,
        [FromQuery] decimal?  minPrice = null,
        [FromQuery] decimal?  maxPrice = null,
        [FromQuery] int       page     = 1,
        [FromQuery] int       pageSize = 20,
        CancellationToken ct           = default)
        => Ok(await _mediator.Send(
            new SearchListingsQuery(keyword, category, city,
                                    minPrice, maxPrice, page, pageSize), ct));

    // ── GET /api/listings/{id} ─────────────────────────────────────────────
    [HttpGet("{id:guid}")]
    public async Task<ActionResult<ListingDto>> GetById(
        Guid id, CancellationToken ct)
    {
        var result = await _mediator.Send(new GetListingByIdQuery(id), ct);
        return result is null ? NotFound() : Ok(result);
    }

    // ── POST /api/listings — create [Authorize] ───────────────────────────
    [HttpPost, Authorize]
    public async Task<ActionResult<Guid>> Create(
        [FromBody] CreateListingRequest request, CancellationToken ct)
    {
        var id = await _mediator.Send(new CreateListingCommand(
            request.Title, request.Description, request.Price,
            request.Currency, request.City, request.Postcode,
            request.Category), ct);

        return CreatedAtAction(nameof(GetById), new { id }, id);
    }

    // ── PATCH /api/listings/{id}/publish [Authorize] ──────────────────────
    [HttpPatch("{id:guid}/publish"), Authorize]
    public async Task<IActionResult> Publish(Guid id, CancellationToken ct)
    {
        await _mediator.Send(new PublishListingCommand(id), ct);
        return NoContent();
    }

    // ── PATCH /api/listings/{id}/expire [Authorize] ───────────────────────
    [HttpPatch("{id:guid}/expire"), Authorize]
    public async Task<IActionResult> Expire(Guid id, CancellationToken ct)
    {
        await _mediator.Send(new ExpireListingCommand(id), ct);
        return NoContent();
    }

    // ── DELETE /api/listings/{id} [Authorize] ─────────────────────────────
    [HttpDelete("{id:guid}"), Authorize]
    public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
    {
        await _mediator.Send(new DeleteListingCommand(id), ct);
        return NoContent();
    }

    // ── POST /api/listings/{id}/contact [Authorize] ───────────────────────
    [HttpPost("{id:guid}/contact"), Authorize]
    [RateLimit(PermitLimit = 5, Window = "1m")]  // prevent contact spam
    public async Task<IActionResult> SendContactRequest(
        Guid id, [FromBody] SendContactRequestRequest request,
        CancellationToken ct)
    {
        await _mediator.Send(new SendContactRequestCommand(
            id, request.Message), ct);
        return Accepted();  // 202 — async processing (email notification)
    }
}

// ── Program.cs — exception-to-HTTP mapping ────────────────────────────────
// The global exception handler (from Part 7) maps domain exceptions:
// NotFoundException   → 404
// ForbiddenException  → 403
// DomainException     → 400
// ValidationException → 422 (with errors dictionary)
// All others          → 500
Note: The SendContactRequest endpoint returns 202 Accepted (not 200 OK) because the response is intentionally asynchronous — the contact request is saved to the database synchronously, but the email notification to the listing owner is triggered asynchronously via the domain event and notification handler. The 202 signals to the client that the request was received and is being processed, but the full effect (email delivered) may not have happened yet. This is the correct REST semantic for async-triggered side effects.
Tip: The controller has exactly one responsibility: map HTTP verbs to MediatR messages. If you find yourself writing if/else, null checks, or any business logic in the controller, that logic belongs in the command/query handler instead. A controller should never contain domain knowledge. The cleaner the controller, the easier it is to add new API endpoints (mobile app, webhooks, background jobs) that reuse the same handlers without duplicating business logic.
Warning: Rate limiting on the contact request endpoint prevents users from spamming sellers with automated messages. Without rate limiting, a bad actor could send hundreds of contact messages programmatically to all active listings — DDoS for sellers’ inboxes. ASP.NET Core’s built-in rate limiting middleware (from .NET 8) applies a 5-requests-per-minute limit per user (keyed by JWT user ID) on the contact endpoint specifically. Apply more aggressive limits on unauthenticated endpoints.

Common Mistakes

Mistake 1 — Business logic in the controller (defeats Clean Architecture)

❌ Wrong — controller checks if the listing belongs to the user before sending the command; ownership check duplicated in controller and handler.

✅ Correct — controller sends the command; handler checks ownership via ICurrentUserService; throws ForbiddenException.

Mistake 2 — Returning 200 instead of 202 for async operations

❌ Wrong — 200 OK on contact request; implies all effects (email delivered) are complete; misleads the client.

✅ Correct — 202 Accepted; request accepted and being processed; client knows the email delivery is async.

🧠 Test Yourself

The controller sends new DeleteListingCommand(id). The handler throws ForbiddenException because the current user doesn’t own the listing. What HTTP status does the client receive?