Skip to content
kc@kumarChandrachooda.com:~$ cd /blog/the-upgrade-that-only-moved-braces && read --section="top" 0%
Architecture

The Upgrade That Only Moved Braces

Inflow's .NET 6 upgrade touches 519 files. Run the diff with whitespace ignored and almost all of it is one Rider refactoring - and the three real changes, plus two design rewrites nobody announced, are hiding underneath it.

By Kumar Chandrachooda 10 Feb 2026 7 min read
A container whose outline shifts while the contents stay identical

Part 7 left two of Inflow's four variant branches sitting on .NET 5. The upgrade that produced the other two is one commit, 95fe438, dated 30 November 2021 at 17:50, with the subject .NET6 upgrade.

$ git diff --shortstat origin/net5 master
 519 files changed, 8391 insertions(+), 8870 deletions(-)

Five hundred and nineteen files out of 540 tracked. Seventeen thousand lines touched. That is what a framework migration looks like in a summary, and every one of those numbers is misleading. Run the same diff with whitespace ignored:

$ git diff -w --ignore-blank-lines --shortstat origin/net5 master
 519 files changed, 1242 insertions(+), 1721 deletions(-)

Eighty-five per cent of the insertions and eighty per cent of the deletions are indentation. And most of what survives is not a migration either.

The signature

Here is the entire diff for Program.cs, whitespace included, from the commit that upgraded this application from .NET 5 to .NET 6:

-namespace Inflow.Bootstrapper
+namespace Inflow.Bootstrapper;
+
+public class Program
 {
-    public class Program
-    {
-        public static Task Main(string[] args)
-            => CreateHostBuilder(args).Build().RunAsync();
+    public static Task Main(string[] args)
+        => CreateHostBuilder(args).Build().RunAsync();
 
-        public static IHostBuilder CreateHostBuilder(string[] args) =>
-            Host.CreateDefaultBuilder(args)
-                .ConfigureWebHostDefaults(webBuilder => webBuilder.UseStartup<Startup>())
-                .ConfigureModules()
-                .UseLogging();
-    }
+    public static IHostBuilder CreateHostBuilder(string[] args) =>
+        Host.CreateDefaultBuilder(args)
+            .ConfigureWebHostDefaults(webBuilder => webBuilder.UseStartup<Startup>())
+            .ConfigureModules()
+            .UseLogging();
 }

Not one token changed. The block-scoped namespace became a file-scoped namespace, and everything below it moved four columns left. That is a single IDE refactoring — “convert to file-scoped namespace”, applied to the solution — and 470 files on master now carry a file-scoped namespace X; declaration.

Its fingerprint is easy to count. Of the 519 changed files, 504 have five or fewer whitespace-insensitive insertions and six or fewer deletions, and 44 of them show exactly +3/−4: three lines added (the namespace X;, a blank line, and one re-flowed line) against four removed (the old namespace X, its opening brace, its closing brace, and one re-flowed line). That signature is the C# 10 namespace conversion and nothing else.

There is a second cosmetic sweep riding along. Across the same diff, roughly forty-five type declarations change from public to internal, tightening the accessibility of things like Rng and EfInbox<T> that were never meant to leave their assembly. Good hygiene, unrelated to .NET 6, invisible under the brace noise.

The three changes that are actually the upgrade

Strip all of that out and the diff that genuinely exists because the target framework moved is three items.

One. RNGCryptoServiceProvider was obsoleted in .NET 6:

-            using var rng = new RNGCryptoServiceProvider();
+        using var rng = RandomNumberGenerator.Create();

Two. Npgsql 6 changed how DateTime maps to timestamptz, which would have broken every entity in four modules. The upgrade sidesteps it:

// Temporary fix for EF Core issue related to https://github.com/npgsql/efcore.pg/issues/2000
AppContext.SetSwitch("Npgsql.EnableLegacyTimestampBehavior", true);

Three. All four EF Core _Init migrations were regenerated. On origin/net5 they are stamped 20210820112749, 20210820112828, 20210820113833 and 20210829093406. On master they are 20211229212455, 20211229212535, 20211229212704 and 20211229212750 — four dotnet ef migrations runs inside a two-minute-fifty-five-second window on 29 December 2021, a month after the upgrade commit's own date.

That is the whole migration. One obsoleted API, one compatibility switch, and a set of regenerated migrations.

The commit that was made a month after it was dated

Those migration timestamps are the loose thread. 95fe438 is dated 30 November 2021, and it contains files that EF Core generated on 29 December 2021. A commit cannot contain files from its own future, so one of those dates is not what it appears to be — and git records both:

