Mert Özen Further With Every Line
Software Architecture Clean, Onion and Hexagonal Architecture — #07

Persistence Ignorance and Infrastructure Adapters: EF Core, HttpClient, and External Services

Mert Özen Nov 7, 2026 14 min 5 views
Persistence Ignorance and Infrastructure Adapters: EF Core, HttpClient, and External Services

Persistence ignorance means domain classes don't know which database or ORM they are written to; with EF Core you keep the mapping in Infrastructure through IEntityTypeConfiguration, but rules such as constructor binding and getter-only properties still shape the domain. Wrapping the shipping company's API in a driven adapter with a typed HttpClient, keeping the external model out, and the generic repository and IQueryable trap.

Version note: The code in this post was built and run with C# 14 and .NET 10.

Guess First

Here is the Book class we put in the Domain in post 05:

namespace Bookstore.Domain.Catalog;

public sealed class Book(int id, string title, decimal price, int stock)
{
    public int Id { get; } = id;
    public string Title { get; } = title;
    public decimal Price { get; } = price;
    public int Stock { get; private set; } = stock;

    public bool TryReserve(int quantity)
    {
        if (quantity <= 0 || quantity > Stock) return false;
        Stock -= quantity;
        return true;
    }
}

There is no parameterless constructor, no [Key] attribute, and no setter for Title or Price. The Domain project's .csproj has no Entity Framework Core (EF Core) package either. Meanwhile, BookConfiguration in Infrastructure contains two lines that look unnecessary at first glance:

internal sealed class BookConfiguration : IEntityTypeConfiguration<Book>
{
    public void Configure(EntityTypeBuilder<Book> builder)
    {
        builder.HasKey(b => b.Id);
        builder.Property(b => b.Id).ValueGeneratedNever();
        builder.Property(b => b.Title).HasMaxLength(200);
        builder.Property(b => b.Price);
        builder.Property(b => b.Stock).IsConcurrencyToken(); // two customers can't both take the last copy
    }
}

builder.Property(b => b.Title) and builder.Property(b => b.Price) don't seem to configure anything. First question: what happens if you delete these two lines?

Second question. Someone on the team proposed a generic repository, saying "let's not write a separate repository for every entity":

public interface IRepository<T> where T : class
{
    IQueryable<T> Query();
    void Add(T entity);
}

public sealed class EfRepository<T>(DbContext db) : IRepository<T> where T : class
{
    public IQueryable<T> Query() => db.Set<T>();
    public void Add(T entity) => db.Set<T>().Add(entity);
}

For a reporting screen, this line was written in Application to fetch orders above 100 TRY:

// a query written in Application that compiles but doesn't work
List<Order> bigOrders = await repository.Query().Where(o => o.Total > 100m).ToListAsync();

The code compiles. What happens when it runs?

What Persistence Ignorance Means

Let's save the second question for the end and start with the first. When a domain class is unaware of which database or which ORM (object-relational mapper) it will be written to, we call that persistence ignorance. The architectural principles chapter of Microsoft's "Architect Modern Web Applications with ASP.NET Core and Azure" e-book defines the term as types that need to be persisted but whose code is unaffected by the choice of persistence technology. The same chapter lists typical ways of violating the principle. Let's check Bookstore's Domain against that list:

ViolationIn the Bookstore Domain
A required base classNone; Order and Book derive from nothing
A required interface implementationNone
Classes that save themselves (Active Record)None; saving is IUnitOfWork's job
A required parameterless constructorNone; EF Core uses the constructors from post 05
Properties that must be virtualNone; we don't use lazy loading proxies
Persistence-specific attributesNone; the mapping lives entirely in Infrastructure, and the Domain's .csproj rejects package references at build time

We come out clean on all six. But that doesn't mean EF Core has no say in the shape of the domain. The subject of this post is that invisible agreement: what Infrastructure has to know so the Domain can stay unaware of EF Core, and where the agreement ends.

Mapping Lives in Infrastructure: IEntityTypeConfiguration

Everything the Domain doesn't know is in configuration classes in Infrastructure, written with EF Core's Fluent API (Application Programming Interface). In the EF Core series post "Data Annotations or Fluent API in EF Core? OnModelCreating and IEntityTypeConfiguration" we discussed the reasons for choosing the Fluent API over attributes; in Clean Architecture it stops being a preference and becomes a requirement. The OrderConfiguration from post 05:

using Bookstore.Domain.Ordering;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;

namespace Bookstore.Infrastructure.Persistence.Configurations;

