ADR: The SQL Server backend
- Status
- accepted
- Date
- 2026-09-03
- Shipped as
- 1.0.0.49
Steve asked for
a SQL backend built correctly on SQL Server with Entity Framework, with clear
diagrams, ready to expand.
In plain words
This page moves the site's data into Microsoft's managed SQL Server (Azure SQL Database), so a bid now survives a deploy as well as a restart. The site signs in to the database with its own Azure identity (a managed identity), so the connection string carries no password, and laptops and CI keep using a single-file database (SQLite).
What that is worth: a developer can clone and test the code with no Azure credential, and the organization has no database password to leak or rotate, with a site that still comes up when the database is paused.
The connection string that is not a credential
Steve's second instruction that day was to avoid standard connection strings
wherever possible, because they are a security risk, and this is the part of the
work that answers it, so it goes first.
A standard connection string is a security risk because it carries a password.
It ends up in a settings file, in a deployment variable, in a screenshot of a
deployment variable, in a support ticket, and in the shell history of whoever
tested it. Rotating it means finding every copy. The usual answers are to make
the copies harder to read: a key vault, a secure setting, a masked log. All of
them are still guarding a password.
There is no password here. The logical server was created with
--enable-ad-only-auth, which means it has no SQL administrator login at all
and no ability to grow one. The container authenticates as the user-assigned
managed identity it already carried for pulling its own image,
id-theyard-ss, and the token comes from Azure at the moment it is needed.
Before, on the storage this replaces:
Data Source=/app/state/yard.db
After:
Server=tcp:sql-theyard-ss-westus3.database.windows.net,1433;Initial Catalog=sqldb-theyard-ss;
Encrypt=True;TrustServerCertificate=False;Connect Timeout=30;
Authentication=Active Directory Managed Identity;User Id=2888a6ca-be1c-46a5-a1de-c666b1d193e5;
Every field in that is public information. The server name is in DNS. The
database name is in the resource group. The client id is an identifier that
already appears in Program.cs beside the Application Insights wiring. There is
nothing in it to steal, nothing to rotate, and nothing that stops working if it
appears in a log.
Two things follow from that and both are worth saying out loud.
The identity's database user was created without Microsoft Graph. The usual
CREATE USER [name] FROM EXTERNAL PROVIDER needs the logical server to hold a
directory permission so it can look the name up. Giving a database server the
ability to read a directory is a permission that outlives the reason it was
granted. The user was created from the SID of the identity's client id instead,
which the server can verify without asking anybody:
CREATE USER [id-theyard-ss] WITH SID = 0xCAA688281CBEA546A1DEC666B1D193E5, TYPE = E;
ALTER ROLE db_datareader ADD MEMBER [id-theyard-ss];
ALTER ROLE db_datawriter ADD MEMBER [id-theyard-ss];
Read back from sys.database_principals afterwards, because a grant nobody
verified is a grant nobody has:
principal: name=id-theyard-ss type_desc=EXTERNAL_USER sid_hex=0xCAA688281CBEA546A1DEC666B1D193E5
role: db_datareader
role: db_datawriter
Two roles, and the third one is the interesting part. db_ddladmin was granted
first, because the plan at that point was for the container to apply its own
migrations at startup, and then removed when the schema became the SQL project's
(ADR: Data first, and the database in source control). The running application
now cannot create, alter or drop a table. It is not that it does not; it is not
permitted to.
The code still refuses to print it. YardConnection.Describe returns
"Azure SQL Database" and nothing else, and a test asserts that the server, the
database name and the user id are all absent from what it returns. There is no
secret in today's connection string, but that is a fact about today's
configuration and not a property of the method. The next person to add a setting
should not have to notice that this method would have printed it.
Context
ADR: The relational store put the catalogue and the bids into SQLite through EF
Core, behind the ports that already existed, and was explicit about what it did
not give you: /app/state is the container's own writable layer, so a bid
survived a process restart and did not survive a deploy. That record named the
fix and declined to do it, because it needed a resource in a subscription this
project was not allowed to add to.
Steve authorised exactly one exception on 2026-09-03: one Azure SQL Database and
the logical server it needs.
The database, and what it costs
Priced before it was created, from the Azure retail price API rather than from
memory, for the region the container runs in:
Azure SQL Database has a free limit: 100,000 vCore-seconds of serverless compute,
32 GB of data and 32 GB of backup per database per month, renewing on the first,
on any subscription type, and it does not expire. Both free meters exist in this
region at $0.00, which is the check that the offer is real here rather than
announced somewhere else.
The database was created with it, and read back rather than assumed:
sku GP_S_Gen5_2
minCapacity 0.5 vCore
autoPauseDelay 60 minutes
maxSize 32 GB
useFreeLimit true
exhaustion AutoPause
backup Local
status Online
location westus3
Committed cost: $0.00 per month. When the monthly allowance runs out the database
auto-pauses until the first rather than billing, which is the setting
freeLimitExhaustionBehavior: AutoPause and is not reversible once changed to
the other option.
It is in westus3 and the container is in westus2, because West US 2 refused:
(RegionDoesNotAllowProvisioning) Location 'West US 2' is not accepting creation
of new Windows Azure SQL Database servers at this time.
That is the third capacity refusal this project has taken from Azure, after App
Service quota and Container Apps (ADR: Deployment strategy). The cost of the
extra region is one hop on the writes, and nothing at all on the reads, because
the catalogue is read into memory once at startup and never queried again per
request.
One thing that refusal taught, and it cost a run to learn: a create that fails
still pins the name to the region it failed in. The first retry loop kept one
server name and walked nine regions, and every region after the first answered
InvalidResourceLocation: the resource already exists in location 'westus2',
which is ARM remembering a resource that az sql server list says does not
exist. The loop that worked uses a name per region.
Decision
Two providers, one model, and the difference is in one method. Azure SQL
Database is what the deployed container talks to. SQLite is what a developer and
a CI runner get, because neither has an Azure credential and neither is getting
one. YardConnection is the only place that knows which is which: it picks the
provider, names that provider's migrations assembly, and adds the retry policy a
cloud database needs.
The provider is chosen by whether there is a SQL Server to talk to, and a
placeholder counts as nothing:
public static YardConnection Choose(string? sqlServer, string sqlite) =>
!string.IsNullOrWhiteSpace(sqlServer) && !sqlServer.StartsWith("__", StringComparison.Ordinal)
? new YardConnection(YardProvider.SqlServer, sqlServer)
: new YardConnection(YardProvider.Sqlite, sqlite);
public static YardConnection Choose(string? cosmosEndpoint, string? sqlServer, string sqlite) =>
!string.IsNullOrWhiteSpace(cosmosEndpoint) && !cosmosEndpoint.StartsWith("__", StringComparison.Ordinal)
? new YardConnection(YardProvider.Cosmos, cosmosEndpoint)
: Choose(sqlServer, sqlite);
Live from api/TheYard.Infrastructure/Shared/Stores/YardConnection.cs, region choose, read from this build at 8003063.
The __ test is the difference between a bad deploy and an outage. The
container's SQL setting is substituted at roll time by the deploy exactly as the
Application Insights key is, and a substitution that fails leaves the literal
__YARD_SQL_CONNECTION__ behind. Reading a placeholder as "no SQL Server here"
means a broken deploy falls back to the SQLite path that ran this site for a
week, instead of crash-looping against a string that is not a connection string.
Retries, because a serverless database is sometimes asleep:
public DbContextOptionsBuilder Configure(DbContextOptionsBuilder builder) => Provider switch
{
YardProvider.SqlServer => builder.UseSqlServer(WithResumeBudget(ConnectionString), sql =>
{
sql.EnableRetryOnFailure(maxRetryCount: 2, maxRetryDelay: TimeSpan.FromSeconds(6), errorNumbersToAdd: null);
sql.CommandTimeout(120);
}),
YardProvider.Sqlite => builder.UseSqlite(ConnectionString, sqlite => sqlite.MigrationsAssembly(SqliteMigrations)),
_ => throw new InvalidOperationException($"{Describe()} is not an Entity Framework provider"),
};
public const int ConnectSeconds = 60;
public static string WithResumeBudget(string connectionString)
{
var builder = new SqlConnectionStringBuilder(connectionString);
if (!builder.ShouldSerialize("Connect Timeout"))
{
builder.ConnectTimeout = ConnectSeconds;
}
return builder.ConnectionString;
}
Live from api/TheYard.Infrastructure/Shared/Stores/YardConnection.cs, region configure, read from this build at 8003063.
The database auto-pauses after an idle hour. The first connection after that
wakes it, and while it is waking it answers with a transient error rather than a
connection. The numbers are a budget rather than a preference: this runs during
startup, before the container serves anything, and the deploy gives a new build
five minutes to answer, so four retries with a ceiling of eight seconds over a
thirty-second connect timeout is about ninety seconds of patience. That is enough
for a serverless resume, which takes thirty to sixty, and short enough that a
database which is genuinely gone falls through to the path below while the deploy
is still watching. Waiting longer would not fix an outage, it would turn it into
a failed deploy as well.
The site still comes up when the database does not. ADR: The relational
store built that and this did not touch it: YardDatabase.Prepare runs before
anything is registered, and if it throws, the composition root registers the JSON
file readers and NullBidStore instead. A paused serverless database is now a
routine event rather than a hypothetical one, which makes that path more
important than it was, not less. The health check names the provider so the
Admin tab says which one is serving.
The schema, done properly
Steve scoped this: the current model done properly. Vehicles, photos, accounts,
bids. Correct types and lengths, real foreign keys, the indexes the queries
actually need, a concurrency token on bids. Nothing speculative.
Every decision below is written in api/TheYard.Database as DDL and mirrored in
the EF model, and a conformance test fails the build if the two disagree. The
catalogue table, with the reason beside every length:
CREATE TABLE [dbo].[Vehicles] (
[Id] nvarchar(64) NOT NULL,
[Seq] int NOT NULL,
[Vin] varchar(17) NOT NULL,
[Year] int NOT NULL,
[Make] nvarchar(64) NOT NULL,
[Model] nvarchar(64) NOT NULL,
[Trim] nvarchar(64) NOT NULL,
[BodyStyle] nvarchar(32) NOT NULL,
[ExteriorColor] nvarchar(32) NOT NULL,
[InteriorColor] nvarchar(32) NOT NULL,
[Engine] nvarchar(128) NOT NULL,
[Transmission] nvarchar(64) NOT NULL,
[Drivetrain] nvarchar(16) NOT NULL,
[OdometerKm] int NOT NULL,
[FuelType] nvarchar(32) NOT NULL,
[ConditionGrade] decimal(3,1) NOT NULL,
[ConditionReport] nvarchar(1024) NOT NULL,
[DamageNotes] nvarchar(max) NOT NULL,
[TitleStatus] nvarchar(32) NOT NULL,
[Province] nvarchar(64) NOT NULL,
[City] nvarchar(64) NOT NULL,
[AuctionStart] datetime2(0) NOT NULL,
[StartingBid] int NOT NULL,
[ReservePrice] int NULL,
[BuyNowPrice] int NULL,
[Images] nvarchar(max) NOT NULL,
[SellingDealership] nvarchar(128) NOT NULL,
[Lot] nvarchar(32) NOT NULL,
[CurrentBid] int NULL,
[BidCount] int NOT NULL,
CONSTRAINT [PK_Vehicles] PRIMARY KEY NONCLUSTERED ([Id])
);
GO
CREATE UNIQUE CLUSTERED INDEX [IX_Vehicles_Seq] ON [dbo].[Vehicles] ([Seq]);
GO
Live from api/TheYard.Database/Tables/Vehicles.sql, region *, read from this build at 8003063.
The mapping that has to agree with it:
protected override void OnModelCreating(ModelBuilder model)
{
base.OnModelCreating(model);
bool sqlServer = Database.IsSqlServer();
model.Entity<YardUser>().Property(user => user.Id).HasMaxLength(YardColumns.IdentityKeyLength);
model.Entity<IdentityRole>().Property(role => role.Id).HasMaxLength(YardColumns.IdentityKeyLength);
model.Entity<IdentityUserClaim<string>>().Property(claim => claim.UserId).HasMaxLength(YardColumns.IdentityKeyLength);
model.Entity<IdentityRoleClaim<string>>().Property(claim => claim.RoleId).HasMaxLength(YardColumns.IdentityKeyLength);
model.Entity<IdentityUserLogin<string>>(login =>
{
login.Property(row => row.LoginProvider).HasMaxLength(YardColumns.IdentityKeyLength);
login.Property(row => row.ProviderKey).HasMaxLength(YardColumns.IdentityKeyLength);
login.Property(row => row.UserId).HasMaxLength(YardColumns.IdentityKeyLength);
});
model.Entity<IdentityUserRole<string>>(role =>
{
role.Property(row => row.UserId).HasMaxLength(YardColumns.IdentityKeyLength);
role.Property(row => row.RoleId).HasMaxLength(YardColumns.IdentityKeyLength);
});
model.Entity<IdentityUserToken<string>>(token =>
{
token.Property(row => row.UserId).HasMaxLength(YardColumns.IdentityKeyLength);
token.Property(row => row.LoginProvider).HasMaxLength(YardColumns.IdentityKeyLength);
token.Property(row => row.Name).HasMaxLength(YardColumns.IdentityKeyLength);
});
model.Entity<VehicleRow>(vehicle =>
{
vehicle.ToTable("Vehicles");
vehicle.HasKey(row => row.Id);
vehicle.Property(row => row.Seq).IsRequired();
vehicle.HasIndex(row => row.Seq).IsUnique();
vehicle.Property(row => row.Id).HasMaxLength(YardColumns.IdLength);
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)
{
vehicle.Property(row => row.ConditionGrade).HasConversion<decimal>().HasPrecision(3, 1);
vehicle.Property(row => row.AuctionStart)
.HasConversion(YardColumns.AuctionStartToDateTime)
.HasColumnType("datetime2(0)");
}
});
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");
bid.HasKey(row => new { row.UserId, row.VehicleId });
bid.Property(row => row.UserId).HasMaxLength(YardColumns.IdentityKeyLength);
bid.Property(row => row.VehicleId).HasMaxLength(YardColumns.IdLength);
bid.HasOne<YardUser>()
.WithMany()
.HasForeignKey(row => row.UserId)
.OnDelete(DeleteBehavior.Cascade);
var version = bid.Property(row => row.RowVersion);
if (sqlServer)
{
version.IsRowVersion();
}
else
{
version.IsConcurrencyToken();
}
});
ActivityModel.Configure(model);
StandingsModel.ConfigureStandings(model);
CatalogueModel.ConfigureCatalogue(model, sqlServer);
BlogModel.ConfigureBlog(model, sqlServer);
CatalogueModel.ConfigureCatalogueWords(model);
}
Live from api/TheYard.Infrastructure/YardDbContext.cs, region model, read from this build at 8003063.
Lengths on every text column this application owns. Without them every string
is nvarchar(max) on SQL Server, which cannot be indexed, is stored off-row past
8,000 bytes, and tells a reader nothing about what belongs in it. The lengths
were chosen from what the dataset actually holds, with headroom, and the longest
observed value in the 200-record seed is in brackets: id 64 (36), make 64 (10),
model 64 (14), trim 64 (19), body style 32 (9), colours 32 (16), engine 128 (25),
transmission 64 (12), drivetrain 16 (3), fuel type 32 (8), condition report 1024
(143), title status 32 (7), province 64 (16), city 64 (11), dealership 128 (24),
lot 32 (6).
The id gets 64 rather than 40 because bids reference the synthetic ids, which are
the seed id plus six characters, and a foreign key column and the column it
points at have to agree.
A VIN is varchar(17) and it is the only non-Unicode column here. Seventeen
characters from a defined alphabet is not a guess about this dataset, it is ISO
3779. It is deliberately not char(17): fixed-length columns pad on read, and a
padded VIN stops equalling the one in the record it came from.
A condition grade is decimal(3,1), not a float. It is a number between 1.0
and 5.0 to one decimal place that gets compared and displayed, never accumulated,
so the exact type is the right one and float's approximation of 2.7 is not. The
CLR property stays double, because that is what the domain record uses and the
persistence layer does not get to change the shape of what it stores. A converter
sits between them.
An auction start is datetime2(0). The dataset carries it as
2026-04-05T19:00:00, a local wall-clock instant to the second with no zone,
which is exactly datetime2(0). It is sortable and comparable in the database
now rather than only in C#. The property stays a string for the same reason the
grade stays a double, and the converter is asserted to round-trip every one of
the 200 rows in the dataset rather than an example:
public static readonly Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter<string, DateTime>
AuctionStartToDateTime = new(
text => DateTime.ParseExact(text, AuctionStartFormat, System.Globalization.CultureInfo.InvariantCulture),
moment => moment.ToString(AuctionStartFormat, System.Globalization.CultureInfo.InvariantCulture));
Live from api/TheYard.Infrastructure/Shared/Stores/YardColumns.cs, region auction-start, read from this build at 8003063.
Damage notes and images stay JSON columns. 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.
The catalogue is clustered on the column it is read in order of. A clustered
index is the table's physical order, and SQL Server puts it on the primary key by
default. The only query this table ever serves is SELECT ... ORDER BY Seq, run
once at startup, so Seq is the clustered index here and the primary key on Id
is explicitly not. This one is decided in the DDL and nowhere else, because
physical design belongs beside the storage and the EF model has no business
knowing about it:
CONSTRAINT [PK_Vehicles] PRIMARY KEY NONCLUSTERED ([Id])
CREATE UNIQUE CLUSTERED INDEX [IX_Vehicles_Seq] ON [Vehicles] ([Seq]);
One real foreign key, and one that would be a lie.
A bid now references its account, with a cascade, so deleting an account takes
its bids with it and the database is what guarantees that rather than something
the application has to remember. Before this, a bid whose account had been
deleted stayed in the table forever, was loaded into BidService at every
startup, and counted toward a vehicle's standing price on behalf of nobody.
There is deliberately no foreign key from a bid to the catalogue. The Vehicles
table holds 200 rows, which SyntheticVehicleSource expands in memory to 100,000
by deriving ids from them, and a visitor bids on the expanded set. A constraint
there would reject 99.8 per cent of legitimate bids. It becomes correct the day
the expansion is persisted, and a test asserts its absence so that the day it is
added, something fails and sends the next person to this paragraph.
One index removed. Bids had an index on UserId and its primary key is
(UserId, VehicleId). The leading column of the key already answers every
question that index existed for, so it was a second copy of the first half of the
key: a write on every bid, and nothing earned.
A concurrency token on bids. Two containers, or two requests that get past
one container's lock, can both read a bid row and both decide to write it. The
token makes the second write fail instead of silently overwriting the first,
which is a lost update, and a lost update on an auction is somebody's money.
On SQL Server it is a real rowversion: 8 bytes, maintained by the database,
impossible for the application to forget to move, and declared in
Tables/Bids.sql. SQLite has no such type, so the store assigns one on every
save and the guarantee is the same with a different owner. The store retries a conflict rather than failing the bid, because the
correct answer to "somebody moved this row" is to start again from what is there
now:
public async Task<IReadOnlyList<StoredBid>> LoadAsync(CancellationToken cancellation)
{
using var db = await factory.CreateDbContextAsync(cancellation);
return await db.Bids.AsNoTracking().Select(row => new StoredBid(
row.UserId,
row.VehicleId,
new BidState(row.Amount, row.BidCount, row.WonBuyNow, row.AtMs)))
.ToListAsync(cancellation);
}
public async Task<IReadOnlyList<StoredBid>> BidsOnAsync(string vehicleId, CancellationToken cancellation)
{
using var db = await factory.CreateDbContextAsync(cancellation);
return await db.Bids.AsNoTracking().Where(row => row.VehicleId == vehicleId).Select(row => new StoredBid(
row.UserId,
row.VehicleId,
new BidState(row.Amount, row.BidCount, row.WonBuyNow, row.AtMs)))
.ToListAsync(cancellation);
}
public async Task<bool> TrySaveAsync(string userId, string vehicleId, BidState state, VehicleStanding standing, string? expectedVersion, CancellationToken cancellation)
{
using var db = await factory.CreateDbContextAsync();
await using var transaction = await db.Database.BeginTransactionAsync();
if (expectedVersion is not null && !await TryMoveStandingAsync(db, vehicleId, standing, long.Parse(expectedVersion, CultureInfo.InvariantCulture), CancellationToken.None))
{
return false;
}
if (expectedVersion is null)
{
db.Standings.Add(RowOf(vehicleId, standing, version: 1));
}
await StageBuyerRowAsync(db, userId, vehicleId, state, cancellation);
try
{
await db.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException)
{
return false;
}
catch (DbUpdateException) when (expectedVersion is null)
{
await transaction.RollbackAsync();
if (await StandingExistsAsync(vehicleId, CancellationToken.None))
{
return false;
}
throw;
}
await transaction.CommitAsync();
return true;
}
private static async Task StageBuyerRowAsync(YardDbContext db, string userId, string vehicleId, BidState state, CancellationToken cancellation)
{
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;
}
if (db.Database.IsSqlite())
{
existing.RowVersion = Guid.NewGuid().ToByteArray();
}
}
public async Task ClearAsync(string userId, CancellationToken cancellation)
{
using var db = await factory.CreateDbContextAsync();
await using var transaction = await db.Database.BeginTransactionAsync();
var touched = await db.Bids.Where(bid => bid.UserId == userId).Select(bid => bid.VehicleId).ToListAsync();
await db.Standings.Where(row => touched.Contains(row.VehicleId)).ExecuteDeleteAsync();
await db.Bids.Where(bid => bid.UserId == userId).ExecuteDeleteAsync();
await transaction.CommitAsync();
}
Live from api/TheYard.Infrastructure/Shared/Auction/EfSources.cs, region bid-store, read from this build at 8003063.
Identity's own tables were left alone, including the nvarchar(max) columns
it uses for password hashes and stamps. Those belong to a framework whose hashing
algorithm can change under this application, they hold a handful of rows, and
bounding them would trade nothing measurable for a truncation that would only
appear on the day somebody upgraded. The test that requires a length on every
text column names the three tables this application owns, and says why.
The schema does not come from here
The first version of this work generated the SQL Server schema from the EF model
and applied it with migrations at startup. That lasted about an hour, until Steve
asked for data first and gave the reason: the data structure outlives the
framework that reads it.
So the schema is api/TheYard.Database, a SQL project of hand-written DDL that
builds to a DACPAC, and this application maps to it. The decision, what it costs
and what enforces it are in ADR: Data first, and the database in source control.
What matters here is the shape it leaves behind:
api/TheYard.Database/ the SQL Server schema, the authority, published by SqlPackage
api/TheYard.Migrations.Sqlite/ the SQLite schema's history, applied by the process that uses it
The SQLite history is the same history it has always had, moved out of
TheYard.Infrastructure and renamed, carrying the same migration ids, so a
database created last week still matches its own __EFMigrationsHistory rows.
The SQL Server migrations were deleted rather than kept, because a migrations
chain nobody applies is a second definition of the schema waiting to be believed.
One design-time factory still exists, in the host project, because SQLite still
has migrations to write:
public sealed class YardDbContextFactory : IDesignTimeDbContextFactory<YardDbContext>
{
public YardDbContext CreateDbContext(string[] args)
{
bool sqlServer = args.Any(arg => string.Equals(arg, "--sqlserver", StringComparison.OrdinalIgnoreCase));
var connection = sqlServer
? new YardConnection(YardProvider.SqlServer, "Server=design-time;Database=design-time;")
: new YardConnection(YardProvider.Sqlite, "Data Source=design-time.db");
return new YardDbContext(connection.Options());
}
}
Live from api/TheYard.Api/Shared/Stores/DesignTime.cs, region design-time, read from this build at 8003063.
What the first publish said, and what it cost to ignore
Publishing the schema warned four times:
Warning! The maximum key length for a clustered index is 900 bytes.
The index 'PK_AspNetUserTokens' has maximum length of 2700 bytes.
The index 'PK_AspNetUserLogins' has maximum length of 1800 bytes.
The index 'PK_AspNetUserRoles' has maximum length of 1800 bytes.
The index 'PK_Bids' has maximum length of 1028 bytes.
That is ASP.NET Core Identity's default key width, nvarchar(450), which is 900
bytes on its own, so any composite key built from two of them is over SQL
Server's limit before this application adds anything. It is a real defect and not
a style note: an insert of a long enough value fails.
Nothing in the C# would have said so. The model compiles, the tests pass, and the
number only appears when a real SQL Server is asked to build the thing. That is
an argument for the SQL project rather than against it: the schema got compiled
by the engine that has to hold it, and the engine objected.
Identity's key columns are now nvarchar(128) here, which makes the widest
composite key 768 bytes. The ids this application creates are GUIDs, so 128 is
generous, and the widths are declared in the DDL and asserted against the model
by the conformance test.
The fix then failed once, with every step reporting success, and the way it
failed is worth more than the fix. The DDL was changed, the solution was built,
the tests passed, the schema was published, and the database came back with the
same four warnings and the same 450-byte columns. dotnet build on the
solution does not build a SQL project. It reported success, the DACPAC on disk
was the previous one, and the publish shipped a schema that did not contain the
change.
Nothing in that sequence looked wrong. The lesson is the one this repository
keeps relearning: the gate is an independent read of the thing itself, not a
tool's report about it. The database's own sys.indexes is what said so, and CI
now has a database job that builds the SQL project explicitly and fails if no
package comes out of it.
Tests that run where there is no Azure
The suite runs in CI, CI has no Azure credential, and it is not getting one. So
the SQL Server schema is asserted from the model rather than against a server:
each test builds the context with the SQL Server provider, reads the model or
generates the CREATE script, and never opens a connection.
[Fact]
public void Every_text_column_this_application_owns_has_a_length()
{
using var db = SqlServer();
Type[] mine = [typeof(VehicleRow), typeof(CatalogueRow), typeof(PhotoRow), typeof(BidRow)];
var unbounded = mine
.Select(type => db.Model.FindEntityType(type)!)
.SelectMany(entity => entity.GetProperties()
.Where(property => IsTextColumn(property) && property.GetMaxLength() is null)
.Select(property => $"{entity.ShortName()}.{property.Name}"))
.ToList();
Assert.True(
unbounded.Count == 0,
$"these columns would be nvarchar(max) on SQL Server: {string.Join(", ", unbounded)}");
}
private static bool IsTextColumn(IProperty property) =>
property.ClrType == typeof(string)
&& (property.GetValueConverter()?.ProviderClrType ?? typeof(string)) == typeof(string);
[Theory]
[InlineData(nameof(VehicleRow.Id), 64)]
[InlineData(nameof(VehicleRow.Vin), 17)]
[InlineData(nameof(VehicleRow.Make), 64)]
[InlineData(nameof(VehicleRow.Drivetrain), 16)]
[InlineData(nameof(VehicleRow.ConditionReport), 1024)]
public void The_catalogue_columns_are_the_length_the_data_needs(string property, int expected)
{
using var db = SqlServer();
Assert.Equal(expected, db.Model.FindEntityType(typeof(VehicleRow))!.FindProperty(property)!.GetMaxLength());
}
[Fact]
public void A_vin_is_seventeen_characters_of_ascii_because_a_standard_says_so()
{
using var db = SqlServer();
var vin = db.Model.FindEntityType(typeof(VehicleRow))!.FindProperty(nameof(VehicleRow.Vin))!;
Assert.Equal(17, vin.GetMaxLength());
Assert.False(vin.IsUnicode());
Assert.NotEqual(true, vin.IsFixedLength());
}
Live from api/TheYard.Tests/SqlServerModelTests.cs, region lengths, read from this build at 8003063.
That covers the lengths, the types, the keys, the clustering, the token and the
absent foreign key. What it cannot cover is whether Azure accepts the DDL, which
is proved once, by the container, when it migrates on its first boot, and shows
up on the Admin tab if it fails.
The constraints that both providers share are exercised against a real engine,
which is SQLite for the same reason it is SQLite in CI:
[Fact]
public void The_second_of_two_writers_that_read_the_same_bid_is_refused()
{
using (var db = Context())
{
db.Bids.Add(ABid());
db.SaveChanges();
}
using var first = Context();
using var second = Context();
var mine = first.Bids.Single();
var theirs = second.Bids.Single();
mine.Amount = 11_000;
mine.RowVersion = Guid.NewGuid().ToByteArray();
first.SaveChanges();
theirs.Amount = 10_500;
theirs.RowVersion = Guid.NewGuid().ToByteArray();
Assert.Throws<DbUpdateConcurrencyException>(() => second.SaveChanges());
}
[Fact]
public async Task The_store_moves_the_token_on_every_save()
{
var store = new EfBidStore(new PlainFactory(Connection));
Assert.True(await store.TrySaveAsync("buyer-1", "veh-1", new BidState(10_000, 1, false, 1), Standing(10_000, 1), null, CancellationToken.None));
byte[] first = TokenOf();
Assert.True(await store.TrySaveAsync("buyer-1", "veh-1", new BidState(11_000, 2, false, 2), Standing(11_000, 2), "1", CancellationToken.None));
byte[] second = TokenOf();
Assert.NotEmpty(first);
Assert.NotEqual(first, second);
}
[Fact]
public async Task A_conflict_on_the_buyers_row_writes_nothing()
{
var store = new EfBidStore(new PlainFactory(Connection));
Assert.True(await store.TrySaveAsync("buyer-1", "veh-1", new BidState(10_000, 1, false, 1), Standing(10_000, 1), null, CancellationToken.None));
using (var other = Context())
{
var standing = other.Standings.Single();
standing.Amount = 10_800;
standing.Version = 2;
other.SaveChanges();
}
Assert.False(await store.TrySaveAsync("buyer-1", "veh-1", new BidState(12_000, 3, false, 3), Standing(12_000, 3), "1", CancellationToken.None));
using var db = Context();
Assert.Equal(10_000, db.Bids.Single().Amount);
Assert.Equal(10_800, db.Standings.Single().Amount);
}
[Fact]
public async Task The_second_first_bid_on_a_vehicle_loses_the_insert()
{
var store = new EfBidStore(new PlainFactory(Connection));
Assert.True(await store.TrySaveAsync("buyer-1", "veh-1", new BidState(10_000, 1, false, 1), Standing(10_000, 1), null, CancellationToken.None));
Assert.False(await store.TrySaveAsync("buyer-1", "veh-1", new BidState(10_500, 1, false, 2), Standing(10_500, 1), null, CancellationToken.None));
using var db = Context();
Assert.Equal(10_000, db.Standings.Single().Amount);
Assert.Equal(1, db.Standings.Single().Version);
}
private static VehicleStanding Standing(int amount, int count) => new(amount, count, "buyer-1", false, count);
Live from api/TheYard.Tests/RelationalConstraintTests.cs, region concurrency, read from this build at 8003063.
What this does not give you
A second replica. One container still writes, and the in-process lock in
BidService is still what orders bids. The concurrency token is what would make
a second writer safe rather than what makes it exist.
A private network path. The logical server is reachable from Azure services
through the AllowAzureServices firewall rule, which is the 0.0.0.0 rule and not
an open-internet rule, and the runner's own address is a second rule naming one
address. The container group has a public egress address that changes on every
roll, so pinning it would break the next deploy. What actually gates access is
that the server accepts only Entra tokens, and the only principal with a database
user is the container's identity. A private endpoint is the real answer and it is
a new resource this project is not authorised to add.
Durable backups anyone has restored. Backup storage is Local redundancy
inside the free limit. Nobody has tested a restore.
The review of this diff
Written after the work, before the ship, by reading the whole diff again as
somebody who did not write it. Nine things came out of it and all nine are in the
tree.
The health check said too much. Describe() returned
"SQL Server, tcp:sql-theyard-ss-westus3.database.windows.net,1433", which put a
server name in a log and on a public endpoint. It returns "Azure SQL Database"
now, and a test asserts that the server, the database name and the user id are
all absent from what it returns. There is no secret in today's connection string,
but that is a fact about today's configuration and not a property of the method.
The retry policy had no budget. Six retries with a twelve-second ceiling over
a sixty-second connect timeout can spend minutes before giving up, and it spends
them during startup, inside the five minutes the deploy gives a new build to
answer. It is four retries, an eight-second ceiling and a thirty-second connect
timeout now, which is about ninety seconds: enough for a serverless resume, short
enough that a database which is genuinely gone falls through to the file-backed
path while the deploy is still watching.
Two lists of the same four table names, one in an array and one inside the
SQL, on a check whose entire job is to notice drift. One list now.
An index that was a second copy of half a key. Bids had an index on
UserId and a primary key of (UserId, VehicleId). Removed, with the reason in
the DDL where the next person will look.
Identity's key width, which the first publish caught and is described above.
A test helper living inside a test class file. Repo is used by three
classes and now has its own.
A gate that read UTF-8 as Windows-1252. The em dash check reported a hit in
docs/app-architecture/PROJECTS.md, which has no em dash: 0x97 is the second byte of a UTF-8
multiplication sign and an em dash in that code page. Read as UTF-8 the tree
scans clean at 234 files. Worth recording because a gate that reports a false
finding is worse than no gate: it teaches people to ignore it.
A build that succeeded without building. dotnet build on the solution does
not build a SQL project, so a schema change was published that the package did
not contain, and every tool involved reported success. CI has a job that builds
the SQL project on its own and fails if no package comes out.
A browser suite that looked broken and was not. Four full runs, one or two
failures each, a different test every time, all passing in isolation. The
temptation was to call it a flake. Instead the API was started deliberately, kept
alive after the suite and asked what it had recorded: /api/vehicles?limit=100
in 328 to 397 ms, /api/errors holding nothing but the test's own probe, every
health check green, and 43 of 43 passing in 1.6 minutes. The cause was on the
first line of that same log: eight orphaned dotnet processes left by a day of
test runs, and a Playwright config that reuses whatever server it finds
listening. The failures were a loaded machine, and that sentence is now a
measurement rather than a hope.
Addendum, the same day: the release that shipped on SQLite
1.0.0.49 went out with every gate green and came up on SQLite. The site was
healthy, /readyz said ready, 42,775 auctions were answering, and the health
check said, correctly, "the seed catalogue is in the store (SQLite)".
The container's own first log line settles what happened and what did not:
Database ready: SQLite, migrated in 2477 ms and seeded in 1158 ms,
inserting 200 vehicles and 50 photos, now holding 200 and 50
No error. Nothing failed to connect. YardConnection.Choose was handed the
literal __YARD_SQL_CONNECTION__ and read it as "no SQL Server here", which is
exactly what that check exists for.
The placeholder survived because the deploy asked Azure for the server's fully
qualified name and the deploy's own identity is not allowed to read it. Its role
assignments are Container Instances Contributor, AcrPush and Managed Identity
Operator, and no reader role anywhere, so az sql server show failed, || true
swallowed it, and the substitution had nothing to substitute.
The fix is to stop asking. An Azure SQL logical server's address is its name plus
the service suffix; it is not a secret, it does not change, and it does not need
a permission. The deploy composes it now.
Two things worth keeping out of this.
The fallback is not theoretical any more. It was written for a paused
serverless database and the first thing it actually caught was a deploy
misconfiguration. A visitor saw a working site throughout, and nothing about the
release was wrong except which store it was reading from.
Silence was the real defect. A roll landing on SQLite must not fail a deploy,
because that is the fallback doing its job. It must also not be invisible, and it
was: nothing said so until the health endpoint was read by hand. The Verify step
now reads /api/health and warns when the container is not on Azure SQL Database.
A warning, not a failure, because the difference between those two is the
difference between a safety net and a tripwire.
Addendum: the CI failure that could not explain itself
The commit that fixed the deploy went red on CI, and the only thing the run
could say was Process completed with exit code 1 followed by
No files were found with the provided path: playwright-report/.
Four of the five jobs passed, including the new one: the SQL project compiled on
a Linux runner in fifteen seconds, first time, and the style job was untroubled
by a .sqlproj sitting in a C# solution. The browser suite failed, on a runner
that took 4m19s where a developer's machine takes 2m.
Two things came out of it.
The evidence step had never worked. CI has uploaded playwright-report/ on
failure since the suite existed, and Playwright only writes that directory when
an html reporter is configured, which it was not. Every failing browser run on a
machine nobody can log into has left nothing behind. That is fixed in
playwright.config.ts, and it is the more useful of the two fixes: the next
failure will be readable.
A fix for one machine was a defect on another. The bid helper introduced
hours earlier waited six seconds for the accepted state and treated anything else
as a refusal. On a developer's machine that is generous. On a two-core runner it
is not, so a slow accept was read as a refusal and three of them threw, turning
an intermittent pass into a hard failure. It races the two possible answers
against each other now and returns as soon as either appears: fast when the
machine is fast, patient when it is not.
The general lesson is the one this record keeps arriving at from different
directions. A number chosen against the machine in front of you is a measurement
of that machine, not of the thing you are testing.
Consequences
- A bid survives a deploy, which is the sentence this record exists for. It
survived a process restart before; the storage it was written to did not
survive the container being replaced, and now it is not in the container.
- The site has a network dependency it did not have. A paused database is a
routine event on this tier, so the file-backed fallback moved from a safety net
to something that runs.
- Local development and CI are unchanged. No connection string, no credential,
no Azure.
dotnet test behaves exactly as it did.
- Adding a column is now four edits rather than three: the record, the row, the
mapping, and a length on the column. The fourth is enforced by a test.
- Two migrations projects exist and both have to be kept. A schema change is two
dotnet ef migrations add commands, which is written down in the junior record
beside this one.
- The connection string is configuration, so reverting this is removing an
environment variable rather than a deploy.
aci-export-v11.yaml is untouched.
Addendum, 2026-09-03: the first container that actually connected
Every part of this had been verified separately. The server exists, the schema is
published, the identity has exactly two roles, the connection string carries no
secret. What had never been observed was a container coming up and saying it was
on Azure SQL Database, because 1.0.0.49 shipped on SQLite through the fallback
and 1.0.0.50 never reached the site.
1.0.0.51 got there, and failed, and the failure is worth keeping because of how
precisely it named itself:
Connection Timeout Expired. The timeout period elapsed during the post-login
phase. The duration spent while attempting to connect to this server was -
[Pre-Login] initialization=135; handshake=432;
[Login] initialization=1; authentication=4;
[Post-Login] complete=29347;
Read the phases before reaching for an explanation. The handshake completed in
432 ms, so the network path and the firewall rule are fine. Login authentication
completed in 4 ms, so the managed identity is fine and the contained user exists
with the right SID. Everything this ADR was uncertain about had just been proved
working, by the error.
What ran out was post-login, at 29.3 seconds against SqlClient's 30. That is not
a connection problem. That is a serverless database waking up: the free tier
auto-pauses after an idle hour, a resume takes thirty to sixty seconds, and a
container rolls about once a day, so nearly every deploy will arrive at a
database that is asleep.
The fix is a wider connect timeout, ninety seconds, set in code:
var builder = new SqlConnectionStringBuilder(connectionString);
if (!builder.ShouldSerialize("Connect Timeout"))
{
builder.ConnectTimeout = 90;
}
Three things about that, each of which was a choice:
Ninety, not five minutes. A resume that has not finished in ninety seconds is
not a resume. The deploy gives a new build five minutes to answer, and the
fallback to files has to still happen while the deploy is watching, or an outage
becomes a failed deploy as well.
In code, not in the setting. The connection string is a value somebody edits
under pressure at two in the morning. This number has a reason, and the reason
belongs beside it. An explicit timeout in the setting still wins.
One attempt, not a retry loop. Retrying a ninety-second attempt four times is
six minutes, which is longer than the deploy will wait. The retry policy that is
already here covers transient errors during commands; the cold connect gets one
honest budget.
One thing worth knowing, because it surprised the test: the builder rewrites what
it is handed. Server comes back as Data Source, User Id as User ID, and
Active Directory Managed Identity as ActiveDirectoryManagedIdentity. The
driver accepts either spelling, so the setting the deploy writes and the string
the driver receives are equivalent without being identical. The test reads the
values back as values for exactly that reason.
The first version of that method used ContainsKey and therefore never widened
anything, which the test caught before it shipped. That is the second time on
this work that a check answered an easier question than the one it was written
for, and it is recorded in ADR: The exemption that hid a contrast failure.
Addendum, 2026-09-08: the connection string that is not a credential, on the other store too
This record's first section, on authenticating as a managed identity with no
password anywhere, is now the rule for two stores. The Cosmos DB account created
on 2026-09-08 has local authentication disabled, so it has no keys at all, and
the same identity id-theyard-ss holds a data-plane role on it; the container's
configuration for that store is an account endpoint, which is a URL. What was a
property of one connection string is a property of the project
(ADR: A second store on Cosmos DB, and what it costs).
Two of this record's decisions have a different answer on the document store,
and the difference is written where it is made rather than here:
- The concurrency token.
rowversion on SQL Server and a store-assigned
token on SQLite; on Cosmos DB the document's own _etag, sent back as
If-Match on every replace. The retry loop in the bid store is the same
three tries on both sides (ADR: The partition key; ADR: Accounts on a document
store).
- "The schema does not come from here." The rule holds, and the artifact is
different: a container definition rather than a SQL project, and a data-plane
role that cannot create a container rather than a database user without
db_ddladmin (ADR: Data first, and the database in source control, addendum).
The YardProvider enumeration this record introduced has a third member,
Cosmos, chosen by the presence of an endpoint, and Configure throws for it
because there is no Entity Framework provider behind it.
Where it sits
YardConnection in Infrastructure is the one class that knows which engine sits underneath, and the schema it maps to lives in TheYard.Database beside the onion. Single responsibility gives that choice one home, and Liskov substitution is why it works: the same EF adapters behind IVehicleSource and IBidStore run on SQL Server or SQLite, and the inner rings cannot tell which. It cost a second Azure region for the database, a length on every new text column and, since the free month ran out on 14 September, $4.90 a month for the Basic tier. A second writer or a private network path would change the design, because both need resources this project was not cleared to add.
Files
api/TheYard.Infrastructure/Shared/Stores/YardConnection.cs: the provider choice, the retry policy, and what may be said about the database.
api/TheYard.Infrastructure/YardDbContext.cs: the model, both providers, and the value converters.
api/TheYard.Infrastructure/Rows.cs: the row types, and the concurrency token.
api/TheYard.Infrastructure/Shared/Auction/EfSources.cs: the adapters, the seed, and the store that retries a conflict.
api/TheYard.Api/Shared/Stores/DesignTime.cs: the one design-time factory, and the two commands that use it.
api/TheYard.Database: the SQL Server schema, hand written, and the authority for it.
api/TheYard.Migrations.Sqlite: the SQLite schema's history.
api/TheYard.Tests/SchemaConformanceTests.cs: what holds the mapping to the schema.
api/TheYard.Tests/SqlServerModelTests.cs: the schema, asserted without a SQL Server.
api/TheYard.Tests/RelationalConstraintTests.cs: the foreign key and the token, exercised against an engine.
infra/aci-theyard.yaml: the setting, and the placeholder that is not a connection string.
.github/workflows/deploy.yml: where the setting is built, at roll time, from the resource itself.
docs/decisions/ADR-040-database-source-control.md: the SQL project, why it is the authority, and what enforces that.
docs/decisions/ADR-041-two-providers-explained.md: the same setup, walked at a new developer's level.
Addendum, 2026-09-14: the free month ran out on the fourteenth, and the Basic tier beside it
The free limit this record priced, 100,000 vCore-seconds a month with
freeLimitExhaustionBehavior: AutoPause, was reached on 14 September at
06:40 UTC. Read off the database itself: status Paused, pausedDate 2026-09-14T06:40:21Z, useFreeLimit true, exhaustion AutoPause; and off
the container's log, error 42119, "This database has reached the monthly
free amount allowance for the month of September 2026 and is paused for the
remainder of the month." At 0.5 vCore minimum a database that never pauses
spends 1,800 vCore-seconds an hour, so the allowance is fifty-five awake
hours; the site activity collector (ADR: Site activity, and the line an
address does not cross) had been writing it a batch every five seconds since
the thirteenth, and an idle hour never came.
The consequences were the ones this record predicted for a store that does
not come up, and they were visible: the catalogue served from the files,
bids that would not outlive the process, accounts, sign-in and the password
reset answering 503, the health card reading database=fail. The document
store's site was untouched.
Steve's pick, from three priced: the Basic tier this record listed at
$4.90 a month, a new database sqldb-theyard-ss-basic on the same
Entra-only server, 5 DTU, 2 GB, local backup, created and read back
Online at 09:10 CDT; the container's identity given its contained user from
its SID and the same two roles, db_datareader and db_datawriter, read
back from sys.database_principals; the schema published from the SQL
project by SqlPackage with no warnings, twelve tables read back. The
deploy's SQL_DB names it, so the next roll connects there and the store
fills itself from the seed files on first boot. The paused database is
left as it is; it resumes on the first of October and nothing points at it.
The other two options were the portal's one-way "continue with additional
charges" on the paused database, serverless at about $0.26 an awake hour,
and leaving the site browse-only until October for nothing. The second
site's deploy carries its own copy of the database name, and it was missed
on the first roll: 1.0.0.128 read database=pass on the SQL site and
database (sql)=fail on the Cosmos DB site, whose warm second store still
named the paused database; 1.0.0.129 is that one line. The activity
collector no longer writes here at all, which is what keeps a Basic
database from mattering to the bill in a way the free one could not afford.