# ADR: Entity Framework, explained

Status: accepted, 2026-09-03, shipped as 1.0.0.43. A companion to
ADR: The relational store, written for a developer meeting EF Core for the
first time. That record says what was decided and why; this one walks the
setup file by file and explains what each piece is actually doing.

It exists for the same reason ADR: Program.cs, explained does. Generated
configuration is the easiest place in a project to carry things nobody can
account for, and a data layer is the worst place for that to be true.

## In plain words

This page walks through how the code talks to the database (Entity Framework Core), one file at a time, for a developer meeting it for the first time. Every generated file is explained, so nothing in the data layer is there without a reason someone can state.

What that is worth: a developer new to EF Core can change the schema safely after one read, and the organization's data layer carries no setup that nobody can account for.

## The five pieces

**A `DbContext` is a session with the database.** `YardDbContext` is a class
that names the tables it can see and, through `DbSet<T>`, gives you something
you can write LINQ against. When you write `db.Vehicles.Where(...)`, EF turns
that expression into SQL and runs it. The context also tracks the objects it
handed you so that changing one and calling `SaveChanges()` produces an UPDATE
for exactly the columns you touched.

A context is not thread-safe and is not meant to be long-lived. It is a unit of
work: open, do a thing, dispose.

**`IDbContextFactory<T>` is how you get one when you are not in a request.**
The usual advice for a web application is `AddDbContext`, which gives every
HTTP request its own context and disposes it at the end. That is the right
default and it is not what this application needs. The catalogue is read once
at startup, before any request exists, and the two sources and the bid store
are singletons that outlive every request. `AddDbContextFactory` registers a
factory instead, and each of them opens a context for the length of one
operation and disposes it. `using var db = factory.CreateDbContext();` is that
in one line.

**A migration is the schema, as code, with a history.** Running
`dotnet ef migrations add InitialCreate` compares the model in `YardDbContext`
against the last known state and writes three files: the migration itself, with
an `Up` that creates the tables and a `Down` that drops them; a `.Designer.cs`
holding a snapshot of the model at that point; and an updated
`YardDbContextModelSnapshot.cs`, which is what the next migration will compare
against.

At startup, `db.Database.Migrate()` looks at a table called
`__EFMigrationsHistory`, sees which migrations have already been applied to
this particular database, and runs the ones that have not. That is the whole
mechanism, and it is why the alternative was rejected: `EnsureCreated()` makes
the tables and writes no history, so the second time the model changes there is
nothing to compare against and no way forward except deleting the database.

**The design-time factory keeps the tooling out of the application.** When
`dotnet ef` needs to know what the model looks like, it will by default start
your application to find a context in its service provider. That would be fine
in most projects and is actively harmful in this one: this application migrates
and seeds during startup, so generating the very first migration would boot an
app that tries to seed tables the migration has not created yet.
`YardDbContextFactory` implements `IDesignTimeDbContextFactory<YardDbContext>`,
which the tooling prefers over booting anything. It hands back a context with a
throwaway connection string, because the tooling only ever reads the model from
it.

There is a matching detail worth knowing because the error message is the only
place it is stated clearly: `Microsoft.EntityFrameworkCore.Design` has to be
referenced by the **startup** project, not only by the project holding the
context. That is what `dotnet ef migrations add` failed on the first time it
was run here.

**Primitive collections are stored as JSON.** `VehicleRow.DamageNotes` is a
`List<string>`, and EF Core maps it to a single text column holding a JSON
array. No join table, no second entity. That is the right shape for a list that
is only ever read back whole with its owner, which these two are.

## The three files with the same name

Look in the directory holding the database during a run and there are three
files, not one:

| file | what it is |
| --- | --- |
| `yard.db` | the database |
| `yard.db-wal` | the write-ahead log |
| `yard.db-shm` | shared memory, coordinating readers and the log |

SQLite is in write-ahead logging mode. A committed transaction is written to
the log and fsynced there, and moved into the database proper by a checkpoint
later, usually at a clean shutdown. That is why a database file can be 4,096
bytes, exactly one page, while holding two hundred vehicles: they are in the
log, and the next process to open the pair replays it.

Two practical consequences. Copying `yard.db` on its own can copy an empty
database, so the log has to come with it. And a size check on the database file
alone can report that the data vanished when it did not, which is exactly what
happened here and is written up in ADR: The relational store.

## Why the row types are not the domain records

`TheYard.Data.Vehicle` is a sealed record with `required init` properties and
`IReadOnlyList<string>` collections. Every one of those choices is deliberate
and every one of them is awkward for a persistence layer: init-only members
cannot be set field by field after construction, and a read-only interface is
not something EF can populate.

