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:

  • cache with three strategies behind one interface: boltcache for embedded local state, valkeycache for shared, and multicache for tiered L1/L2 where reads mostly miss L2.
  • ubhttp/endpoint type-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.

  • sqlgen a SQLDelight-style generator that turns .sq schema and query files into typed GORM APIs.
  • db with Model, ModelUint and ModelUuid as GORM base models, each carrying an ID, timestamps and soft delete.
  • error_ex with an error stack and a handler manager, plus the small stuff you'd otherwise write badly: must_ptr, optional, readonly_map, regex_cache, a resettable timer, and simple_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.