Archiboard

Set K · Craft & Judgment

Fourteen years of .NET web applications: what changed, what did not

From ASP.NET MVC 2 and Entity Framework 4 to .NET 10. Hosting, configuration, resilience and telemetry became somebody else's problem. Tenancy, migrations, cost, saying no and naming things are still mine.

The first web application whose architecture was entirely mine went into production in the spring of 2010. ASP.NET MVC 2, Entity Framework 4, SQL Server 2008, two Windows servers in a rack I had a key to, and a Global.asax that held the wiring for the whole thing. I still have the drawing. It has a box marked "retry?" with nothing inside it.

Fourteen years later, when I first drew this sheet, a system of the same shape ran on .NET 8 with the 9 release candidate on my laptop. Today it runs on .NET 10. The Global.asax is gone, so is the Startup.cs that replaced it, the retry box is one line calling a library Microsoft ships, and the single database is nine databases in an elastic pool. Some of that is progress in the platform and some of it is progress in me. This sheet tries to separate the two, and to be honest about which is which.

What got easier

Hosting. In 2010 it meant IIS, an application pool, a Windows licence per machine, and a deployment that was a file copy done by whoever had the permissions, on a Thursday evening. A web application and a background worker were two different kinds of project with two different startup paths, so the tenant resolver was written twice and the copies drifted apart within a year. The generic host began fixing that and WebApplication.CreateBuilder finished it; an API and a worker now share one builder and one container. Since Aspire went stable in 2024 the local loop is dull as well, which from me is praise. It has changed nothing in production, where the deployment is still Bicep.

Dependency injection. In 2010 this was a third-party opinion: Castle Windsor or StructureMap or Ninject, registered in an XML section, and an argument in code review about which one. Half the tenant-scoped state in that first product lived in a thread-static field, because the alternative was a container nobody else wanted to learn. ASP.NET Core put one container in the box in 2016, and the day scoped lifetimes became a language everyone spoke was the day tenant resolution stopped being clever.

Configuration. web.config transforms per environment, encrypted sections, and a deployment that could not tell you which values had actually been applied. Now there is a layered IConfiguration, environment variables that win over files, Key Vault behind the same interface, and options validated at startup, so a missing connection string fails the container rather than the first request.

Resilience. The empty retry box got filled in with Polly and a hand-written policy registry I was rather proud of. In .NET 8 AddStandardResilienceHandler() replaced the registry with a rate limiter, a total timeout, retries with jitter, a circuit breaker and an attempt timeout, with defaults I would have chosen myself. I deleted 400 lines and the behaviour improved. This is the clearest case I know of the platform absorbing something that used to be craft.

Telemetry. In 2010 this was a log file on a server and a scheduled job that mailed me the lines containing the word "error". Then came the Application Insights SDK and a custom initialiser that stamped the tenant on every item, which worked and was proprietary. OpenTelemetry, built on ActivitySource and Meter in the base library, puts the tenant on the activity once and every exporter sees it. The stamping code is still mine; the plumbing is not.

Cross platform. .NET meant Windows Server, and Windows Server meant a licence, a patch window and a machine somebody had to log into. The build now runs on Linux agents and the application in a Linux container. This one saves real money and nobody writes about it, because there is nothing to write.

The hinge, 2016

Everything above sits either side of one year. .NET Core 1.0 and ASP.NET Core 1.0 arrived in 2016, and they were not an upgrade. They were a rewrite of the platform with the same name, and the migration cost me the better part of two years across two codebases. EF Core 1.0 could not do things EF 6 had done since 2013; I waited for 2.0 in 2017 and told the client we were waiting, which was an uncomfortable conversation and the right one.

What survived is the interesting part. The code did not; almost every file changed. The decisions did, because they were written down on pages that were not made of C#. Shared-row tenancy, the tenant resolved once before anything else runs, reference data kept out of the filtered tables: all of it came through the rewrite intact. That migration is the strongest argument I have for keeping decision records, and I did not know it at the time.

