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
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.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.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.