Hello, Velocity

What's in the box of a full-stack Go framework: a radix-tree router, an ORM written from scratch, a native Inertia adapter, factories and a test client for ergonomic tests, and pre-1.0 honesty.

·13 min read

Most Go web stacks ask you to assemble the framework yourself: a router here, an ORM there, a queue library borrowed from someone's blog post, auth glued on by hand, a config loader written in an afternoon and never touched again. It works. It also means every project starts from zero and ends with a pkg/ folder full of opinionated wiring no one else can read.

Velocity takes the opposite bet. One framework. One mental model. Thirty-plus packages behind a unified API. Routing, ORM, auth, cache, queues, mail, storage, scheduling, broadcasting, WebSockets, gRPC, and an Inertia bridge — all first-class, all sharing the same conventions, all configured the same way, all swappable along clean boundaries.

This post is the long-form introduction. If you've read the landing page, you already know the pitch. Here is the substance behind it: what's in the box, what the design bets are, what's stable, what isn't, and what this blog will be doing next.

The case against assembly-required

The argument for "pick your own libraries and glue them" is real. Different projects have different needs, ecosystems mature unevenly, and a framework that bundles everything risks shipping the wrong opinion in some component you actually care about.

The argument against is the one most Go teams discover the hard way. Once you've picked your libraries, you have to teach them to coexist:

  • Your router exposes a different middleware contract than your queue worker.
  • Your ORM's transaction handle doesn't compose with your cache's pipelining.
  • Your mail client and your job runner have unrelated retry semantics.
  • Your config loader knows about everything, and everything knows about your config loader.

Each of those is fixable in an afternoon. The cost is that every project across your organization fixes it slightly differently. Onboarding a new engineer means walking them through your pkg/ folder. Reading someone else's Velocity-shaped Go project should not require a dedicated tour.

Velocity removes the glue, not the choices. You still pick Postgres or SQLite, Redis or in-memory, a SaaS mailer or local SMTP. The shape of the code that uses them is settled before you start.

What's in v0.32

The current pre-1.0 series ships these, all under one import:

  • Routing. Radix-tree router with parameter, wildcard, and regex-constrained segments, route groups, group-scoped middleware, and a status-aware response writer.
  • ORM. Written from the ground up, not a wrapper around an existing library. Generic, type-safe queries (orm.NewQuery[User]()), associations, eager loading, scopes, soft deletes, and migrations. SQLite, Postgres, and MySQL drivers behind one query API.
  • Auth. Session-based and JWT-based authentication, with secure-cookie defaults, CSRF protection, X-Real-IP handling, and a guest/auth middleware pair.
  • Cache. A unified cache interface with Redis, in-memory, and file-system stores. Tagged invalidation. Atomic counters. Distributed locks with context propagation.
  • Queues. Redis-backed and in-memory queue drivers. Job retries, delayed jobs, batched dispatch, and a worker pool with bounded shutdown.
  • Mail. SMTP and SaaS-friendly mailers, structured templates, attachment caps, and CRLF-injection protection in headers.
  • Storage. Local-filesystem and S3-compatible drivers for blob storage, with consistent file-handle semantics across both.
  • Broadcasting. Real-time event broadcasting over WebSockets with channel scoping and presence tracking.
  • Scheduling. A cron-style scheduler with overlap protection and a single-instance lock for clustered deployments.
  • WebSockets and gRPC. Both first-class, with shutdown-aware handlers and context propagation through the request path.
  • Inertia adapter (bond). A native Inertia.js protocol implementation in Go, not an HTTP-bridge to a Node sidecar. Build a React or Vue frontend on top with no API design required. Server-rendered HTML on initial load, JSON for transitions.
  • Project-local CLI. The ./vel binary covers the daily loop: dev server with HMR, migrations, queue and scheduler workers, code generation for every primitive (./vel make:model Post -m, make:handler, make:job, make:mail...), production build to a single binary. Generated code follows the framework's conventions so the codebase reads consistently as it grows.

Each piece is replaceable, but you do not have to replace anything to get started. velocity new produces a project where every box above is wired, working, and tested.

The driver-swap pattern

The design idea worth dwelling on: configuration over code paths.

Most frameworks treat "what database am I talking to" as a code-level choice. Velocity treats it as an env var. The same handler that runs against SQLite on a developer's laptop runs against Postgres in production, with no code changes:

// Same handler. SQLite locally, Postgres in production.
func ListPosts(c *router.Context) error {
    var posts []models.Post
    if err := orm.NewQuery[models.Post]().
        Where("published_at IS NOT NULL").
        OrderBy("published_at DESC").
        Limit(20).
        Find(c.Ctx(), &posts); err != nil {
        return err
    }
    return c.JSON(200, posts)
}
# .env (development)
DB_DRIVER=sqlite
DB_DSN=storage/dev.db

