My Utility Belt: One Library in Go, One in .NET
Every product I build needs the same layer underneath it: a cache abstraction, some DTO shapes, base models with soft delete, an HTTP layer that emits OpenAPI, auth plumbing, and a handful of small things I always end up rewriting badly.
That's punksky/utilitybelt in Go and punksky/utilitybelt-net in .NET. One idea, two languages, and the port turned out to be more interesting than either version alone.
The Go one came first
go.punksky.com/utilitybelt, Go 1.26.4, 117 files across 24 top-level packages:
application auth bus cache/{boltcache,multicache,valkeycache} context_ex
db dcron dto error_ex health_check/valkey_check job_pool
jwt_utils logger must_ptr optional permissions readonly_map
regex_cache scim simple_sync tenant timer
ubhttp/{endpoint,fileupload,middleware,response} usage/store
Install it as a library or as the CLI tools:
go get go.punksky.com/utilitybelt@latest
go install go.punksky.com/utilitybelt/cmd/...@latest
The pieces I'd actually reach for again:
cachewith three strategies behind one interface:boltcachefor embedded local state,valkeycachefor shared, andmulticachefor tiered L1/L2 where reads mostly miss L2.ubhttp/endpointtype-safe endpoints that generate their own OpenAPI. You build it with a chain and it registers itself:
endpoint.New[Item]().
GET().
WithPath("/items/:id").
WithName("getItem").
WithDescription("Fetch a single item").
Register(router)
No annotations, no comment scraping, no spec file that drifts from the handler. The docs are wrong the moment you rename a method, which is the entire reason specs drift.
sqlgena SQLDelight-style generator that turns.sqschema and query files into typed GORM APIs.dbwithModel,ModelUintandModelUuidas GORM base models, each carrying an ID, timestamps and soft delete.error_exwith an error stack and a handler manager, plus the small stuff you'd otherwise write badly:must_ptr,optional,readonly_map,regex_cache, a resettabletimer, andsimple_sync.
It also depends on openfga/go-sdk, kin-openapi, swaggest/openapi-go, valkey-io/valkey-go, bbolt, gorm.io/gorm and OpenTelemetry. That's a deliberate dependency list: everything in it is something I didn't want to write.
Then I ported it
On the 21st of August I started PunkSky.Utilitybelt as a commit called Init port. It added 82 files and 4,593 lines. Two weeks later it was 149 files across seven projects:
Src/ PunkSky.Utilitybelt
PunkSky.Utilitybelt.EFCore
PunkSky.Utilitybelt.Http
PunkSky.Utilitybelt.Valkey
PunkSky.Utilitybelt.TypedFGA.Abstractions
PunkSky.Utilitybelt.TypedFGA.EntityFrameworkCore
PunkSky.Utilitybelt.TypedFGA.Generator
Test/ .Benchmarks .EFCore.Tests .Metrics.Tests
Same four ideas, same public shape, net10.0, MIT. The README is blunt about where it is: pre-v1.0.0, unstable, expect breaking changes in minor and patch releases. The Go README doesn't say that, because it doesn't need to yet.
Three things that didn't survive the crossing
OpenFGA turned into three projects. In Go it's a function call into openfga/go-sdk. In .NET it needed TypedFGA.Abstractions for the contracts, TypedFGA.EntityFrameworkCore for persistence, and TypedFGA.Generator as a source generator. One package reference became three assemblies. The problem didn't get harder the ecosystem you're working inside just doesn't give you the SDK for free.
GORM's hooks and EF Core's interceptors are not the same seam. Go's base models carry behaviour directly, so soft delete lives on the model. EF Core doesn't work that way, so soft delete became an ISaveChangesInterceptor registered by builder.AddSoftDelete(). I didn't transliterate the model; I had to find a different extension point and redesign around it. This is the single biggest difference between the two versions and the one I'd flag to anyone planning a port.
A port is a prioritisation argument, not a copy. scim, dcron, job_pool and simple_sync have no .NET counterpart yet, and that was a decision rather than an oversight. The .NET side picked up things Go doesn't have at all: bulk batching with configurable batch size, per-entity tag tables via ConfigureTagObjects(), and a metrics test project. Neither version is the whole thing.
The release cadence
Worth noting because it's the part I got right by accident: the last commit is four weeks old and v0.0.30 shipped twelve days ago. Thirty tagged versions, cut from a stable trunk rather than from whatever was on the branch when I got around to it.
Tags are cheap. A version number you can't map to a known-good commit is expensive.