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