// Mapping lives in Infrastructure; the Order in Domain carries no EF Core attribute
internal sealed class OrderConfiguration : IEntityTypeConfiguration<Order>
{
    public void Configure(EntityTypeBuilder<Order> builder)
    {
        builder.HasKey(o => o.Id);
        builder.Property(o => o.CustomerEmail).HasMaxLength(256);
        builder.Property(o => o.PostalCode).HasMaxLength(10);
        builder.HasIndex(o => o.CustomerEmail);
        builder.Ignore(o => o.Subtotal);
        builder.Ignore(o => o.Total);
        builder.OwnsMany(o => o.Items, items =>
        {
            items.ToTable("OrderItems");
            items.WithOwner().HasForeignKey("OrderId");
            items.Property<int>("Id");
            items.HasKey("Id");
        });
        builder.Navigation(o => o.Items).HasField("_items");
    }
}

Three problems are solved here without touching the domain. The first is the identity of the items. OrderItem behaves like a value object; from the domain's point of view, "1 copy of Dune at 250 TRY" doesn't need an Id. The database, however, needs a primary key. items.Property<int>("Id") and HasForeignKey("OrderId") define two shadow properties: they exist as columns in the table, not in the C# class.

The second is the private list. HasField("_items") tells EF Core to use the _items field rather than the Items property when populating the items. That way the domain hands out a read-only list while EF Core can still fill it. EF Core actually finds a field named _items by convention even without this line; the line makes the rule explicit, and if the field is renamed one day, EF Core stops with a clear error while building the model.

The third is computed values. Subtotal and Total are values the domain computes; with Ignore they stay out of the table. Storing enums as text, strongly typed Ids and writing your own value converters are covered in the EF Core series post "Owned Types and Value Converters in EF Core: Addresses, Enums and Strongly Typed Ids"; what matters here is where the conversion lives. The schema EF Core generates for SQLite:

CREATE TABLE "Books" (
    "Id" INTEGER NOT NULL CONSTRAINT "PK_Books" PRIMARY KEY,
    "Title" TEXT NOT NULL,
    "Price" TEXT NOT NULL,
    "Stock" INTEGER NOT NULL
);
CREATE TABLE "Orders" (
    "Id" INTEGER NOT NULL CONSTRAINT "PK_Orders" PRIMARY KEY AUTOINCREMENT,
    "CustomerEmail" TEXT NOT NULL,
    "PostalCode" TEXT NOT NULL,
    "ShippingFee" TEXT NOT NULL
);
CREATE TABLE "OrderItems" (
    "Id" INTEGER NOT NULL CONSTRAINT "PK_OrderItems" PRIMARY KEY AUTOINCREMENT,
    "BookId" INTEGER NOT NULL,
    "UnitPrice" TEXT NOT NULL,
    "Quantity" INTEGER NOT NULL,
    "OrderId" INTEGER NOT NULL,
    CONSTRAINT "FK_OrderItems_Orders_OrderId" FOREIGN KEY ("OrderId") REFERENCES "Orders" ("Id") ON DELETE CASCADE
);

The OrderItems table has Id and OrderId columns that the C# doesn't. Subtotal and Total are nowhere. One more detail: since SQLite has no decimal type, Price and ShippingFee are stored as TEXT. The Domain doesn't know about that either; we'll come back to why it matters in post 08.

In this post we also added a single line to BookConfiguration: the one that makes Stock a concurrency token. If two customers ask for the same last copy at the same moment, the second one's UPDATE affects no rows and EF Core throws DbUpdateConcurrencyException (the EF Core series post "Concurrency in EF Core: Tokens, rowversion and DbUpdateConcurrencyException"). It may look like a business rule, but how it's enforced is purely a persistence decision; not a single line changed in the Domain.

Where Persistence Ignorance Ends

Now the answer to the first question. When EF Core turns a row into an object, it has to call a constructor. If there is no parameterless constructor, it uses a parameterized one, but only if the parameter names match mapped properties. Title and Price are getter-only properties; by convention, EF Core doesn't map properties that have neither a setter nor a backing field. Those two lines map these properties explicitly. In a DbContext where I deleted them, EF Core says this while building the model:

No suitable constructor was found for the type 'Book'. The following constructors had parameters that could not be bound to properties of the type: 
    Cannot bind 'title', 'price' in 'Book(int id, string title, decimal price, int stock)'
Note that only mapped properties can be bound to constructor parameters. Navigations to related entities, including references to owned types, cannot be bound.

The last sentence of the message is the rule itself: only mapped properties can be bound to constructor parameters, and navigations and references to owned types can't be bound at all. That's also why Order's constructor works by taking only customerEmail and postalCode and not the items; an Order that took its items through the constructor would give the same error.