$ git log --all --pretty=format:"%h | A:%ad | C:%cd | %s" --date=iso
39e96da | A:2022-07-23 12:21:23 | C:2022-07-23 12:21:23 | packages update
...
4724753 | A:2021-11-30 17:53:29 | C:2022-01-01 09:36:53 | .NET6 upgrade
95fe438 | A:2021-11-30 17:50:04 | C:2022-01-01 09:36:16 | .NET6 upgrade
35a9859 | A:2021-10-31 07:34:43 | C:2021-10-31 07:34:43 | Microservices transition
34f3f02 | A:2021-09-02 16:51:33 | C:2021-10-28 10:19:16 | init

The .NET6 upgrade commit was written on 1 January 2022 and back-dated to 30 November 2021. Its microservices twin was written thirty-seven seconds later and back-dated to three minutes after it. init was authored on 2 September and committed on 28 October — nearly two months of building a twenty-project application, flattened into one commit that claims a single afternoon.

This is ordinary rebase and squash behaviour, not deception; the author date survives a rebase and the committer date does not, so any squashed branch produces exactly this pattern. But it means the only honest reading of master's three-commit history is that the author dates are when each piece of work started and the committer dates are when it was tidied away. Of the twelve commits in the repository, the ones whose two dates agree are the single-sitting ones: the microservices transition, the workshop setup, and the five commits of that Saturday afternoon. Those are the commits that record something. The rest record a decision to stop recording.

What was not adopted

.NET 6 shipped a new project template, and this repository declined nearly all of it:

.NET 6 feature Adopted
File-scoped namespaces (C# 10) Yes — 470 files
WebApplicationBuilder / minimal hosting No — still Host.CreateDefaultBuilder + UseStartup<Startup>
Top-level statements No — Program class with an explicit Main
<ImplicitUsings> No — absent from all 20 .csproj files
<Nullable> No — absent from all 20 .csproj files
Minimal APIs for the module endpoints No — MVC controllers throughout

Two of those are worth defending. Keeping Startup.cs is arguably correct here: the whole bootstrapping story in this estate depends on Startup's constructor running ModuleLoader.LoadAssemblies before ConfigureServices, and rewriting that into a WebApplicationBuilder would have meant redesigning module discovery to win a stylistic point. And <LangVersion> is absent, which means the codebase leans on the C# 10 default that the net6.0 TFM provides — the file-scoped namespaces it just adopted only compile because of it.

The absence of <Nullable> is harder to defend for a codebase this size, and the absence of <ImplicitUsings> is a straight preference. Neither is a defect; both are choices, and like almost every choice in this repository (which is part 10), neither is written down.

Two design changes riding in the noise

Here is why the whitespace matters, and why I would flag this commit in review.

The Saga was rewritten. On origin/net5, NewCustomerBonusFundsSaga starts on SignedUp and has steps for SignedUp, CustomerCompleted, CustomerVerified, WalletAdded, DepositCompleted and FundsAdded. On master it starts on CustomerVerified, and the SignedUp and CustomerCompleted steps are gone along with their two files under Messages/. NewCustomerBonusFundsSagaData lost RegisteredAt and CompletedAt. SagaEventHandler lost two of its six IEventHandler<> implementations. The seven-day expiry window that used to be measured from registration is now measured from verification.

That is a change to when a customer qualifies for a bonus. It has nothing to do with .NET 6.

The inbox was rewritten. EfInbox<T> on origin/net5 had two conditional behaviours: record to the inbox only when messageId != Guid.Empty, and open a transaction only when OutboxOptions.TransactionsDisabled was false. On master both conditionals are gone — it always records and always transacts — and TransactionsDisabled is deleted from OutboxOptions and returns zero hits estate-wide. The message is now added on success rather than updated after the fact, which changes what the inbox contains after a failure.

That is a change to exactly-once delivery semantics. It also has nothing to do with .NET 6.

Both are defensible improvements. Both are invisible. A reviewer opening a 519-file diff titled .NET6 upgrade, seeing the first twenty files move braces, will scroll — and these two files look exactly like the other 517 in the file list.

The rule

Never combine a formatting sweep with a semantic change in one commit, because the sweep is the perfect hiding place. If you are going to convert every namespace in the solution, that is its own commit, with a subject that says so, reviewable in thirty seconds by anyone who can run git diff -w and see it come back empty. Then the upgrade commit is three files and everyone reads it.

The diagnostic is cheap and I use it on every large diff I inherit:

git diff --shortstat <base> <head>
git diff -w --ignore-blank-lines --shortstat <base> <head>

If the second number is a small fraction of the first, the commit is mostly formatting, and the residue is the part that can hurt you. Here the residue was 1,242 insertions across 519 files — which sounds like a lot until you find that most of it is re-flowed continuation lines, and the parts that matter are a saga's start trigger and an inbox's transaction rule.

There is a deeper asymmetry in this repository, and this commit is a symptom of it: nothing in the estate would have caught either change. No CI, no tests over the shared framework, no analyzer, no build-time check of any kind. And yet the author did build a runtime guard — for a different invariant entirely. Next, a guard for the drift, none for the rule.