AZ
All writing
4 min read

The Blazor app that came first (and why we left it)

dotnetblazorcsharpretrospective
Part of the projectNFTMixer (.NET)The original generative-NFT builder — C#/.NET 6, Blazor Server, PostgreSQL/EF Core — a mature product whose security foundations motivated the Go rewrite.View project

Before the Go app there was a Blazor one, and it worked. It shipped features. It had ~40 EF Core migrations spanning 2022 to 2026, a V3 generation engine, masters, rarity tiers, and a real rendered-output tree on disk. It was mature, not a toy. This is the honest retrospective of the app we left — what it did well, what it could not keep, and why the second of those forced a rewrite rather than a patch.

The thing it did well was also the thing that trapped it

NFTMixer (.NET) is a C#/.NET 6 Blazor Server app. The entire UI is .razor components rendered on the server and diffed to the browser over a SignalR websocket (AddServerSideBlazor, MapBlazorHub, MapFallbackToPage("/_Host")). That model is genuinely productive: a long generation run could stream progress dialogs to the client "for free," because the server was already holding the component's state and pushing updates down the socket.

But that same statefulness is the fork in the road. Blazor Server has no Go equivalent — there is nothing to port a stateful, socket-diffed component tree to. So the moment the decision was "own the stack in Go," the entire interactive surface had to be rebuilt from scratch in TypeScript, no matter how clean the engine was. That is the honest reason the migration ended up ~70% frontend. The framework that made the app pleasant to build is the one that made it impossible to port.

Storage sealed it. The app persisted generated output to local disk, under {userId}/{mixName}/{n}.png|.json|.png.sha256, on a volume the design notes say "must never be lost." A stateful disk that can't be lost is the opposite of a container you can kill and reschedule — it is exactly what blocked safe containerization and drove the Go app's stateless, S3-for-everything design.

The four foundations we couldn't keep

The features were fine. The foundations were not — and these are the ones that turned "improve it" into "replace it." Each is real and citable, and each maps 1:1 to a Go-side fix.

1. Authentication that doesn't authenticate. The browser reports the connected account and the server simply believes it — no signature challenge anywhere:

SelectedAccount = await _ethereumHostProvider.GetProviderSelectedAccountAsync();
if (SelectedAccount != null) {
    await InitUserData(SelectedAccount); // server trusts the address as-is
}

InitUserData then FindAsynces that address or silently creates the user. Anyone can log in as any address, including an admin's. (Go fix: real SIWE — the server issues a nonce, the client signs the full message, and the server recovers the signer via secp256k1/keccak256.)

2. An unauthenticated database dump. GET /api/Download/export/db shells out to pg_dump and returns the whole dump, with no [Authorize] — the runtime image even installs postgresql-client to make the shell-out work. (Go fix: not ported at all.)

3. Ownership read from the URL. GET /api/Download/zipmixer/{userId}/{mixName} builds the output path directly from the URL userId, no membership check, so any caller can download any user's output. (Go fix: ownership is folded into the store's query filter — there is no unfiltered read to leak.)

4. Sessions that never expire. Web3 tokens live in a process-local ConcurrentDictionary whose sweep method is empty:

public static void RegisterToken(string token, Web3User user) {
    _activeTokens.TryAdd(token, (DateTime.UtcNow, user));
    Cleanse();
}
static void Cleanse() {

}

The map only ever grows. (Go fix: every session carries a hard ExpiresAt that a Mongo TTL index enforces.) There was also a committed live Pinata API key in source — treated as burned and never carried forward.

The tell: two "generate" buttons

If you want a single symptom that captures why this needed a rewrite rather than a refactor, it is that the app shipped a "Process Paths" button and a "Process Paths (Accurate)" button — and they produced different collections. The "accurate" one was unique-but-uniform-random with an unbounded while (generatedCount < stat.NumberToGenerate) that hangs when the graph can't yield enough distinct combinations, and it incremented the NFT number on every attempt, leaving gaps. Two divergent notions of "generate," one of which could lock up, is not a bug you patch; it is a design you replace. The Go engine folds both into one weighted-and-unique generator with a bounded retry that fails with an actionable exhaustion error instead of spinning.

Why it still earns its place

None of this is a dunk on the app. It reached a feature set the rewrite spent seven slices catching up to, and several of its behaviours were correct enough to port verbatim. The reason to keep it visible is that the interesting engineering is the delta: a working, mature product whose statefulness, spoofable auth, open dump endpoint and leaked key could not survive contact with "we have to own and trust this in production." The Blazor app is the honest baseline that makes the Go rewrite legible — you cannot appreciate the discipline of the second without the inheritance of the first.

Back to the projectNFTMixer (.NET)See the full case study and related write-ups.View project