Skip to content
Release

Introducing gopackx: a small collection of Go packages I actually use in production

Why I built gopackx, what's inside, and what's coming next.

Andrian Prasetya Andrian Prasetya

For the last few years, every backend project I started in Go ran into the same four problems before it ever shipped a feature: how do I migrate this schema, how do I audit who did what, how do I send a notification without standing up a service for it, and how do I document this API without lying to my clients.

Each time, I went looking. Each time, I came back with the same impression: the Go ecosystem is full of libraries that are either too big to read or too dead to depend on. So eventually I stopped searching and started extracting.

That’s gopackx. Four packages, each pulled out of a real production service, each small enough that you can read the source in one sitting and decide for yourself whether to trust it.

Why another collection of packages?

Most of the libraries I’ve adopted over the years have failed in one of two ways. The first failure mode is the framework: you reach for a small thing, import it, and discover it has opinions about your DI container, your logger, your context propagation, and the shape of your main(). By the time you’ve satisfied all of them, your codebase has a new dependency graph.

The second failure mode is the orphan: a perfectly-scoped package, exactly what you need, last commit three years ago, four open issues describing data loss bugs. You can fork it. You probably should. But forking means owning, and once you own it you may as well have written it yourself.

The best Go libraries are boring. They do one thing, the API hasn’t changed in two years, and you forget you’re using them. That’s the bar.

gopackx is my attempt at that bar — for the four problems I keep solving.

What’s inside

go-migration

Laravel-style migrations and seeders in plain Go. A migration is a struct with Up and Down methods that receive a fluent schema builder — no raw SQL files, no autogeneration from struct tags. The migrator runs pending migrations in order, each in its own transaction unless you opt out, and records them in batches you can roll back.

go database/migrations/create_users_table.go
package migrations

import "github.com/gopackx/go-migration/pkg/schema"

type CreateUsersTable struct{}

func (m *CreateUsersTable) Up(s *schema.Builder) error {
return s.Create("users", func(bp *schema.Blueprint) {
	bp.ID()
	bp.String("email", 255).Unique()
	bp.Boolean("active").Default(true)
	bp.Timestamps()
})
}

func (m *CreateUsersTable) Down(s *schema.Builder) error {
return s.Drop("users")
}

You register it under a timestamped name with m.Register("20260101120000_create_users", &CreateUsersTable{}) and call m.Up(). The builder is a DSL, and that’s the trade-off: it covers what the PostgreSQL, MySQL, and SQLite grammars implement, and for anything else s.RawExec runs SQL you write yourself. The package also ships seeders with factories, and a CLI with the commands you’d expect from Laravel — migrate, migrate:rollback, make:migration, db:seed.

The integration suite runs against real SQLite, PostgreSQL, and MySQL — not mocks — because a migration that “works” against a mock has never once told me the truth about production.

go-audit

An audit trail and API call log in one package. Plug in the GORM, Bun, or Ent adapter and every create, update, and delete through that ORM is recorded with its old values, new values, and the user who made it — no manual Record() calls. Outbound API calls go through auditor.API().Record, with sensitive headers and body fields redacted and oversized bodies truncated. Both share a transaction_id, so “which API call led to this row changing” has one answer.

Storage sits behind a Store interface. The default writes two tables through database/sql on PostgreSQL, MySQL, or SQLite; an in-memory store covers tests. The core has no dependencies outside the standard library, and each ORM adapter is its own module. The trade-off: by the time the audit row is written, your ORM write has already happened. If the audit insert fails, go-audit reports it as loudly as each ORM allows, but it doesn’t roll your write back.

go-notification

One API for notifying a user wherever they should hear from you, modeled on Laravel’s Notification facade. You define a notification once — Via() lists the channels, ToMail(), ToWhatsApp(), ToDatabase() and the rest build each message. There are 24 drivers across 7 channels: mail, WhatsApp, SMS, chat (Slack, Telegram, Discord, Teams), push through FCM, an in-app database channel, and generic webhooks. The drivers are built on the standard library rather than vendor SDKs, so the module adds nothing else to your go.mod.

Delivery is async by default: a bounded worker pool, retries with exponential backoff, and per-channel rate limits. Pass notification.Sync() when you need to block until a message is sent — an OTP, say. The trade-off is that the queue lives in memory. If the process dies mid-retry, that notification is gone; if you need durable delivery, put a real job queue in front of it.

open-swaggo

OpenAPI 3.1 documentation written in Go (module github.com/gopackx/open-swag-go). It’s a library, not a CLI: no comment annotations to parse and no generate step. Each endpoint is an openswag.Endpoint struct that lives in the same file as its handler, and the schemas come from your real request and response types. You mount the docs on net/http or through the Chi, Gin, Echo, or Fiber adapter, and they’re served with a Scalar UI. docs.Validate() fails fast at startup on duplicate routes or colliding operation IDs, and the versioning package diffs two specs and flags breaking changes before your clients find them.

It exists because I was tired of docs that lied about my API. The trade-off: nothing ties an Endpoint to the handler it describes — you still register the route yourself — so the docs stay honest because they sit next to the code, not because a compiler checks them.

How every package is built

There are three principles I’ve tried to hold across all of them:

1. Zero magic

No global registries, no init() side effects, no code generation in your build. Reflection shows up only where the job is reading your structs: open-swaggo’s schemas and go-audit’s adapters and diffs. If you read the source, you can predict what runs.

2. Tested where it matters

The two packages that touch your database — go-migration and go-audit — run their integration suites against real PostgreSQL, MySQL, and SQLite in CI. Unit tests are useful; they’re not what tells me a release is safe.

3. Stable APIs

Once exported, breaking it requires a major version bump and a written migration path. Refactor freely, break carefully. I’ve already bent this once: go-migration v1.2.0 removed migrator.Run() and added methods to the schema.Grammar interface in a minor release. Its README has an upgrade section that lists every change.

What’s next

When I first published this post, I listed v1.0 cuts as the next milestone. They had already happened: all four packages tagged v1.0.0 in April. So the roadmap is smaller than I wrote. Keep the v1 APIs where they are. Run every package’s tests against the latest two Go releases — the CI matrices are uneven today. And, maybe, one new package focused on background job orchestration — only if I keep needing it across services. No expansion for the sake of expansion.

If you’d like to follow along, the GitHub org is where the work happens. Issues and discussions are open. Bug reports with a minimal repro are the best gift you can give a maintainer.

Try one of the packages this week. If it breaks, file a bug. If it doesn’t, let me know that too — silence is the worst feedback.