The Blazor app that came first (and why we left it)
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.