# .env.production
DB_DRIVER=postgres
DB_DSN=postgres://app:secret@db:5432/app

The same pattern runs through the rest of the framework. CACHE_DRIVER flips Redis to in-memory for tests. QUEUE_DRIVER flips a Redis-backed queue to an in-process one. MAIL_DRIVER flips SMTP to a log-only writer. Tests don't need Docker. Local dev doesn't need cloud credentials. Production doesn't need code changes.

This pattern only works because Velocity's component APIs are designed around a common interface, not the specifics of any one driver. That is the discipline the framework is paying for. It is also the discipline that makes the framework worth using.

Composition: middleware and providers

Every Velocity app boots through a single declarative chain. The shape is the same in velocity new's main.go and in production:

v, _ := velocity.New()
if err := v.
    Providers(app.Configure).        // bind services
    Middleware(app.Middleware).      // global / web / api stacks
    Routes(routes.Register).         // route definitions
    Commands(commands.Register).     // custom CLI commands
    Events(app.Events(v.Log)).       // event listeners
    Run(); err != nil {
    log.Fatal(err)
}

Each callback receives a typed builder and returns nothing. The wiring is explicit, debuggable, and fully Go.

Middleware at three layers

The framework distinguishes three middleware scopes, each with a different domain of responsibility.

Global middleware runs on every request before any route group is entered. The right home for cross-cutting concerns: request ID injection, structured logging, panic recovery, security headers, host trust enforcement.

func Middleware(m *velocity.MiddlewareStack) {
    m.Global(
        middleware.RequestID,
        middleware.Logger,
        middleware.Recover,
        middleware.SecurityHeaders,
    )
}

Group middleware is attached to a route group. The Web() and API() groups are the two that ship by default. The web group typically carries session, CSRF, flash messages, and the Inertia bridge. The API group carries JSON rendering defaults, throttling, and bearer-token authentication.

func Middleware(m *velocity.MiddlewareStack) {
    m.Web(
        middleware.StartSession,
        middleware.VerifyCSRF,
        middleware.ShareInertiaProps,
    )
    m.API(
        middleware.ThrottleAPI,
        middleware.AuthenticateBearer,
    )
}

Route-level middleware scopes to a specific sub-group. Apply it with .Use(...) after defining the routes inside:

web.Group("", func(auth router.Router) {
    auth.Get("/dashboard", handlers.Dashboard)
    auth.Get("/settings", handlers.Settings)
}).Use(middleware.Auth)

Anything inside this group runs middleware.Auth after the global and web stacks; routes outside it do not. The inverse pattern (middleware.Guest) keeps signed-in users off /login and /register. The starter ships both, and more groups compose the same way.

The order is consistent: global → group → route, matching the way the request travels through the routing tree.

Providers, briefly

A ServiceProvider is the unit of modular service registration. The interface has three methods:

type ServiceProvider interface {
    Register(s *Services) error  // bind services into the container
    Boot(s *Services) error      // wire cross-provider dependencies
    Shutdown(ctx context.Context) error
}

The lifecycle is two-phase: every provider's Register runs first, then every provider's Boot. The split exists so a provider that depends on another (a mailer that needs the queue, say) can resolve its dependency at boot time without caring what order providers were registered in. Shutdown runs in reverse registration order during graceful teardown, so later-registered resources get released before the things they depend on go away.

In practice you reach for providers when you're integrating something the framework doesn't ship: a custom payment gateway, a third-party analytics SDK, a tenant-aware service. Velocity's own services are registered the same way internally.

The ORM: written from the ground up

The ORM is its own implementation, not a wrapper around an existing library. The reason is the same one that drove the rest of the framework: the API needed to share conventions with the cache, queue, and storage layers, and a wrapper would have leaked the underlying library's idioms into Velocity-shaped code.

The query surface

The query API is generic and chainable. Model[T] carries every standard verb directly:

// Find a user by primary key, fail if missing.
user, err := models.User{}.FindOrFail(42)

// Read with conditions, ordering, eager-loaded relations.
posts, err := models.Post{}.
    Where("published_at IS NOT NULL").
    Where("category_id = ?", categoryID).
    OrderBy("published_at", "desc").
    With("Author", "Tags").
    Paginate(page, perPage)

// First-or-create idiom.
user, err := models.User{}.FirstOrCreate(
    map[string]any{"email": email},
    map[string]any{"name": name, "verified_at": time.Now()},
)