The alternative to a second type is loosening the first one, and that trade
runs the wrong way. The domain record is used everywhere and its immutability
is load-bearing: `with` copies drive the bid overlay, and value equality drives
the tests. So `VehicleRow` is a plain mutable class, `VehicleRows` maps between
them in one file, and the awkwardness is confined to a mapper instead of spread
through the application.

The honest cost is in the other record: adding a field to a vehicle is now
three edits rather than one.

## What `AsNoTracking` is doing there

By default a context remembers every entity it returns so it can work out what
changed later. That bookkeeping is worth paying for when you intend to write.
The catalogue reads never write, so `AsNoTracking()` tells EF to skip it. On a
few hundred rows the difference is small. It is in the code because the habit
matters more than this instance: a read path that tracks is a read path that
allocates and holds objects for no reason.

## What the seed is checking, and why

`YardSeed.EnsureSeeded` asks whether the table is empty, not whether the file
is new. Those come apart in exactly one case that matters: a process that died
partway through its first seed leaves a database with a schema and no rows. The
emptiness check fills it on the next start. A "have I seeded before" flag would
have recorded the attempt and left the tables empty forever.

## In the code

The context and its model (`api/TheYard.Infrastructure/YardDbContext.cs`):

```csharp
/// <summary>
/// The model for both providers: Identity's tables with their keys
/// narrowed, the catalogue, the photos, the bids and the activity
/// counters, with the SQL Server only parts behind one flag.
/// </summary>
protected override void OnModelCreating(ModelBuilder model)
{
    // Identity's own tables first. Skipping this call is the classic way to
    // get a context that compiles, migrates, and has nowhere to put a user.
    base.OnModelCreating(model);

    // The two providers differ in what they can express, not in what the
    // application means. Everything below that is inside this flag is a
    // SQL Server feature SQLite has no equivalent for.
    bool sqlServer = Database.IsSqlServer();

    // Identity's key columns, narrowed from its default of 450. A clustered
    // index key is capped at 900 bytes on SQL Server and nvarchar(450) is
    // 900 bytes on its own, so Identity's own composite keys are over the
    // cap out of the box: at the default widths PK_AspNetUserTokens is
    // 2,700 bytes and PK_Bids is 1,028, each of which fails an insert on a
    // long enough value. The ids this
    // application creates are GUIDs. The widths here exist to match
    // api/TheYard.Database, which is the authority, and a test holds them
    // to it (ADR: Data first, and the database in source control).
    model.Entity<YardUser>().Property(user => user.Id).HasMaxLength(IdentityKeyLength);
    model.Entity<IdentityRole>().Property(role => role.Id).HasMaxLength(IdentityKeyLength);
    model.Entity<IdentityUserClaim<string>>().Property(claim => claim.UserId).HasMaxLength(IdentityKeyLength);
    model.Entity<IdentityRoleClaim<string>>().Property(claim => claim.RoleId).HasMaxLength(IdentityKeyLength);
    model.Entity<IdentityUserLogin<string>>(login =>
    {
        login.Property(row => row.LoginProvider).HasMaxLength(IdentityKeyLength);
        login.Property(row => row.ProviderKey).HasMaxLength(IdentityKeyLength);
        login.Property(row => row.UserId).HasMaxLength(IdentityKeyLength);
    });
    model.Entity<IdentityUserRole<string>>(role =>
    {
        role.Property(row => row.UserId).HasMaxLength(IdentityKeyLength);
        role.Property(row => row.RoleId).HasMaxLength(IdentityKeyLength);
    });
    model.Entity<IdentityUserToken<string>>(token =>
    {
        token.Property(row => row.UserId).HasMaxLength(IdentityKeyLength);
        token.Property(row => row.LoginProvider).HasMaxLength(IdentityKeyLength);
        token.Property(row => row.Name).HasMaxLength(IdentityKeyLength);
    });

    model.Entity<VehicleRow>(vehicle =>
    {
        vehicle.ToTable("Vehicles");

        // Natural keys throughout. A vehicle already has an id the rest of
        // the system uses, a photo already has a unique file name, and a
        // bid is one per buyer per vehicle by definition, so a surrogate
        // key here would be a second identity to keep in step with the
        // first.
        vehicle.HasKey(row => row.Id);

        // Insertion order, kept explicitly. The synthetic scale-up expands
        // the seed catalogue deterministically from its order, so a set
        // that came back in a different order would be a different hundred
        // thousand vehicles. A table has no order of its own; this column
        // is what makes the answer the same every time.
        vehicle.Property(row => row.Seq).IsRequired();
        vehicle.HasIndex(row => row.Seq).IsUnique();

        // Which index is clustered is not decided here. On SQL Server the
        // physical design belongs to the SQL project, which puts the
        // clustered index on Seq because that is the only column this table
        // is ever read in order of, and leaves the primary key
        // nonclustered. This model maps to that schema and does not build it
        // (ADR: Data first, and the database in source control).

        vehicle.Property(row => row.Id).HasMaxLength(IdLength);

        // Seventeen characters, from a defined alphabet, by ISO 3779. This
        // is the one string in the catalogue whose length and character set
        // are fixed by a standard rather than by whatever the dataset
        // happens to hold, so it is the one that gets varchar rather than
        // nvarchar. Not fixed-length: nchar pads on read, and a padded
        // string would stop matching the record it came from.
        vehicle.Property(row => row.Vin).HasMaxLength(17).IsUnicode(false);

        vehicle.Property(row => row.Make).HasMaxLength(64);
        vehicle.Property(row => row.Model).HasMaxLength(64);
        vehicle.Property(row => row.Trim).HasMaxLength(64);
        vehicle.Property(row => row.BodyStyle).HasMaxLength(32);
        vehicle.Property(row => row.ExteriorColor).HasMaxLength(32);
        vehicle.Property(row => row.InteriorColor).HasMaxLength(32);
        vehicle.Property(row => row.Engine).HasMaxLength(128);
        vehicle.Property(row => row.Transmission).HasMaxLength(64);
        vehicle.Property(row => row.Drivetrain).HasMaxLength(16);
        vehicle.Property(row => row.FuelType).HasMaxLength(32);
        vehicle.Property(row => row.ConditionReport).HasMaxLength(1024);
        vehicle.Property(row => row.TitleStatus).HasMaxLength(32);
        vehicle.Property(row => row.Province).HasMaxLength(64);
        vehicle.Property(row => row.City).HasMaxLength(64);
        vehicle.Property(row => row.SellingDealership).HasMaxLength(128);
        vehicle.Property(row => row.Lot).HasMaxLength(32);

        if (sqlServer)
        {
            // A grade between 1.0 and 5.0 to one decimal place. `float` can
            // represent 2.7 only approximately, and this number is compared
            // and displayed rather than accumulated, so the exact type is
            // the right one. The CLR property stays `double` because that is
            // what the domain record uses and the persistence layer does not
            // get to change the domain's shape.
            vehicle.Property(row => row.ConditionGrade).HasConversion<decimal>().HasPrecision(3, 1);

            // An instant, stored as an instant. The dataset carries it as
            // `2026-04-05T19:00:00`, which is a local wall-clock time with no
            // zone, so `datetime2(0)` is exactly the type for it: seconds
            // precision, no offset, and sortable and comparable in the
            // database rather than only in C#. The property stays a string
            // for the same reason the grade stays a double.
            vehicle.Property(row => row.AuctionStart)
                .HasConversion(AuctionStartToDateTime)
                .HasColumnType("datetime2(0)");
        }

        // DamageNotes and Images are primitive collections: EF stores each
        // as a JSON array in one column. They are read and written whole and
        // never queried into, which is the case a JSON column is for. The
        // day something filters on a damage note is the day this becomes a
        // table, and not before (ADR: The SQL Server backend).
    });

    model.Entity<PhotoRow>(photo =>
    {
        photo.ToTable("Photos");
        photo.HasKey(row => row.File);
        photo.Property(row => row.Seq).IsRequired();
        photo.HasIndex(row => row.Seq).IsUnique();

        photo.Property(row => row.File).HasMaxLength(128);
        photo.Property(row => row.Style).HasMaxLength(32);
        photo.Property(row => row.Title).HasMaxLength(256);
    });

    model.Entity<BidRow>(bid =>
    {
        bid.ToTable("Bids");

        // A bid belongs to a person and to a vehicle, and one person has one
        // standing bid per vehicle, so the pair is the key
        // (ADR: Accounts and per-user bids).
        bid.HasKey(row => new { row.UserId, row.VehicleId });

        // Identity's key column is nvarchar(450) on SQL Server, and a
        // foreign key has to match the column it points at.
        bid.Property(row => row.UserId).HasMaxLength(IdentityKeyLength);
        bid.Property(row => row.VehicleId).HasMaxLength(IdLength);

        // A real foreign key, and the only one this model can honestly
        // carry. Deleting an account takes its bids with it, which is the
        // right answer and is the database's answer rather than something
        // the application has to remember to do.
        bid.HasOne<YardUser>()
            .WithMany()
            .HasForeignKey(row => row.UserId)
            .OnDelete(DeleteBehavior.Cascade);

        // There is deliberately no foreign key from VehicleId to Vehicles.
        // The catalogue in this table is 200 rows expanded in memory to
        // 100,000 by SyntheticVehicleSource, and a visitor bids on the
        // expanded set, so 99.8 per cent of legitimate bids name a vehicle
        // id that has no row here. A constraint would reject them. The
        // constraint becomes correct the day the expansion is persisted,
        // and writing it before then would be writing a rule the
        // application breaks on purpose (ADR: The SQL Server backend).

        // No index on UserId either, and its absence is the point: the
        // primary key is (UserId, VehicleId), so its leading column already
        // answers every "what has this person bid on" query. An index on
        // UserId would be a second copy of the first half of the key,
        // costing a write on every bid and earning nothing.

        // The concurrency token. Two containers, or two requests that get
        // past one container's lock, can both read this row and both decide
        // to write it; the token is what makes the second write fail
        // instead of silently overwriting the first.
        var version = bid.Property(row => row.RowVersion);
        if (sqlServer)
        {
            // SQL Server keeps the token itself: `rowversion` is a database
            // generated, monotonically increasing 8-byte value, and nothing
            // in the application can forget to bump it.
            version.IsRowVersion();
        }
        else
        {
            // SQLite has no rowversion. The token still exists and still
            // fails a stale write; the difference is that the store assigns
            // it, which is why EfBidStore sets one on every save.
            version.IsConcurrencyToken();
        }
    });

    // #region activity-model
    // The activity counters (ADR: Site activity, and the line an address
    // does not cross). Natural keys again: an hour on a store is one row,
    // a visitor token on a store on a day is one row. The counters move by
    // ExecuteUpdate so two containers writing the same Azure SQL row add
    // rather than overwrite; the paths column is a JSON object the store
    // reads, merges and writes back, bounded at twenty entries, and the
    // one place a batch can lose to another batch, which the record says.
    model.Entity<ActivityHourRow>(hour =>
    {
        hour.ToTable("ActivityHours");
        hour.HasKey(row => new { row.Store, row.Hour });
        hour.Property(row => row.Store).HasMaxLength(StoreKeyLength);
        hour.Property(row => row.Paths).HasMaxLength(PathsLength);
    });

    model.Entity<ActivityVisitorRow>(visitor =>
    {
        visitor.ToTable("ActivityVisitors");
        visitor.HasKey(row => new { row.Store, row.Day, row.Visitor });
        visitor.Property(row => row.Store).HasMaxLength(StoreKeyLength);
        // yyyy-MM-dd, and nothing else is ever written here.
        visitor.Property(row => row.Day).HasMaxLength(10);
        // The token: the first 16 bytes of a keyed hash, as 32 hex characters.
        visitor.Property(row => row.Visitor).HasMaxLength(32);
        // Three octets and an x, or the same shape for an address that
        // could not be read: never four octets.
        visitor.Property(row => row.Network).HasMaxLength(NetworkLength);
        visitor.Property(row => row.Paths).HasMaxLength(PathsLength);
        visitor.Property(row => row.Sources).HasMaxLength(PathsLength);
    });
    // #endregion activity-model
}
```
*Live from [`api/TheYard.Infrastructure/YardDbContext.cs`](https://github.com/SteveStout/TheYard/blob/1d6b50b/api/TheYard.Infrastructure/YardDbContext.cs#L49-L280), region `model`, read from this build at 1d6b50b.*

The seed (`api/TheYard.Infrastructure/EfSources.cs`):

```csharp
/// <summary>
/// Fills the vehicle table and the photo table, each only when it is empty,
/// in one save, and returns what was inserted and what each table now holds.
/// </summary>
public static async Task<SeedResult> EnsureSeededAsync(YardDbContext db, IVehicleSource vehicles, IPhotoManifestSource photos)
{
    int vehiclesAdded = 0;
    int photosAdded = 0;

    // "Empty" rather than "new", so a database that half-filled because a
    // process died mid-seed is not left half-filled forever.
    if (!await db.Vehicles.AnyAsync())
    {
        var rows = (await vehicles.LoadAsync()).Select((vehicle, index) => vehicle.ToRow(index)).ToList();
        db.Vehicles.AddRange(rows);
        vehiclesAdded = rows.Count;
    }

    if (!await db.Photos.AnyAsync())
    {
        var rows = (await photos.LoadAsync())
            .Select((photo, index) => new PhotoRow
            {
                Seq = index,
                File = photo.File,
                Style = photo.Style,
                Title = photo.Title,
            })
            .ToList();
        db.Photos.AddRange(rows);
        photosAdded = rows.Count;
    }

    if (vehiclesAdded > 0 || photosAdded > 0)
    {
        await db.SaveChangesAsync();
    }

    return new SeedResult(vehiclesAdded, photosAdded, await db.Vehicles.CountAsync(), await db.Photos.CountAsync());
}
```
*Live from [`api/TheYard.Infrastructure/YardSeed.cs`](https://github.com/SteveStout/TheYard/blob/1d6b50b/api/TheYard.Infrastructure/YardSeed.cs#L18-L57), region `seed`, read from this build at 1d6b50b.*

The bid store, which is the only thing here that writes
(`api/TheYard.Infrastructure/EfSources.cs`):

```csharp
/// <summary>Every stored bid, for BidService to replay into its indexes at startup.</summary>
public async Task<IReadOnlyList<StoredBid>> LoadAsync()
{
    using var db = await factory.CreateDbContextAsync();
    return await db.Bids.AsNoTracking()
        .Select(row => new StoredBid(
            row.UserId,
            row.VehicleId,
            new BidState(row.Amount, row.BidCount, row.WonBuyNow, row.AtMs)))
        .ToListAsync();
}

/// <summary>
/// One row per buyer per vehicle, replaced rather than appended. Called
/// inside BidService's lock, which is what makes "read the row, decide,
/// write the row" safe here without the database needing an opinion about
/// it.
/// </summary>
public async Task SaveAsync(string userId, string vehicleId, BidState state)
{
    // Three tries, then the exception travels. A conflict here means
    // another writer moved this same buyer's row between the read and the
    // write, which takes the one account bidding from two containers at
    // once. The retry reads the row again and writes this bid over it: the
    // rules already ran on this side against the standing this container
    // held, and what the row held in between is not re-examined. Two buyers
    // racing each other are two rows and never meet here: the store keeps no
    // per-vehicle standing, so the compare-and-set standing that would join
    // them is a decision still open (ADR: Three readers with no memory of the project). A fresh
    // context per attempt, because a context that has just thrown a
    // concurrency exception is holding the values that lost.
    for (int attempt = 1; ; attempt++)
    {
        try
        {
            await WriteAsync(userId, vehicleId, state);
            return;
        }
        catch (DbUpdateConcurrencyException) when (attempt < 3)
        {
            // Deliberately empty. The retry is the handling.
        }
    }
}

/// <summary>
/// One attempt at the write: insert the buyer's row for this vehicle, or
/// update it in place, on a fresh context, and move the concurrency token
/// where the provider does not move it itself.
/// </summary>
private async Task WriteAsync(string userId, string vehicleId, BidState state)
{
    using var db = await factory.CreateDbContextAsync();
    var existing = await db.Bids.FindAsync(userId, vehicleId);
    if (existing is null)
    {
        existing = new BidRow
        {
            UserId = userId,
            VehicleId = vehicleId,
            Amount = state.Amount,
            BidCount = state.BidCount,
            WonBuyNow = state.WonBuyNow,
            AtMs = state.AtMs,
        };
        db.Bids.Add(existing);
    }
    else
    {
        existing.Amount = state.Amount;
        existing.BidCount = state.BidCount;
        existing.WonBuyNow = state.WonBuyNow;
        existing.AtMs = state.AtMs;
    }

    // SQL Server keeps the token itself; SQLite has no rowversion type, so
    // the store is what moves it. Setting it here rather than in a trigger
    // keeps the difference between the two providers in one readable place
    // (ADR: The SQL Server backend).
    if (db.Database.IsSqlite())
    {
        existing.RowVersion = Guid.NewGuid().ToByteArray();
    }

    await db.SaveChangesAsync();
}

/// <summary>Deletes one buyer's bids, every vehicle, in one statement.</summary>
public async Task ClearAsync(string userId)
{
    using var db = await factory.CreateDbContextAsync();
    // One person's rows and nobody else's. A delete over the whole table,
    // on a site two strangers can be looking at, would let either of them
    // delete the other's bids (ADR: Reset is one person's start-over).
    await db.Bids.Where(bid => bid.UserId == userId).ExecuteDeleteAsync();
}
```
*Live from [`api/TheYard.Infrastructure/EfSources.cs`](https://github.com/SteveStout/TheYard/blob/1d6b50b/api/TheYard.Infrastructure/EfSources.cs#L54-L149), region `bid-store`, read from this build at 1d6b50b.*

Startup: migrate, then seed, then serve (`api/TheYard.Api/Program.cs`):

```csharp
// The schema and the contents, before anything is registered, because the
// answer decides what gets registered. Migrate rather than EnsureCreated: the
// schema's history is files in this repository, so a container starting against
// an older database brings it forward instead of finding a shape it half
// recognises. A fresh database gets its contents from the JSON readers, so
// `npm run data` regenerates the dataset and the seed cannot drift from it.
//
// Two stores, two of everything below: each store is brought up on its own,
// timed on its own, and stands behind its own catalogue, bids, room and
// accounts (ADR: One container, both stores). What a request gets is decided
// per request, further down; nothing here knows which one a visitor will pick.
var seedVehicles = new JsonFileVehicleSource(dataPath);
var seedPhotos = new JsonFilePhotoManifestSource(manifestPath);
var backendList = new List<Backend>();

// #region sql-backend
// The relational store, on SQL Server or SQLite. A factory rather than a
// scoped context: the two sources and the bid store are singletons that each
// want a context for one operation, and there is no request scope at startup
// when the catalogue is read. The interceptor puts every statement on the Admin
// tab; it is attached here so YardConnection stays a description of where the
// database is.
// How long the first of the five asks waits after a refusal (two seconds unless the
// configuration shortens it, which only a test that proves the fallback does).
var firstWait = StorePrepare.FirstWaitFrom(builder.Configuration);
var sqlStartup = new StartupTimings();
var sqlState = await sqlStartup.Time("prepare", () => StorePrepare.WithTriesAsync(() => YardDatabase.PrepareAsync(yard, seedVehicles, seedPhotos), firstWait));
// The backend stands on the files until its store is attached: the same two
// ports answered out of the JSON, the null bid store, no accounts, with the
// synthetic scale-up still decorating the vehicle source (ADR: The relational
// store). Attached at once below when the store came up, or by the second
// chance when it comes up later (ADR: The relational store, the addendum on
// the second chance).
var sqlBackend = new Backend
{
    Key = "sql",
    Name = yard.Describe(),
    Database = sqlState,
    Startup = sqlStartup,
    Inventory = new InventoryService(new SyntheticVehicleSource(seedVehicles, targetCount), seedPhotos),
    Bids = new BidService(NullBidStore.Instance),
    Market = new MarketService(builder.Configuration.GetValue("Market:GraceSeconds", MarketService.DefaultGraceSeconds)),
    Probe = () => Task.FromResult(false),
    UserStore = _ => null,
};
// Everything that stands on the relational store, built from a state that
// says it came up. The loggers are attached the moment the host exists (the
// factory is read at call time, so an attach after startup gets them too).
StoreAttachment SqlAttachment(DatabaseState state)
{
    var sqlOptions = new DbContextOptionsBuilder<YardDbContext>();
    yard.Configure(sqlOptions);
    sqlOptions.AddInterceptors(new SqlLogInterceptor(sqlLog, currentRequest));
    var contexts = new ContextFactory(sqlOptions.Options);
    // The same database without the interceptor, for the activity counters:
    // a batch every five seconds would fill the SQL log with the feature that
    // reads the SQL log, the observer effect the request ring designed out
    // (ADR: Site activity, and the line an address does not cross).
    var quietOptions = new DbContextOptionsBuilder<YardDbContext>();
    yard.Configure(quietOptions);
    var quietContexts = new ContextFactory(quietOptions.Options);
    if (host.HostLoggers is not null)
    {
        contexts.Attach(host.HostLoggers);
    }
    return new StoreAttachment(
        state,
        contexts,
        quietContexts,
        new InventoryService(new SyntheticVehicleSource(new EfVehicleSource(contexts), targetCount), new EfPhotoManifestSource(contexts)),
        new BidService(new EfBidStore(contexts)),
        new EfActivityStore(quietContexts),
        () => YardDatabase.HasSeedAsync(contexts),
        // Identity's own store over the accounts tables, given a context from the
        // request's scope. The type is the one AddEntityFrameworkStores would have
        // registered for a user type with no roles; naming it here is what lets
        // the other backend register a different one under the same interface.
        services => new Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore<YardUser, YardDbContext, string>(
            services.GetRequiredService<YardDbContext>(),
            services.GetService<IdentityErrorDescriber>()));
}
if (sqlState.Ready)
{
    sqlBackend.Attach(SqlAttachment(sqlState));
}
backendList.Add(sqlBackend);
// #endregion sql-backend

// #region cosmos-backend
// The document store, when there is an account to talk to. Same question, same
// answer shape, other store: are the containers there with the keys this code
// was written for, and is the seed in them. A refusal falls through to the
// file-backed catalogue exactly as a missing schema does on SQL Server.
CosmosStore? cosmos = null;
StoreSecondChance.Plan? cosmosSecondChance = null;
if (cosmosConfigured)
{
    cosmos = CosmosStore.Connect(
        configuredCosmos!,
        builder.Configuration["Cosmos:Database"] ?? "theyard",
        builder.Configuration["Cosmos:ContainerPrefix"] ?? "",
        // "managed-identity" on the deployed container; anything else, which
        // is what a developer's machine and the test runner have, means the
        // signed-in Azure CLI session.
        builder.Configuration["Cosmos:Credential"] ?? "azure-cli",
        YardComposition.AzureClientId(builder.Configuration),
        storeLog);
    // Filed under the request that caused it from the first operation on: the
    // describer exists before the store does now, so no operation is
    // attributed to nobody (ADR: What the store is actually doing).
    cosmos.CurrentRequest = currentRequest;
    var cosmosStartup = new StartupTimings();
    var cosmosState = await cosmosStartup.Time("prepare", () => StorePrepare.WithTriesAsync(() => cosmos.PrepareAsync(seedVehicles, seedPhotos), firstWait));
    var store = cosmos;
    // The same shape as the relational backend: the files until the store is
    // attached, at once when it came up, by the second chance when it did not
    // (ADR: A second store on Cosmos DB, and what it costs).
    var cosmosBackend = new Backend
    {
        Key = "cosmos",
        Name = "Azure Cosmos DB",
        Database = cosmosState,
        Startup = cosmosStartup,
        Cosmos = cosmos,
        Inventory = new InventoryService(new SyntheticVehicleSource(seedVehicles, targetCount), seedPhotos),
        Bids = new BidService(NullBidStore.Instance),
        Market = new MarketService(builder.Configuration.GetValue("Market:GraceSeconds", MarketService.DefaultGraceSeconds)),
        Probe = () => Task.FromResult(false),
        UserStore = _ => null,
    };
    StoreAttachment CosmosAttachment(DatabaseState state) => new(
        state,
        null,
        null,
        // The same three ports, answered out of the document store.
        new InventoryService(new SyntheticVehicleSource(new CosmosVehicleSource(store), targetCount), new CosmosPhotoManifestSource(store)),
        new BidService(new CosmosBidStore(store)),
        new CosmosActivityStore(store),
        store.ProbeAsync,
        // One document per account and one per address, and none of Identity's
        // seven tables (ADR: Accounts on a document store).
        _ => new CosmosUserStore(store));
    if (cosmosState.Ready)
    {
        cosmosBackend.Attach(CosmosAttachment(cosmosState));
    }
    backendList.Add(cosmosBackend);
    cosmosSecondChance = new StoreSecondChance.Plan(
        cosmosBackend,
        () => store.PrepareAsync(seedVehicles, seedPhotos),
        async state =>
        {
            var attachment = CosmosAttachment(state);
            await attachment.Inventory.WarmAsync();
            await attachment.Bids.LoadAsync();
            cosmosBackend.Attach(attachment);
        });
}
// #endregion cosmos-backend

// Which one a request gets when it names none, and the answer every request
// reads from its own header (ADR: One container, both stores).
// Peer:Site is the other container as a visitor reaches it, which behind the
// edge is the domain and not the origin the peer endpoint reads (ADR: One
// container, both stores, addendum). Unset everywhere but the deployed groups.
var backends = new Backends(
    backendList,
    configuredDefaultStore ?? (cosmosConfigured ? "cosmos" : "sql"),
    builder.Configuration["Peer:Site"]);
builder.Services.AddSingleton(backends);
builder.Services.AddScoped<CurrentBackend>();
// Identity's stores want a context per request, and the factory hands out
// contexts rather than registering one. This is the adapter between the two
// and one of the four scoped registrations in the application. The factory is
// the backend's at call time, so a store attached after startup serves the
// accounts too; nothing asks for a context while the store is on the files,
// because the user store is null until then.
builder.Services.AddScoped(_ =>
    (sqlBackend.Contexts ?? throw new InvalidOperationException("the relational store is not attached"))
        .CreateDbContext());
// #region second-chance-wiring
// A store that refused at startup is asked again after it (ADR: The relational
// store, the addendum on the second chance): every thirty seconds for an hour,
// attached warm when it answers. Only the backends that are not ready are
// planned, so a site whose stores came up runs nothing here.
var secondChances = new List<StoreSecondChance.Plan>
{
    new(
        sqlBackend,
        () => YardDatabase.PrepareAsync(yard, seedVehicles, seedPhotos),
        async state =>
        {
            var attachment = SqlAttachment(state);
            await attachment.Inventory.WarmAsync();
            await attachment.Bids.LoadAsync();
            sqlBackend.Attach(attachment);
        }),
};
if (cosmosSecondChance is not null)
{
    secondChances.Add(cosmosSecondChance);
}
builder.Services.AddHostedService(services => new StoreSecondChance(secondChances, services.GetRequiredService<ILogger<StoreSecondChance>>()));
// #endregion second-chance-wiring
// The partition key experiment needs the document store; without one it says so.
builder.Services.AddSingleton<IStoreExperiment>(
    cosmos is not null ? new PartitionExperiment(cosmos, TimeProvider.System) : NoStoreExperiment.Instance);
```
*Live from [`api/TheYard.Api/Composition/StoreRegistration.cs`](https://github.com/SteveStout/TheYard/blob/1d6b50b/api/TheYard.Api/Composition/StoreRegistration.cs#L79-L285), region `migrate-and-seed`, read from this build at 1d6b50b.*

## If you want to change the schema

These four steps were right when this record was written and are now the second
half of the job rather than the whole of it. The schema is authored as SQL first
(ADR: Data first, and the database in source control), Entity Framework maps to
it, and a conformance test fails the build if the two disagree. The full list,
in order, is in ADR: Two providers and a SQL project, explained. The short
version:

1. Change the DDL in `api/TheYard.Database`, with a length and a reason.
2. Change the row class in `Rows.cs`, and the mapping in `EfSources.cs` if the
   field also exists on the domain record.
3. Add the SQLite migration, so local development and CI get the column too:
   `dotnet ef migrations add <Name> --project api/TheYard.Migrations.Sqlite
   --startup-project api/TheYard.Api --context YardDbContext -- --sqlite`
4. Read the generated `Up` method. It is ordinary C# and it is where the change
   becomes real on SQLite.
5. Commit all three generated files. The snapshot is not optional; without it
   the next migration compares against the wrong model.
6. Publish the SQL project with SqlPackage when it ships.

Two things in the original wording are now wrong and are worth naming rather
than deleting without a note. The migrations project is
`api/TheYard.Migrations.Sqlite` and not `api/TheYard.Infrastructure`, because
two providers produce two models and EF Core keeps one snapshot per assembly.
And "there is no step for applying it, the application does that on start" is
true of SQLite only: on SQL Server the application holds `db_datareader` and
`db_datawriter` and cannot create or alter a table at all.

## Addendum, 2026-09-08: the store this record explains is one of two

Everything above explains Entity Framework, and Entity Framework is the mapper
for the relational store only. The second store, Azure Cosmos DB, is reached
through the Cosmos DB SDK directly, with no context, no migrations and no
mapper beyond a class per document and a method that copies fields
(ADR: A second store on Cosmos DB, and what it costs). If you are reading this
to learn how the deployed site on `theyard.stevenstout.biz` keeps its data, it
is the right record; if you are reading it to learn how the second container
keeps the same data, the newcomer's record for that side is ADR: Cosmos DB,
explained for someone who knows SQL Server, and the reason the two are
different is in the parent record.

One thing in "If you want to change the schema" changed shape: the ports the
adapters implement are asynchronous now (ADR: The ports learn to wait), so an
adapter's `Load()` is `LoadAsync()` and the calls inside it are the
`...Async` forms Entity Framework has always had.

## Where it sits

This walkthrough explains Infrastructure code, YardDbContext and YardSeed, along with the design-time factory that now lives in the host's DesignTime.cs and the SQLite history in its own project beside the onion. The principle it teaches is single responsibility: VehicleRow changes when storage changes, the Data record Vehicle changes when the domain does, and VehicleRows is the one file that maps between them. That separation is paid for as extra edits on every new field. A team willing to let EF shape its records directly would save those edits and accept a domain record with mutable members.

## Files

- [`api/TheYard.Infrastructure/YardDbContext.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Infrastructure/YardDbContext.cs)
- [`api/TheYard.Infrastructure/Rows.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Infrastructure/Rows.cs)
- [`api/TheYard.Infrastructure/EfSources.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Infrastructure/EfSources.cs)
- [`api/TheYard.Migrations.Sqlite`](https://github.com/SteveStout/TheYard/tree/main/api/TheYard.Migrations.Sqlite): the SQLite schema's history, which moved out of Infrastructure when a second provider arrived.
- [`api/TheYard.Database`](https://github.com/SteveStout/TheYard/tree/main/api/TheYard.Database): the SQL Server schema, which is the authority Entity Framework maps to.
- [`docs/decisions/ADR-033-relational-store.md`](https://github.com/SteveStout/TheYard/blob/main/docs/decisions/ADR-033-relational-store.md): what was decided, and what this deliberately does not give you.
- [`docs/decisions/ADR-018-program-cs-explained.md`](https://github.com/SteveStout/TheYard/blob/main/docs/decisions/ADR-018-program-cs-explained.md): the same treatment for the composition root.
- [`docs/decisions/ADR-041-two-providers-explained.md`](https://github.com/SteveStout/TheYard/blob/main/docs/decisions/ADR-041-two-providers-explained.md): the same treatment for the two engines and the SQL project, which is where this record continues.