So the terms of the agreement are short but real: constructor parameters must match property names, getter-only properties must be mapped explicitly in Infrastructure, collections must live in a field EF Core can populate, and the Id must be a property. These are light constraints on the shape of the domain. Its code has no trace of EF Core, but what EF Core can do still affects how the domain can be written.

At this point you have two options. The first is today's approach: map the domain classes directly with EF Core and accept these constraints. Less code, a single model; the price is that the shape of the domain is limited by what EF Core can do. The second is a separate persistence model: write EF Core-friendly classes such as OrderRecord in Infrastructure and map between them and the domain on every read and write. The domain stays completely free; the price is a second class for every entity, two-way mapping code, and change tracking that now follows the record rather than the domain object. In a system like Bookstore, whose domain stays within what EF Core can model, the first option is almost always cheaper. Keep the second for cases where the domain genuinely needs a structure EF Core can't map, or where the same model is also written to another store.

A Driven Adapter: The Shipping Company's API

Persistence isn't the only driven adapter. In post 05 we put a flat-rate placeholder behind the IShippingRates port. The shipping company Bookstore actually works with has an HTTP (Hypertext Transfer Protocol) API, and the fee is calculated there. We made a single change to the port in this post:

public interface IShippingRates
{
    // null: no delivery to this postal code
    Task<decimal?> QuoteAsync(string postalCode, int weightGrams, CancellationToken ct);
}

There is no "shipping company", "HTTP" or "rate request" in the port's name. Application says how many grams go to a postal code and expects a fee. Returning null carries a meaning too: there is no delivery to this address. The handler turns that into the 422 we set aside in post 06:

decimal? fee = await rates.QuoteAsync(command.PostalCode, command.Quantity * GramsPerBook, ct);
if (fee is null)
    return new Error("shipping.unsupported_destination", $"No delivery to {command.PostalCode}.", ErrorKind.Validation);

order.ApplyShipping(fee.Value);

.NET itself uses this shape everywhere. ILogger is a port, and the ILoggerProvider implementations (console, debug, EventSource) are adapters. IDistributedCache is the same: the Redis and SQL (Structured Query Language) Server implementations ship in separate packages, and application code doesn't know which one is registered.

An Adapter with a Typed HttpClient

The shipping company's API lives in its own world: field names are snake_case, the amount is an integer in minor units, the currency is a separate field, there are fields we don't use such as transit days and service code, and it returns 422 for postal codes it doesn't serve. The adapter's job is to translate that world into the port's language:

using System.Net;
using System.Net.Http.Json;
using System.Text.Json.Serialization;
using Bookstore.Application.Ordering;

namespace Bookstore.Infrastructure.Shipping;

internal sealed class CarrierApiOptions
{
    public const string Section = "Shipping:Carrier";
    public required Uri BaseAddress { get; init; }
    public required string ApiKey { get; init; }
}

// Driven adapter: translates Application's port to the shipping company's HTTP API
internal sealed class CarrierShippingRates(HttpClient http) : IShippingRates
{
    public async Task<decimal?> QuoteAsync(string postalCode, int weightGrams, CancellationToken ct)
    {
        CarrierRateRequest body = new(postalCode, weightGrams, ServiceLevel: "STANDARD");
        using HttpResponseMessage response = await http.PostAsJsonAsync("v2/rates", body, ct);

        if (response.StatusCode == HttpStatusCode.UnprocessableEntity)
            return null; // the company doesn't deliver to this postal code
        response.EnsureSuccessStatusCode();

        CarrierRateResponse rate = await response.Content.ReadFromJsonAsync<CarrierRateResponse>(ct)
            ?? throw new InvalidOperationException("Carrier returned an empty body.");
        if (rate.Currency != "TRY")
            throw new InvalidOperationException($"Unexpected carrier currency {rate.Currency}.");

        // the external model's language ends here: minor units → decimal, the company's other fields stay out
        return rate.AmountMinor / 100m;
    }

    private sealed record CarrierRateRequest(
        [property: JsonPropertyName("destination_zip")] string DestinationZip,
        [property: JsonPropertyName("weight_grams")] int WeightGrams,
        [property: JsonPropertyName("service_level")] string ServiceLevel);

    private sealed record CarrierRateResponse(
        [property: JsonPropertyName("amount_minor")] int AmountMinor,
        [property: JsonPropertyName("currency")] string Currency,
        [property: JsonPropertyName("transit_days")] int TransitDays,
        [property: JsonPropertyName("service_code")] string ServiceCode);
}

