Complete Domain Model — Listings, Users and Contact Requests

📋 Table of Contents
  1. Complete Domain Model
  2. Common Mistakes

The complete Domain layer for the classified website captures everything the business cares about: listings in various lifecycle states, contact requests between buyers and sellers, and the rules that govern these interactions. The specification pattern provides composable, testable query objects that express domain intent (“all active listings in category X near city Y”) without coupling the domain to EF Core’s LINQ API — the Infrastructure layer translates specifications to database queries.

Complete Domain Model

// ── Domain/Entities/ContactRequest.cs ─────────────────────────────────────
public sealed class ContactRequest
{
    public Guid      Id         { get; private set; }
    public Guid      ListingId  { get; private set; }
    public string    SenderId   { get; private set; } = default!;
    public string    Message    { get; private set; } = default!;
    public ContactRequestStatus Status { get; private set; }
    public string?   ReplyMessage { get; private set; }
    public DateTime  SentAt     { get; private set; }
    public DateTime? RepliedAt  { get; private set; }

    private ContactRequest() { }

    public static ContactRequest Send(
        Guid listingId, string senderId, string message)
    {
        if (string.IsNullOrWhiteSpace(message))
            throw new DomainException("Contact message cannot be empty.");
        if (message.Length > 1000)
            throw new DomainException("Contact message cannot exceed 1000 characters.");

        return new ContactRequest
        {
            Id        = Guid.NewGuid(),
            ListingId = listingId,
            SenderId  = senderId,
            Message   = message.Trim(),
            Status    = ContactRequestStatus.Pending,
            SentAt    = DateTime.UtcNow,
        };
    }

    public void Reply(string replyMessage)
    {
        if (Status != ContactRequestStatus.Pending)
            throw new DomainException("Can only reply to pending contact requests.");
        if (string.IsNullOrWhiteSpace(replyMessage))
            throw new DomainException("Reply message cannot be empty.");

        ReplyMessage = replyMessage.Trim();
        Status       = ContactRequestStatus.Replied;
        RepliedAt    = DateTime.UtcNow;
    }
}

// ── Domain/Specifications/ISpecification.cs ───────────────────────────────
public interface ISpecification<T>
{
    Expression<Func<T, bool>> Criteria { get; }
    List<Expression<Func<T, object>>> Includes { get; }
    Expression<Func<T, object>>? OrderBy { get; }
    bool IsDescending { get; }
    int? Skip { get; }
    int? Take { get; }
}

// ── Domain/Specifications/ActiveListingsInCategorySpec.cs ─────────────────
public class ActiveListingsInCategorySpec : BaseSpecification<Listing>
{
    public ActiveListingsInCategorySpec(
        Category? category, string? city, decimal? maxPrice)
    {
        AddCriteria(l => l.Status == ListingStatus.Active);

        if (category.HasValue)
            AddCriteria(l => l.Category == category.Value);

        if (!string.IsNullOrWhiteSpace(city))
            AddCriteria(l => l.Location.City.ToLower()
                              .Contains(city.ToLower()));

        if (maxPrice.HasValue)
            AddCriteria(l => l.Price.Amount <= maxPrice.Value);

        ApplyOrderByDescending(l => l.PublishedAt!);
        AddInclude(l => l.Photos);
    }
}

// ── Domain/Repositories/IListingRepository.cs ────────────────────────────
public interface IListingRepository
{
    Task<Listing?>              GetByIdAsync(Guid id, CancellationToken ct);
    Task<IReadOnlyList<Listing>> ListAsync(ISpecification<Listing> spec, CancellationToken ct);
    Task<int>                   CountAsync(ISpecification<Listing> spec, CancellationToken ct);
    Task                        AddAsync(Listing listing, CancellationToken ct);
    Task                        UpdateAsync(Listing listing, CancellationToken ct);
    Task                        DeleteAsync(Listing listing, CancellationToken ct);  // soft delete
}
Note: The specification pattern encapsulates query logic in named, domain-expressive classes. ActiveListingsInCategorySpec is more readable than an inline LINQ query scattered through a service. Specifications are composable (combine city filter, category filter, and price filter in a single spec object) and testable (evaluate the specification’s criteria expression against an in-memory list without a database). The Infrastructure layer’s repository implementation translates the specification to an EF Core query.
Tip: Use the BaseSpecification<T> abstract base class to provide the AddCriteria(), AddInclude(), and ApplyOrderBy() helper methods, keeping individual specification classes clean. The base class accumulates the criteria expressions and applies them as .Where().Where().Where() in the repository. Each call to AddCriteria() adds an AND condition — the specification represents a conjunction of all the criteria.
Warning: The specification pattern can over-complicate simple queries. For queries that are only used in one place and have no variation, a direct EF Core LINQ expression in the repository or query handler is cleaner. Apply specifications for queries that: (1) have multiple optional filters, (2) are used in multiple places, or (3) need to be testable in isolation. The classified website’s listing search (with 5+ optional filters) is a good specification candidate; fetching a listing by its primary key is not.

Common Mistakes

Mistake 1 — Domain entities with public setters (invariants bypass-able)

❌ Wrong — public ListingStatus Status { get; set; }; any code can set Status = Sold without going through domain logic.

✅ Correct — private setter; status transitions only through named methods (Publish, Expire, MarkAsSold) that enforce invariants.

Mistake 2 — Specification criteria using string-based property names (no compile-time safety)

❌ Wrong — specification using reflection or string names for properties; renamed properties compile but specs break at runtime.

✅ Correct — Expression<Func<T, bool>> typed criteria; renamed properties cause compile errors; refactoring safe.

🧠 Test Yourself

Two AddCriteria() calls are made on a specification: one for active status, one for city. In the repository, these translate to .Where(criteria1).Where(criteria2). What does this return?