The full surface covers what you'd expect from a production ORM: read verbs (Where, WhereIn, WhereNull, OrderBy, With), write verbs (Create, CreateMany, Update, DeleteWhere, Increment, Decrement), aggregates (Sum, Avg, Min, Max), pagination, the Find / FindOrFail / FirstOrCreate / UpdateOrCreate family, and a typed Raw(...) escape hatch for the cases where the builder is the wrong tool. Soft deletes get their own model base (SoftDeleteModel[T]), as do UUID primary keys (UUIDModel[T], SoftDeleteUUIDModel[T]). The choice is at the type level, not a field tag.

Dirty tracking on instances

Loaded instances track which fields you've changed. IsDirty(), HasChanged("email"), and GetChanges() are part of the model interface, which makes selective updates and audit logs trivial:

user.Name = newName
if user.IsDirty() {
    if err := user.Save(); err != nil {
        return err
    }
    log.Info("user.updated", "changed", user.GetChanges())
}

Migrations, seeds, and events

Migrations are Go, not YAML. The schema builder (orm/migrate) handles the same tables across SQLite, Postgres, and MySQL with a single API. Seeds (orm/seed) compose with factories (orm/factory) so populating a development database is the same code path as populating a test one.

Every query emits typed events (QueryExecuted, QueryFailed, TxRecover). Subscribe in your event listeners to log slow queries, ship metrics, or surface failed transactions. Nothing has to be retro-fitted as a logger middleware.

The breadth is the point. The ORM is not "we shipped the basics, you'll grow out of it." It covers what a typical production CRUD app needs without reaching for a second tool, and the escape hatches (Raw, plus the underlying *sql.DB via manager.DB()) are there for the cases where it isn't.

Secure by default, configurable everywhere

A framework's defaults set the ceiling on how secure the average app built on it ends up being. Velocity's defaults err on the strict side, and the unsafe overrides are all explicitly named so you cannot reach for them by accident.

CORS. DefaultCORSConfig() ships with AllowedOrigins empty, which rejects every cross-origin request until you list the origins you actually want. The opt-out for development is named InsecureAllowAllCORS(), and the name is the warning.

// production: explicit allowlist
m.API(router.CORS(router.CORSConfig{
    AllowedOrigins:   []string{"https://app.example.com"},
    AllowCredentials: true,
}))

// development only: wildcard
m.API(router.CORS(router.InsecureAllowAllCORS()))

CSRF. Web routes get CSRF protection via a double-submit token bound to the session ID, not only to the request. The starter wires this in the Web() middleware stack with no configuration required. To exempt a webhook route you whitelist its path explicitly. There is no "skip CSRF for everything in this group" escape hatch.

Secure cookies. Session and CSRF cookies default to Secure, HttpOnly, and SameSite=Lax. To downgrade for local HTTP development, you flip an env var, not a code path.

Security headers. A SecurityHeaders middleware ships with HSTS, content-type-options, frame-options, and XSS-protection set to safe values. Each is overridable with a functional option (WithCSP(...), WithHSTSMaxAge(...)) so you tighten or loosen one at a time, without rewriting the whole config.

JWT and sessions. Tokens signed with none are rejected at parse. Asymmetric algorithms validated against an HMAC key are rejected at parse. Session IDs regenerate on privilege change, so a fixation attack does not survive login. c.RealIP() honors a configured trust list, not whatever header the closest proxy attached.

Mail. Attachment sizes are capped. CRLF in headers and structured setters is rejected at the mailer boundary, so the mailer is hard to misuse as an injection vector even if a developer pipes user input into a header.

The throughline: any default the framework ships is something a careful production Go team would set themselves anyway. Velocity does it for you, and the opt-out is named so reviewers in a PR can spot it on sight.

The starter is fully wired

velocity new is not a hello-world skeleton. It is a working full-stack app on the first boot. What ships in the box:

  • Inertia bridge. A React + TypeScript + Tailwind + Vite frontend bound to the Go backend through Velocity's bond package, which implements the Inertia.js protocol natively. There is no REST contract to design and no JSON serialization layer to wire. Your handlers return Inertia responses with typed props, and the matching React component picks them up.
  • Hot-module-reload on both ends. Save a Go file, the server reloads. Save a .tsx or .css file, the browser refreshes. One terminal, one command, both sides moving together.
  • Scaffolded auth. Login, register, logout, session middleware, password hashing with bcrypt, CSRF protection. All wired against the SQLite default. The Inertia bridge means React forms post to Go handlers with no plumbing in between.
  • A working dashboard. Authenticated landing page, navbar, logout button. The first thing you ship does not have to be the first thing you scaffold.
  • Migrations applied on first boot. Users, cache, and jobs tables are ready. You write your first model migration on top.
  • Tailwind preconfigured. Colors, spacing, dark-mode class, all set. The starter is theme-able the moment you open it.

The bet: the first thirty minutes of using a framework should produce a working signup flow, not a config rabbit hole.

Built for testing