The records of the external model (CarrierRateRequest, CarrierRateResponse) are private inside the adapter. If the company's JSON (JavaScript Object Notation) schema changes, only this file changes, and fields such as TransitDays and ServiceCode never reach Application. In Eric Evans's DDD (Domain-Driven Design, 2003) book, the larger version of this idea is called an anti-corruption layer: a translation layer that doesn't let another system's model corrupt your own. Here it is as small as a single class; the full version comes in the upcoming Domain-Driven Design series. Nothing would change if the company gave us an SDK (software development kit) instead of HTTP. The Clean Code series post "Boundaries: Wrapping Third-Party Code and Learning Tests" and the Design Patterns series post "Adapter Pattern: Fitting a Third-Party SDK to Your Own Interface" are the code-level counterparts of the same boundary.

The adapter takes its HttpClient through the constructor; who creates it, and with which settings, is the composition root's business. AddInfrastructure from post 05 now picks one of two adapters:

public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration)
{
    services.AddDbContext<BookstoreDbContext>(o =>
        o.UseSqlite(configuration.GetConnectionString("Bookstore")));
    services.AddScoped<IUnitOfWork>(sp => sp.GetRequiredService<BookstoreDbContext>());
    services.AddScoped<IBookRepository, BookRepository>();
    services.AddScoped<IOrderRepository, OrderRepository>();
    if (configuration["Shipping:Mode"] == "Carrier")
    {
        services.AddOptions<CarrierApiOptions>().BindConfiguration(CarrierApiOptions.Section).ValidateOnStart();
        services.AddHttpClient<IShippingRates, CarrierShippingRates>((sp, http) =>
        {
            CarrierApiOptions options = sp.GetRequiredService<IOptions<CarrierApiOptions>>().Value;
            http.BaseAddress = options.BaseAddress;
            http.DefaultRequestHeaders.Add("X-Api-Key", options.ApiKey);
            http.Timeout = TimeSpan.FromSeconds(5);
        });
    }
    else
    {
        decimal flatRate = decimal.Parse(configuration["Shipping:FlatRate"] ?? "0", CultureInfo.InvariantCulture);
        services.AddSingleton<IShippingRates>(new FlatRateShipping(flatRate));
    }
    return services;
}

AddHttpClient<IShippingRates, CarrierShippingRates> is a typed client registration: IHttpClientFactory manages the handlers from a pool, and you get a ready HttpClient each time. The typed client is registered as transient. If you inject it into a singleton, the handler is never renewed and you miss DNS (Domain Name System) changes; it is the same trap as in the Web API series post "Dependency Injection in ASP.NET Core: Lifetimes and the Captive Dependency Trap". The adapter is internal; as in post 05, Web can't see it at all. The settings come from appsettings.json:

"Shipping": {
  "Mode": "FlatRate",
  "FlatRate": 40,
  "Carrier": {
    "BaseAddress": "http://localhost:5307/",
    "ApiKey": "dev-only"
  }
},

The real API key obviously doesn't live here; we discussed secret management in the Web API series post "Configuration and the Options Pattern in ASP.NET Core: Provider Order, User Secrets and Validation". Against a small stub server that imitates the company's sandbox, with Shipping__Mode=Carrier:

POST /orders {"customerEmail":"ada@example.com","postalCode":"34000","bookId":1,"quantity":1}
HTTP 201
{"orderId":1,"subtotal":250.0,"shippingFee":39.9,"total":289.9}
POST /orders {"customerEmail":"ada@example.com","postalCode":"99000","bookId":1,"quantity":1}
HTTP 422
{"type":"https://tools.ietf.org/html/rfc4918#section-11.2","title":"shipping.unsupported_destination","status":422,"detail":"No delivery to 99000."}

In the 201, the company's 3990 minor units became 39.9 TRY in the adapter. In the 422, the company's HTTP 422 became null in the adapter and our own shipping.unsupported_destination error in the handler; the client never saw the company's error format.

A Fake Adapter and Swapping Adapters

Not every developer's machine has access to the shipping company's sandbox. FlatRateShipping from post 05 stays as the port's second implementation and never touches the network. With the Shipping:Mode setting left at its default, the same request:

POST /orders {"customerEmail":"ada@example.com","postalCode":"34000","bookId":1,"quantity":1}
HTTP 201
{"orderId":1,"subtotal":250.0,"shippingFee":40,"total":290.0}

