Infrastructure — EF Core Schema, Repositories and Migrations

The Infrastructure layer translates between the clean domain model and the messy reality of SQL Server, EF Core, and external APIs. EF Core entity configurations keep the mapping code separate from the domain entities — domain classes have no [Column], [Table], or [ForeignKey] attributes. Value objects (Money, Location) are configured as EF Core owned entities — they are stored in the same table as the aggregate but are represented as proper value objects in the domain model.

EF Core Configuration and Repositories

// ── Infrastructure/Persistence/Configurations/ListingConfiguration.cs ─────
public class ListingConfiguration : IEntityTypeConfiguration<Listing>
{
    public void Configure(EntityTypeBuilder<Listing> builder)
    {
        builder.ToTable("Listings");
        builder.HasKey(l => l.Id);

        builder.Property(l => l.Title)
               .HasMaxLength(200).IsRequired();

        builder.Property(l => l.Description)
               .HasMaxLength(5000).IsRequired();

        builder.Property(l => l.Status)
               .HasConversion<string>()
               .HasMaxLength(20);

        builder.Property(l => l.Category)
               .HasConversion<string>()
               .HasMaxLength(50);

        // ── Value object: Money (owned entity) ────────────────────────────
        builder.OwnsOne(l => l.Price, m =>
        {
            m.Property(p => p.Amount)
             .HasColumnName("PriceAmount")
             .HasColumnType("decimal(18,2)").IsRequired();
            m.Property(p => p.Currency)
             .HasColumnName("PriceCurrency")
             .HasMaxLength(3).IsRequired();
        });

        // ── Value object: Location (owned entity) ─────────────────────────
        builder.OwnsOne(l => l.Location, loc =>
        {
            loc.Property(p => p.City)
               .HasColumnName("City").HasMaxLength(100).IsRequired();
            loc.Property(p => p.Postcode)
               .HasColumnName("Postcode").HasMaxLength(10).IsRequired();
        });

        // ── Navigation: Photos (owned collection) ─────────────────────────
        builder.OwnsMany(l => l.Photos, photo =>
        {
            photo.ToTable("ListingPhotos");
            photo.WithOwner().HasForeignKey("ListingId");
            photo.Property(p => p.Url).HasMaxLength(500).IsRequired();
        });

        // ── Soft delete: global query filter ─────────────────────────────
        builder.Property<bool>("IsDeleted").HasDefaultValue(false);
        builder.HasQueryFilter(l => !EF.Property<bool>(l, "IsDeleted"));

        // ── Indexes ───────────────────────────────────────────────────────
        builder.HasIndex(l => l.Status);
        builder.HasIndex(l => l.Category);
        builder.HasIndex(l => l.OwnerId);
        builder.HasIndex(l => l.PublishedAt);
    }
}

// ── Infrastructure/Persistence/Repositories/ListingRepository.cs ──────────
public class ListingRepository : IListingRepository
{
    private readonly AppDbContext _db;
    public ListingRepository(AppDbContext db) => _db = db;

    public async Task<Listing?> GetByIdAsync(Guid id, CancellationToken ct)
        => await _db.Listings
                    .Include(l => l.Photos)
                    .FirstOrDefaultAsync(l => l.Id == id, ct);

    public async Task<IReadOnlyList<Listing>> ListAsync(
        ISpecification<Listing> spec, CancellationToken ct)
    {
        var query = ApplySpecification(spec);
        return await query.ToListAsync(ct);
    }

    public async Task<int> CountAsync(
        ISpecification<Listing> spec, CancellationToken ct)
        => await ApplySpecification(spec).CountAsync(ct);

    public async Task AddAsync(Listing listing, CancellationToken ct)
        => await _db.Listings.AddAsync(listing, ct);

    public Task UpdateAsync(Listing listing, CancellationToken ct)
    {
        _db.Listings.Update(listing);
        return Task.CompletedTask;
    }

    public Task DeleteAsync(Listing listing, CancellationToken ct)
    {
        // Soft delete — set shadow property
        _db.Entry(listing).Property("IsDeleted").CurrentValue = true;
        return Task.CompletedTask;
    }

    private IQueryable<Listing> ApplySpecification(ISpecification<Listing> spec)
    {
        var query = _db.Listings.AsQueryable();

        if (spec.Criteria is not null)
            query = query.Where(spec.Criteria);

        foreach (var include in spec.Includes)
            query = query.Include(include);

        if (spec.OrderBy is not null)
            query = spec.IsDescending
                ? query.OrderByDescending(spec.OrderBy)
                : query.OrderBy(spec.OrderBy);

        if (spec.Skip.HasValue) query = query.Skip(spec.Skip.Value);
        if (spec.Take.HasValue) query = query.Take(spec.Take.Value);

        return query;
    }
}
Note: EF Core owned entities (OwnsOne, OwnsMany) are the correct way to map value objects — they are stored in the owner’s table (or a child table) but accessed as strongly-typed objects in C#. The Money value object becomes two columns (PriceAmount, PriceCurrency) in the Listings table, but in the domain model it is a single Money object with validation logic. EF Core handles the decomposition and recomposition automatically on save and load.
Tip: Shadow properties (builder.Property<bool>("IsDeleted")) allow EF Core to track infrastructure concerns (soft delete flags, audit timestamps) without polluting the domain entity with database-specific fields. The domain entity has no IsDeleted property — it is set and read via EF Core’s shadow property API. This keeps the domain model clean. The alternative — adding public bool IsDeleted { get; set; } to Listing — would pollute the domain with an infrastructure concern.
Warning: The global query filter (HasQueryFilter(l => !IsDeleted)) is applied automatically to all DbContext.Listings queries — including those in query handlers and repositories. This means a soft-deleted listing is invisible everywhere in the application without explicitly calling IgnoreQueryFilters(). For admin endpoints (that need to see deleted listings), or for verifying soft-delete in integration tests, always use IgnoreQueryFilters() explicitly and document why.

Common Mistakes

Mistake 1 — Value objects as separate tables (excessive joins for common queries)

❌ Wrong — Money in a separate MoneyValues table; every listing query requires a JOIN to load price; poor performance.

✅ Correct — Money as OwnsOne (columns in Listings table); no JOIN needed; domain value object loaded automatically.

Mistake 2 — Soft delete in the entity instead of as a shadow property (domain pollution)

❌ Wrong — public bool IsDeleted { get; set; } on the Listing entity; domain entity carries an infrastructure concern.

✅ Correct — shadow property in EF Core configuration; domain entity has no IsDeleted; all deletion logic is in Infrastructure.

🧠 Test Yourself

An integration test calls DELETE /api/listings/{id} and then GET /api/listings/{id}. The GET returns 404. Meanwhile, db.Listings.IgnoreQueryFilters().Count() returns 1. What does this confirm?