Test ergonomics is a first-class concern. A framework you cannot test against ergonomically becomes a framework you stop testing. Velocity ships first-class testing primitives so the test suite stays as fast and readable as the code it covers.

In-process test client. testing/http.NewTestClient(t, router) runs a request through the same handler chain production uses, with no network round-trip. Assertions come back as Go values.

Generic model factories. factory.NewModelFactory[T] is type-safe and chainable. Count, State, DefineState, Make, Create, CreateMany. The API you reach for to populate a database in two lines:

users, err := factory.NewModelFactory(manager, func() *User {
    return &User{
        Email: factory.F().Email(),
        Name:  factory.F().Name(),
    }
}).Count(50).Create(nil)

Faker built in. factory.F() returns a gofakeit-backed generator for realistic names, emails, addresses, structured data. No separate test-data dependency to manage.

File-upload helpers. TestUploadBuilder builds multipart requests; AssertFileUploaded and AssertFileContent check the storage backend after a handler runs. Pairs with the in-memory FakeStorage driver so file-handling tests stay off disk.

Auth in tests. Log a user in via the auth manager directly. No fake form posts, no session-cookie juggling.

The pattern repeats: every component you can use in production has a testing helper that mirrors its real API. Tests read like the code they exercise.

Other notables

A full-stack framework has more surface than one post can do justice to. Things mentioned only in passing here:

  • Auth. Multi-guard authentication (session and JWT side-by-side), policy-based authorization, login throttling, password hashing with bcrypt.
  • Cache. Tagged invalidation, distributed locks with context propagation, atomic counters, and pipelining where the driver supports it.
  • Queues. Delayed jobs, batched dispatch, retry policies with custom backoff, and a worker pool with bounded shutdown.
  • Broadcasting and notifications. Channel-scoped real-time events with presence tracking. A unified notification interface with mail, database, broadcasting, and SMS channels.
  • Validation. Request validation that binds to typed handler input and produces structured errors.
  • Observability. Structured logging on top of slog, distributed tracing context for APM hooks, per-driver instrumentation, and typed events on every component.

Each of these gets its own deep dive in the coming weeks. Subscribe to the RSS feed to catch them as they go up.

For the API reference and the technical detail that already exists, see the docs.

Pre-1.0 status

Velocity is pre-1.0 and public about it. Concretely:

What's stable. The shape of the public API. Bootstrap (velocity.New, App.Routes, App.Middleware), routing, ORM core, cache and queue interfaces, auth middleware contracts. Code you write today against v0.32 will need light touch-ups to compile against v0.40 if it ever lands, not a rewrite.

What's still moving. The relations API on the ORM is being polished. The broadcasting subscription model is being finalized. Benchmark numbers are not yet pinned. Error message strings are not part of the compatibility surface, and anything in internal/ is fair game for any release.

Breaking changes happen between minor versions. That's the deal. The CHANGELOG calls out every break with the migration path. Recent example: v0.31 → v0.32 redefined the cache lock signature to take a context.Context, because not propagating context through a Redis call was a real bug waiting to happen.

v1.0 is a deliberate decision, not a calendar date. Promotion to v1.0 means the API surface stops moving in breaking ways. The plan is v1.0.0-rc.1 first, a public testing window, then v1.0.0. Once we're there, Velocity commits to staying on v1 indefinitely. No v2 module path, no "bump the major" escape hatches. That promise is worth not making prematurely.

If you need a v1.0-stable contract today, Velocity is not the right pick yet. If you can adopt pre-1.0 and tolerate the occasional rename in exchange for an outsized voice in what v1.0 ends up being, the inverse is true.

A note on the team

VelocityKode is currently one person, evenings and weekends, around a day job. That shape changes a few things you might assume from the breadth of the framework:

  • The framework moves at the pace of focused side-project hours, not a VC-funded sprint. The 37 packages above represent roughly four months of part-time work since the first commit in late December 2025.
  • Issue triage is direct. File an issue and a real person reads it the same week, often the same day.
  • Pre-1.0 status is partly a function of resourcing. Stabilizing every component to v1.0-quality takes time, and shipping breaking changes openly is preferable to shipping a premature v1.0 that can't be honored.

Pre-1.0 honesty extends to team-size honesty. We're not pretending to be bigger than we are. If you adopt now you have a vote on what gets prioritized; if you wait for v1.0 you get a smaller voice but a more settled API.

How to get involved

That is the whole pitch. The framework exists. It is pre-1.0 and shipping in public. If you've felt the glue-code grind in Go and wished one of the productive full-stack frameworks ran on Go's runtime, this is your invitation to try the version that does.

[ Get Started ]

Ready to build something great?

Get started with Velocity in minutes.

>brew install --cask velocitykode/tap/velocity