Rewriting a .NET Blazor app in Go without losing parity
"Rewrite it in Go" is a satisfying sentence. In this case it was also, mostly, a lie — and noticing that early is what made the rewrite tractable.
Blazor Server is the UI
The original NFTMixer is a C#/.NET 6 Blazor Server app. Its .razor files are not
templates: Blazor Server renders on the server and pushes DOM diffs to the browser
over a SignalR websocket. Go has no equivalent. So while the generation engine,
layer compositing, database access, S3, and wallet auth all port to Go cleanly,
roughly 70% of the work was frontend, rebuilt from scratch in a Next.js 16 App
Router app. The design doc says it plainly — "none of it is Go" — and budgeting
for that up front is the difference between a plan and a surprise.
The other thing decided up front removed most of the risk: the migration has no cutover. Existing data was declared expendable and the schema is greenfield, so there was no dual-write window, no backfill, no "does the old row map to the new shape" edge cases. The design doc names data migration as the single largest source of rewrite risk — and then deletes it.
Parity is a milestone, and it means behaviour
"Functional parity" here is not a vibe; it is a defined checkpoint, reached at the end of slice 6, with slice 7 (masters / IPFS / export / batch limits) classed as parity-complete without it. And parity means matching behaviour, not porting files. Delivery was vertical slices, 0 through 7 — each one a thin cut through domain → store → engine → HTTP → UI that works before the next begins — with graph path-tracing pulled forward into slice 4 because both rarity and sync validation need the same walk.
That framing is what let me leave things behind on purpose. Three categories of C# code were explicit non-ports:
- Dead code — the entire
NftGeneratorlibrary (the live engine was actually inside a.razorcomponent), and whole superseded component generations (ProjectOLD.razor,... - Copy.razor) that were referenced but unreachable. - Dangerous code — an unauthenticated
pg_dumpexport endpoint and a committed live Pinata key. Porting those faithfully would have been faithfully reproducing debt. - Unfinished code — an IPFS
PublishToIpfsthat returned before it uploaded. There was no working behaviour to preserve.
Authentication that actually authenticates
The original's "auth" believed whatever wallet the browser named — no signature,
anywhere. The Go app does real SIWE, and the whole decision is a short, ordered
sequence in Service.Verify: parse strictly, check server expectations, recover
the signer, consume a single-use nonce issued to that exact wallet, and only
then mint a session. Recovery pointedly does not pull in go-ethereum — its
library code is LGPL-3.0, and static-linking it into a proprietary binary carries
a relink/source obligation. Recovery needs only secp256k1 and keccak256, both
permissively licensed, so the core is a few lines:
v := sig[64] // Ethereum [R||S||V]; dcrd wants [V||R||S]
if isHighS(sig[32:64]) { return "", errMalleable } // reject malleable high-S
compact := make([]byte, signatureLen)
compact[0] = v
copy(compact[1:], sig[:64])
pub, _, err := ecdsa.RecoverCompact(compact, EIP191Hash(message))
Ownership then comes from the session, folded into the store's query filter, so
a handler that forgets to check simply selects no document and returns
ErrNotFound — there is no unfiltered read to leak by mistake. Sessions expire via
a Mongo TTL index instead of living forever in an in-memory map whose cleanup
method was empty.
Deliberate parity breaks that are fixes
A rewrite is the one chance to be correct where the original was wrong. Each of these breaks is pinned by a test, and the code comment cites the exact C# line it replaces:
| Behaviour | C#/.NET predecessor | Go rewrite |
|---|---|---|
| Generators | Two — weighted-with-dupes and unique-with-unbounded-while | One weighted-and-unique with a bounded retry |
| Exhaustion | Hangs when combos run out | ExhaustionError{Requested, Available} — a clean 4xx |
| Combination rarity | (varies) — not gated on the rarest step | MIN of step rarities: {0.5, 0.8} → 0.5 |
| Cycle guard | goto — drops whole parallel branches | continue — skip the node, keep its siblings |
| Quantity drift | Single pass, cannot always converge | Loop until the total equals the request exactly |
| NFT numbering | nftNumber++ on every attempt → gaps | Consumed only by an accepted NFT → gap-free |
| Layer resize | Base layer only → misaligns non-uniform art | Every layer resized to output dims (CatmullRom) |
| Metadata | Flat {trait:value} — no marketplace takes it | OpenSea schema + parameterised, rewritable image URI |
The bounded retry is the headline, because it turns a hang into an answer:
for produced < want {
chosen := selectNFT(...)
key := strings.Join(chosen, ",")
if seen[key] {
fails++
if fails >= MaxSelectionRetries {
return GenerateResult{}, &ExhaustionError{Requested: int64(qty), Available: avail}
}
continue
}
}
And the metadata break pays for itself in slice 7: because the image field is a
parameter rather than baked in, once art is pinned to IPFS the entire run's
metadata is re-pointed at ipfs://<cid>/<number>.png by a rewrite pass — filename
and every other byte preserved — with no re-compositing.
Rendering can't be identical, and that's fine
Go's resampling kernels and PNG encoder are not the C# ImageSharp ones, so the rendered bytes differ. Rather than chase an impossible hash match, the render tests assert on perceptual structure — while the metadata JSON, which must be byte-stable for a marketplace, is checked exactly. Parity with the intent, not the mistake. That is the whole discipline of this rewrite in one line: keep the behaviour worth keeping, fix the behaviour that was wrong, and refuse to port the behaviour that never should have existed.