AZ
All writing
5 min read

Rewriting a .NET Blazor app in Go without losing parity

godotnetnextjsrewritesiwe
Part of the projectNFTMixer (Go)A C#/.NET 6 Blazor generative-NFT builder rewritten as a Go 1.26 API + Next.js 16 frontend — parity reached, and the predecessor’s security holes closed.View project

"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 NftGenerator library (the live engine was actually inside a .razor component), and whole superseded component generations (ProjectOLD.razor, ... - Copy.razor) that were referenced but unreachable.
  • Dangerous code — an unauthenticated pg_dump export endpoint and a committed live Pinata key. Porting those faithfully would have been faithfully reproducing debt.
  • Unfinished code — an IPFS PublishToIpfs that 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:

BehaviourC#/.NET predecessorGo rewrite
GeneratorsTwo — weighted-with-dupes and unique-with-unbounded-whileOne weighted-and-unique with a bounded retry
ExhaustionHangs when combos run outExhaustionError{Requested, Available} — a clean 4xx
Combination rarity(varies) — not gated on the rarest stepMIN of step rarities: {0.5, 0.8} → 0.5
Cycle guardgoto — drops whole parallel branchescontinue — skip the node, keep its siblings
Quantity driftSingle pass, cannot always convergeLoop until the total equals the request exactly
NFT numberingnftNumber++ on every attempt → gapsConsumed only by an accepted NFT → gap-free
Layer resizeBase layer only → misaligns non-uniform artEvery layer resized to output dims (CatmullRom)
MetadataFlat {trait:value} — no marketplace takes itOpenSea 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.

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