Application layer handlers wire the domain model to the outside world. Each handler has exactly one job: receive a command or query, orchestrate domain objects and services to fulfil it, and return the result. The handlers for the classified website cover the full listing lifecycle (create, publish, search, contact) and demonstrate how domain events trigger downstream effects — a listing publication sends an email, a contact request sends a push notification — without coupling the domain to delivery mechanisms.
Command and Query Handlers
// ── Application/Listings/Commands/CreateListingCommandHandler.cs ──────────
public class CreateListingCommandHandler
: IRequestHandler<CreateListingCommand, Guid>
{
private readonly IListingRepository _repo;
private readonly IUnitOfWork _uow;
private readonly ICurrentUserService _user;
private readonly IBlobStorage _storage;
public async Task<Guid> Handle(
CreateListingCommand cmd, CancellationToken ct)
{
var listing = Listing.Create(
cmd.Title, cmd.Description,
new Money(cmd.Price, cmd.Currency),
new Location(cmd.City, cmd.Postcode),
cmd.Category,
_user.UserId!);
// Upload photos if provided (before saving, so we have URLs)
foreach (var photo in cmd.Photos ?? [])
{
var url = await _storage.UploadAsync("listings", photo.FileName,
photo.Content, photo.MimeType, ct);
listing.AddPhoto(url, photo.IsPrimary);
}
await _repo.AddAsync(listing, ct);
await _uow.SaveChangesAsync(ct);
return listing.Id;
}
}
// ── Application/Listings/Commands/PublishListingCommandHandler.cs ─────────
public class PublishListingCommandHandler
: IRequestHandler<PublishListingCommand>
{
private readonly IListingRepository _repo;
private readonly IUnitOfWork _uow;
private readonly IPublisher _publisher; // MediatR IPublisher
private readonly ICurrentUserService _user;
public async Task Handle(PublishListingCommand cmd, CancellationToken ct)
{
var listing = await _repo.GetByIdAsync(cmd.ListingId, ct)
?? throw new NotFoundException($"Listing {cmd.ListingId} not found.");
if (listing.OwnerId != _user.UserId)
throw new ForbiddenException("Only the listing owner can publish.");
listing.Publish(); // domain logic: sets status, expiry, raises event
await _uow.SaveChangesAsync(ct); // save first
// Dispatch domain events AFTER successful save
foreach (var evt in listing.DomainEvents)
await _publisher.Publish(evt, ct);
listing.ClearDomainEvents();
}
}
// ── Application/Listings/Queries/SearchListingsQueryHandler.cs ───────────
public class SearchListingsQueryHandler
: IRequestHandler<SearchListingsQuery, PagedResult<ListingSummaryDto>>
{
private readonly AppDbContext _db; // direct EF Core access for read model
public async Task<PagedResult<ListingSummaryDto>> Handle(
SearchListingsQuery q, CancellationToken ct)
{
var query = _db.Listings
.AsNoTracking()
.Where(l => l.Status == ListingStatus.Active);
if (!string.IsNullOrWhiteSpace(q.Keyword))
query = query.Where(l =>
EF.Functions.Like(l.Title, $"%{q.Keyword}%") ||
EF.Functions.Like(l.Description, $"%{q.Keyword}%"));
if (q.Category.HasValue)
query = query.Where(l => l.Category == q.Category.Value);
if (!string.IsNullOrWhiteSpace(q.City))
query = query.Where(l =>
EF.Functions.Like(l.Location.City, $"%{q.City}%"));
if (q.MinPrice.HasValue)
query = query.Where(l => l.Price.Amount >= q.MinPrice.Value);
if (q.MaxPrice.HasValue)
query = query.Where(l => l.Price.Amount <= q.MaxPrice.Value);
var total = await query.CountAsync(ct);
var items = await query
.OrderByDescending(l => l.PublishedAt)
.Skip((q.Page - 1) * q.PageSize)
.Take(q.PageSize)
.Select(l => new ListingSummaryDto(
l.Id, l.Title,
l.Price.Amount, l.Price.Currency,
l.Location.City, l.Category,
l.Photos.OrderBy(p => !p.IsPrimary)
.Select(p => p.Url)
.FirstOrDefault(),
l.Photos.Count,
l.PublishedAt!.Value))
.ToListAsync(ct);
return PagedResult<ListingSummaryDto>.Create(items, total, q.Page, q.PageSize);
}
}
// ── Domain event notification handler ────────────────────────────────────
public class SendPublishedEmailHandler
: INotificationHandler<ListingPublishedEvent>
{
private readonly IEmailService _email;
private readonly IUserRepository _users;
public async Task Handle(ListingPublishedEvent evt, CancellationToken ct)
{
var owner = await _users.GetByIdAsync(evt.OwnerId, ct);
if (owner is null) return;
await _email.SendAsync(
to: owner.Email,
subject: "Your listing is now live!",
body: $"Your listing '{evt.Title}' at {evt.Price} is now visible to buyers.",
ct: ct);
}
}
SearchListingsQueryHandler uses AppDbContext directly — bypassing the repository and specification patterns. This is intentional for the read side: query handlers optimise for read performance, using raw EF Core with AsNoTracking(), Select() projections, and full-text search functions. The repository pattern (used for commands) is designed for loading, modifying, and saving aggregates — it’s not the right abstraction for efficient search queries with pagination and multiple optional filters.SaveChangesAsync() — not before. The pattern is: mutate the aggregate (which raises domain events internally), save the aggregate to the database, then dispatch the events. This ensures atomicity: if the save fails, no events are dispatched. The listing was never actually published (from the database’s perspective), so the “listing published” email is never sent. Dispatching events before saving creates a window where the email is sent but the database save fails — a hard-to-debug inconsistency.EF.Functions.Like() with a wildcard prefix (%keyword%) cannot use database indexes — it requires a full table scan. For production classified websites with millions of listings, this is too slow. Use SQL Server’s full-text search (EF.Functions.Contains()) or Azure Cognitive Search for proper keyword search. The Like approach is acceptable for development and small datasets, but profile against realistic data volumes before going live.Common Mistakes
Mistake 1 — Dispatching domain events before SaveChangesAsync (inconsistency risk)
❌ Wrong — _publisher.Publish(event) before _uow.SaveChangesAsync(); email sent but save fails; listing not published but user notified it was.
✅ Correct — save first; dispatch events only after successful save.
Mistake 2 — Using repository pattern for search queries (loses EF Core query optimisation)
❌ Wrong — search query loaded via IListingRepository.ListAsync(spec); returns full entities; maps to DTOs after load; loads all columns.
✅ Correct — search query handler uses DbContext directly with Select() projection; only loads columns needed for the summary DTO.