Neither the handler nor the endpoint changed. This is exactly the promise of Hexagonal Architecture from 2005: being able to drive the same application with different adapters. In post 08 the use case tests will run with in-memory adapters like this one.

The real adapter has one more problem: what happens when the company is unreachable? It is exactly the scenario from the opening of post 06. I shut the stub down and sent the same request (I trimmed the exception details shown in the Development environment):

POST /orders {"customerEmail":"ada@example.com","postalCode":"34000","bookId":1,"quantity":1}
HTTP 500
{"type":"https://tools.ietf.org/html/rfc9110#section-15.6.1","title":"System.Net.Http.HttpRequestException","status":500,"detail":"Connection refused (localhost:5307)"}

Then I looked at the database:

$ sqlite3 bookstore.db "SELECT Id, Title, Stock FROM Books; SELECT COUNT(*) FROM Orders;"
1|Dune|2
2|Emma|5
1

Dune's stock went from 3 to 2 only on the successful order; the two requests that ended with 422 and 500 wrote nothing. The single SaveChanges rule from post 06 pays off here: because the shipping fee is requested before the commit, the failure doesn't leave a half-finished order behind. "The company isn't answering right now" is not a business outcome but an infrastructure failure, which is why the 500 is deliberate. But turning every request into a 500 during short outages isn't a good experience either. Rather than writing retry, timeout and circuit breaker into the adapter's code, it's better to add them to the HttpClient pipeline; the AddStandardResilienceHandler() method from the Microsoft.Extensions.Http.Resilience package does it in one line, without changing a single line of the adapter.

Common Pitfalls

The generic repository and the IQueryable leak. The answer to the second question in the opening is this exception, thrown the moment the query runs:

The LINQ expression 'DbSet<Order>()
    .Where(o => o.Total > 100)' could not be translated. Additional information: Translation of member 'Total' on entity type 'Order' failed. This commonly occurs when the specified member is unmapped. Either rewrite the query in a form that can be translated, or switch to client evaluation explicitly by inserting a call to 'AsEnumerable', 'AsAsyncEnumerable', 'ToList', or 'ToListAsync'. See https://go.microsoft.com/fwlink/?linkid=2101038 for more information.

Total is a property computed in the domain; it has no column in the table. The moment IQueryable is exposed to Application, every LINQ (Language Integrated Query) lambda written in Application becomes an expression EF Core has to translate to SQL; the compiler can't check that, and the error only surfaces at runtime from inside Infrastructure. On top of that, Application now needs the Microsoft.EntityFrameworkCore namespace for ToListAsync. The trap we described in the EF Core series post "LINQ Queries with EF Core: SQL Translation, Client Evaluation and the IQueryable Trap" now punches through an architectural boundary as well. Design repositories around what the use case needs, with intention-revealing methods like FindAsync. For query-heavy needs such as reporting, handle the read side separately instead of forcing the repository; that separation is where CQRS (Command Query Responsibility Segregation) starts. The EF Core series post "EF Core and Architecture: Repositories, the Read Side of CQRS and Moving to Dapper" covers the first steps.

A repository for every table. Writing an IOrderItemRepository for OrderItem makes the items changeable independently of the order and opens the door to bypassing the stock rule in Order.TryAddItem. Repositories are per aggregate; the items are managed from inside the order.

Mapping hell. In a request → command → entity → persistence model → entity → response DTO (Data Transfer Object) chain, every arrow means a mapping class. If you don't really need a separate persistence model, don't add that link; we'll come back to this cost in post 09.

Letting the external model in. Having the adapter return CarrierRateResponse directly looks like the easiest path; a year later half the handlers are doing the amount_minor / 100 calculation themselves. The translation ends in the adapter.

new HttpClient() on every call. It exhausts sockets and doesn't pick up DNS changes. Use a typed or named client and give the adapter its HttpClient from outside.

A Small Experiment

★☆☆ Add an IEnumerable<OrderItem> items parameter to Order's constructor and start the app. Which sentence of the message above does EF Core's error match?

★★☆ Make the stub respond with "currency":"EUR". Which status code does the request end with, and does the stock change? In that case, is it right for the adapter to throw, or to return null?

★★★ The shipping company's API wants an X-Request-Id header on every request. Add it without writing it into the adapter, using a DelegatingHandler and AddHttpMessageHandler. Should that handler live in Infrastructure or in Web?

We now have a Domain, an Application that defines its ports, an Infrastructure with EF Core and HTTP adapters, and a thin Web. In the next post we test them: the core without a database, the adapters against SQL Server, the production engine, with Testcontainers, and the Dependency Rule itself with ArchUnitNET.