Timeline 2010 to 2026, .NET releases above the line and lessons below What Microsoft shipped vermilion = the rewrite the hinge: the code did not survive it, the decisions did .NET 4.0 MVC 2 .NET 4.5 Web API EF 6.1 OWIN .NET Core 1.0 Core 2.1 LTS .NET 5 one .NET .NET 7 filters .NET 9 Aspire .NET 10 (2025) 2010 2012 2014 2016 2018 2020 2022 2024 2026 pen before keyboard wiki was the wrong place one issuer, many tenants rewrote it, kept the ADRs tenant in the middleware fell over at 340 tenants one builder, api + worker 400 lines of Polly deleted still saying no (K-04) What I learned one release every two years is enough to show the shape
Fig. 1. One line from 2010 to 2026, sampled every two years. Above it, what arrived on schedule; below it, what I learned late and at my own expense. The vermilion box is 2016, the only year in which the two rows had to agree with each other.

What stayed hard

Tenancy. Every one of the improvements above is tenant-blind. The host does not know what a tenant is, the resilience handler does not, OpenTelemetry does not, Aspire does not. The tenant resolver I drew in 2010 as an HTTP module is, allowing for fifteen years of API changes, the resolver in production today, and resolving the tenant once, before authentication, is still the most important decision in the codebase. The platform got better at everything around the tenant and no better at the tenant. The tenant is the product.

Data migrations. EF Core migrations were painful across many databases in 2018 and they are painful across many databases in 2026. Before EF Core there were numbered SQL scripts, painful in a different way, and at least you could read them. Named query filters in EF Core 10 help with the soft-delete plus tenant case. But applying one migration to nine databases, in an order that keeps the application working against both schema versions during the rollout, is a problem about databases and not about EF. I have rewritten the migration runner three times; the current one is the least clever and the most trusted.

Cost. In 2010 the bill was a rack, two licences and a line in a budget, and I could explain all of it. In 2026 it is larger, more detailed and less explicable, and "what does tenant 412 cost us" has no better answer than it did then. Every attempt to meter per tenant runs into a shared resource that does not report per-tenant consumption, and the answer comes out as an estimate with a confidence interval nobody wants to hear.

Saying no. See sheet K-04. The requests that break the tenancy model arrive at the rate they did in 2010, phrased the same way, from customers entirely right from where they are sitting. No release of .NET has changed that, and the drawing I use to say no is the same drawing.

Naming things. It sounds like a joke and is not. Names outlive code: a word chosen carelessly in a first sprint will be in the URLs, the columns, the support scripts and the customer's own vocabulary a decade later, and renaming it by then costs a coordinated release across nine databases. I inherited a system where "document" meant three different things in three modules. The cost was not confusion. It was the four-hour meeting, held twice, in which nobody could agree what to call the fix.

Sheets I would redraw

A few sheets in the drawing set no longer describe what I would build.

T-01, the three tenancy models. The choice is still database, schema or row, but the 2010 sheet treats the three as fixed, which is what you think when a database per customer means a server per customer. A product moves between them over its life. The sheet should show the moves, not the states.

T-02, tenant resolution, drawn around an HTTP module and a base controller class, both dead for a decade. It should show the worker's version beside the web one, because the worker is where the tenant gets forgotten.

The sheet on retry policies I would not redraw. I would retire it and point at the resilience handler documentation, which is what I did with the code.

And this sheet, in two years, because the paragraph about Aspire is the one I trust least.

What I would build today

A .NET 10 API and worker sharing one builder, minimal endpoints on a tenant-scoped route group, the standard resilience handler with retries disabled for unsafe methods, OpenTelemetry with the tenant on the root activity, Aspire for the local loop and Bicep for the real one. Shared-row tenancy until the ADR's revisit line fires. The same resolver, one layer down. The same drawing of the boundary. Fourteen years moved a great deal of code out of my repository and into Microsoft's, and none of the decisions.

Drawn from