Creating a Go App
This guide walks through building a production-ready Go backend using the redelay go-framework. The framework provides built-in modules for auth (JWT + passwords), users (CRUD + model), and groups (permissions), plus a server package that wires everything together automatically.
Prerequisites
- Go 1.25+ (the framework modules declare
go 1.25.0) - MongoDB 7+
- (Optional) Redis 7+
- A GitHub account with access to the
redelayorganization, and SSH set up for GitHub — the Redelay repos are currently private (see Step 1)
Project structure
A minimal redelay app is just a main.go:
myapp/
├── cmd/api/main.go # Entry point (~20 lines)
├── go.mod
├── .env.example
└── Dockerfile
Key principle: framework modules own business logic AND HTTP routes; your app just includes them.
Step 1 — Initialize the module
mkdir myapp && cd myapp
go mod init github.com/yourorg/myapp
Depend on the framework — versioned (Mode B, recommended)
Redelay's Go modules live in private GitHub repos and are consumed by
version — go.mod pins tags, with no replace directives. Configure
access once per machine:
go env -w GOPRIVATE='github.com/redelay/*,github.com/flowdsl/*,github.com/FlowDSL/*,github.com/dreplyai/*'
git config --global url."[email protected]:".insteadOf "https://github.com/"
The repos are private, so this only works with a GitHub account that has been granted access and an SSH key registered with GitHub. Check before going further — this must print a commit hash, not a permission error:
git ls-remote [email protected]:redelay/go-framework.git HEAD
Then require the framework and go mod tidy. The versions below are the working
set the Redelay reference backend currently pins; @latest works too:
go get github.com/redelay/[email protected]
go get github.com/redelay/[email protected] # event bus + transports
# add-ons, as you need them:
go get github.com/redelay/[email protected]
go get github.com/redelay/[email protected]
go get github.com/redelay/[email protected]
go get github.com/redelay/[email protected]
go mod tidy
Local dev against a sibling checkout — the go.work override. When you edit
the framework and your app together, don't add replace directives to go.mod
(that's the legacy "Mode A" and it leaks into every clone). Instead use a
gitignored go.work — the Go-modules analog of the admin's gitignored
pnpm-workspace.yaml override (see the admin version):
// go.work (gitignored; commit a go.work.example template instead)
go 1.25.0
use (
.
../../redelay/go-framework
../../redelay/go-modules
// … each sibling redelay module you're editing
)
go.mod stays authoritative (a fresh clone / CI builds against the pinned
versions); the workspace overrides those with the local trees for dev. Never
commit go.work.sum — a sum generated by one Go toolchain makes a container on
a different toolchain misresolve a module and attempt a network fetch.
Containers with no GitHub credentials. Workspace mode still reads each pinned
module's go.mod to compute the build graph, so a dev container (which can't
fetch the private repos) needs those go.mods available. Mount the host module
cache read-only and let the bind-mounted trees supply the source:
environment:
GOFLAGS: -mod=readonly # workspace mode forbids -mod=mod
GOTOOLCHAIN: local # don't chase a newer toolchain
GOMODCACHE: /gomodcache
volumes:
- ${HOST_GOMODCACHE}:/gomodcache:ro # `go env GOMODCACHE` on the host
Full recipe (Makefile workspace/deps targets, the mod-cache mount, the
never-commit-go.work.sum gotcha): the starter kit scaffolds all of it with
create-redelay-app.sh (Mode B is the default) — see the
starter kit.
Shipping a Mode B app — Dockerfile with SSH-forwarded private modules, swarm stack, push-to-deploy workflow — is covered in Deploying to production. The Redelay reference backend itself runs this way.
replace github.com/redelay/go-framework => ../go-framework
— still works for hacking inside the monorepo, but a fresh clone / CI can't build
without the sibling trees and nothing is version-pinned. Prefer Mode B for any
real project.Step 2 — Create main.go
Create cmd/api/main.go:
package main
import (
"log"
// Blank imports trigger init() factory registration
_ "github.com/redelay/go-framework/modules/asyncapi"
_ "github.com/redelay/go-framework/modules/auth"
_ "github.com/redelay/go-framework/modules/groups"
_ "github.com/redelay/go-framework/modules/health"
_ "github.com/redelay/go-framework/modules/mcp"
_ "github.com/redelay/go-framework/modules/modspec"
_ "github.com/redelay/go-framework/modules/openapi"
_ "github.com/redelay/go-framework/modules/users"
"github.com/redelay/go-framework/app"
"github.com/redelay/go-framework/server"
)
func main() {
inst, srv, err := server.Default()
if err != nil {
log.Fatal(err)
}
if err := app.Run(inst, srv); err != nil {
log.Fatal(err)
}
}
That's it. server.Default() bootstraps the app (connects MongoDB, discovers and starts
modules) and creates an HTTP runner. app.Run() starts the runner and handles signals.
The HTTP runner sets up:
- CORS middleware
- JWT parsing (OptionalAuth)
- Health check at
{prefix}/health - All module routes (auth, users, etc.)
- OpenAPI spec at
/openapi.json+ API reference UI at/reference - AsyncAPI spec at
/asyncapi.json+ event browser at/asyncapi - Module spec browser at
/modules(+/modules.json) - MCP server at
/mcp
How auto-discovery works
- Each framework module registers a factory in its
init()function (e.g.modules.RegisterFactory("auth", ...)) - Blank imports (
_ ".../modules/auth") causeinit()to run app.Bootstrap()callsDiscoverAndRegister()→ all factories run,Configurephase resolves cross-dependencies (auth discovers users as itsLookupProvider, groups asPermissionsProvider)Start()topologically sorts modules byDependsOnand starts them in order- The HTTP runner iterates all modules implementing
RoutesProvider, registering their routes
Multiple transports
The framework is transport-agnostic. The same modules can power HTTP, Kafka consumers, FlowDSL workers, gRPC servers, or WebSocket servers.
Wire an EventBus so all modules can publish and consume events. The recommended way is
auto-wiring: blank-import the go-events lifecycle module and pick the transport with the
TRANSPORT env var:
import (
_ "github.com/redelay/go-events/module" // creates the bus from env and injects it into ModuleDeps
)
TRANSPORT=memory # memory | kafka | nats | redis (default: kafka)
memory needs no broker — use it for local development and tests. The lifecycle module
registers every module's consumers during Configure and starts the bus during Startup,
so do not call bus.Start yourself — eventbus.EventBus ignores consumers registered
after Start, and calling it before app.Bootstrap silently drops every module consumer.
To supply your own transport instead, pass the bus with app.WithEventBus and keep the
blank import — the lifecycle module then registers consumers on, and starts, the bus you
passed:
import (
"github.com/redelay/go-events/eventbus"
"github.com/redelay/go-events/transport/kafka"
_ "github.com/redelay/go-events/module"
)
t, err := kafka.NewFromEnv() // reads KAFKA_BOOTSTRAP_SERVERS etc.
if err != nil { log.Fatal(err) }
bus := eventbus.New(t)
inst, err := app.Bootstrap(app.WithEventBus(bus)) // no bus.Start — Startup does it
See go-events for the transport env vars.
Modules receive ModuleDeps.EventBus and call bus.Publish(ctx, msg) — they never
import Kafka or Redis directly. See Transports for the full API.
Step 3 — Configure environment
Create .env.example:
HOST=0.0.0.0
PORT=8000
ENV=development
ROUTE_PREFIX=/api/v1
MONGO_URI=mongodb://localhost:27017
MONGO_DATABASE=myapp
CORS_ORIGINS=http://localhost:3000
AUTH_JWT_SECRET=change-me-in-production
TRANSPORT=memory # only read when go-events/module is imported
Step 4 — Adding custom routes
Use srv.Router() to access the underlying chi router and add custom endpoints:
func main() {
inst, srv, err := server.Default()
if err != nil {
log.Fatal(err)
}
// Add a custom route
srv.Router().Get("/api/v1/ping", func(w http.ResponseWriter, r *http.Request) {
httputil.WriteJSON(w, http.StatusOK, map[string]string{"pong": "ok"})
})
log.Fatal(app.Run(inst, srv))
}
Step 5 — Advanced: full control
For cases where you need full control over the app lifecycle:
package main
import (
"context"
"log"
"time"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
"go.uber.org/zap"
"github.com/redelay/go-framework/app"
"github.com/redelay/go-framework/modules"
"github.com/redelay/go-framework/server"
_ "github.com/redelay/go-framework/modules/auth"
_ "github.com/redelay/go-framework/modules/groups"
_ "github.com/redelay/go-framework/modules/users"
)
func main() {
logger, _ := zap.NewDevelopment()
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
client, _ := mongo.Connect(ctx, options.Client().ApplyURI("mongodb://localhost:27017"))
cancel()
application := app.New("myapp", logger)
application.DiscoverAndRegister(modules.ModuleDeps{
DB: client.Database("myapp"),
Logger: logger,
})
application.Start(context.Background())
inst := &app.Instance{App: application, MongoClient: client, Logger: logger}
srv := server.FromInstance(inst, server.LoadConfig())
// Add custom routes, middleware, etc.
log.Fatal(app.Run(inst, srv))
}
Module routes
Built-in modules expose their routes automatically via RoutesProvider:
auth module routes (under /auth)
POST /login— Login with email + password (also supports OAuth2 formusername/password)POST /refresh— Refresh token pairPOST /revoke— Revoke a refresh token
users module routes (under /users)
GET /me— Current user profile (authenticated)GET /— List users (superuser only)POST /— Create user (superuser only)GET /{id}— Get user by ID (self or superuser)PATCH /{id}— Update user (self or superuser; non-superusers cannot changeis_active/is_superuser)DELETE /{id}— Delete user (superuser only)
All routes are served under the configured ROUTE_PREFIX (default /api/v1), so the
full path for login is POST /api/v1/auth/login.
Built-in middleware
The auth module provides net/http middleware you can use with chi:
| Middleware | Description |
|---|---|
auth.AuthMiddleware(jwtCfg) | Require valid JWT; rejects unauthenticated requests |
auth.OptionalAuth(jwtCfg) | Parse JWT if present, continue if absent |
auth.RequireActiveUser | Ensure claims exist in context |
auth.RequireSuperuser | Require is_superuser == true |
auth.RequirePermission("perm") | Check specific permission (superusers bypass) |
auth.RequireAnyPermission("a","b") | Check any of the listed permissions |
auth.APISecretMiddleware(secret) | Validate X-API-Secret header |
Permissions
Permissions are resolved at login/refresh time from the user's groups and embedded in the JWT:
- User model has
user_groups []ObjectID - Groups module stores
permissions []stringper group - At login, auth module calls
PermissionsResolver.ResolvePermissions(ctx, groupIDs)→ gets union of all group permissions - Permissions are embedded in
Claims.Permissions RequirePermission("users:write")checksClaims.Permissions— O(1) per request, no DB lookup
Environment variables
Bootstrap (all transports)
| Variable | Default | Description |
|---|---|---|
APP_NAME | redelay | Application name |
ENV | development | Environment name |
DEBUG | false | Debug mode |
MONGO_URI | mongodb://localhost:27017 | MongoDB connection string |
MONGO_DATABASE | redelay | Database name |
MONGO_TIMEOUT_SECONDS | 15 | Connection timeout |
HTTP server
| Variable | Default | Description |
|---|---|---|
HOST | 0.0.0.0 | Listen host |
PORT | 8000 | Listen port |
ROUTE_PREFIX | /api/v1 | API route prefix |
CORS_ORIGINS | * | Comma-separated allowed origins |
Auth module
| Variable | Default | Description |
|---|---|---|
AUTH_JWT_SECRET | change-me | JWT signing secret |
AUTH_JWT_ALGORITHM | HS256 | JWT algorithm (HS256/HS384/HS512) |
AUTH_ACCESS_TOKEN_TTL_MINUTES | 30 | Access token lifetime |
AUTH_REFRESH_TOKEN_TTL_DAYS | 7 | Refresh token lifetime |
Users module
| Variable | Default | Description |
|---|---|---|
USERS_COLLECTION | users | MongoDB collection name |
USERS_DEFAULT_PAGE_SIZE | 20 | Default pagination size |
USERS_MAX_PAGE_SIZE | 100 | Maximum pagination size |
Groups module
| Variable | Default | Description |
|---|---|---|
GROUPS_COLLECTION | groups | MongoDB collection name |
Local development
The app needs MongoDB (and Redis only if you use Redis-backed features). A minimal
docker-compose.yml next to go.mod:
services:
mongo:
image: mongo:7
ports: ["27017:27017"]
volumes: [mongo-data:/data/db]
redis: # optional
image: redis:7
ports: ["6379:6379"]
volumes:
mongo-data:
docker compose up -d # start dependencies
cp .env.example .env # then export it, e.g. `set -a; . ./.env; set +a`
go run ./cmd/api # run the app
The starter kit scaffolds the same
thing (infra/docker-compose.yml, plus a Mongo-only docker-compose.min.yml meant to
pair with TRANSPORT=memory).
Available endpoints
| Method | Path | Description |
|---|---|---|
GET | /api/v1/health | Health check |
POST | /api/v1/auth/login | Login (email + password, or OAuth2 form) |
POST | /api/v1/auth/refresh | Refresh token pair |
POST | /api/v1/auth/revoke | Revoke refresh token |
GET | /api/v1/users/me | Current user profile |
GET | /api/v1/users | List users (admin) |
POST | /api/v1/users | Create user (admin) |
GET | /api/v1/users/{id} | Get user by ID |
PATCH | /api/v1/users/{id} | Update user |
DELETE | /api/v1/users/{id} | Delete user (admin) |
GET | /openapi.json | OpenAPI 3.0 spec |
GET | /reference | API reference UI (Scalar/Swagger/Redoc) |
GET | /asyncapi.json | AsyncAPI 2.6 spec (JSON) |
GET | /asyncapi | AsyncAPI Studio — event browser UI |
GET | /modules.json | Module spec JSON (routes, events, consumers per module) |
GET | /modules | Module spec browser (links to OpenAPI + AsyncAPI) |
POST | /mcp | MCP JSON-RPC endpoint |
POST /api/v1/users/signup is not in this list: signup is flow-driven and is only bound
when the app also imports flowexec (go-flowdsl/flowexec/module) and
go-framework/modules/users/flowtemplates, which auto-publishes the
users/registration-basic flow on first boot.