---
title: 'My Utility Belt: One Library in Go, One in .NET'
url: 'https://kaleb.au/blog/utilitybelt-go-and-dotnet'
markdown: 'https://kaleb.au/blog/utilitybelt-go-and-dotnet.md'
date: '2026-08-15'
description: "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 …"
---

# [My Utility Belt: One Library in Go, One in .NET](https://kaleb.au/blog/utilitybelt-go-and-dotnet)

   15 Aug 2026  

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](https://git.punksky.com/punksky/utilitybelt/) in Go and [punksky/utilitybelt-net](https://git.punksky.com/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:

```bash
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:

```go
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](https://git.punksky.com/punksky/utilitybelt-net/) 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.

---

## Navigation

- Parent: [Blog](https://kaleb.au/blog.md)
- Previous: [The High Court Is Deciding Whether Australia Needs a Power It Already Rejected at a Referendum](https://kaleb.au/blog/white-australia-and-the-1951-question.md)
- Next: [Three Forges, One Man](https://kaleb.au/blog/three-forges-one-man.md)
