Go Framework
Module: github.com/redelay/go-framework
Full-featured Go framework providing a module system with auto-discovery, built-in auth/users/groups modules with self-contained HTTP routes, a transport-agnostic bootstrap system with pluggable runners (HTTP, Kafka, FlowDSL, gRPC, WebSocket), generic MongoDB and ClickHouse CRUD, ClickHouse batch writer, Redis-backed distributed primitives (lock, semaphore), JWT + password management, OpenAPI 3.0 spec generation with Swagger UI / Scalar / Redoc, MCP server, and config loaders.
The canonical IR types, FlowDSL parser, compilation pipeline, structural validation,
dependency resolution, code generation, and SVG tooling have been extracted to the
standalone go-flowdsl library (github.com/redelay/go-flowdsl). The framework
integrates it without re-exporting — use go-flowdsl directly for IR/DSL work.
AsyncAPI and OpenAPI format adapters remain in go-framework/core.
Bootstrap & Runner architecture
The framework separates application lifecycle from transport:
app.Bootstrap()— connects Mongo, discovers modules, starts them → returns*Instance- Transport runners (
server.Server, futureconsumer.KafkaRunner,grpc.Server, etc.) implementapp.Runner app.Run(inst, runners...)— starts all runners in parallel, handles signals, shuts down cleanly
This means the same module system powers HTTP, Kafka consumers, FlowDSL workers, gRPC, and WebSocket — without any code changes in modules.
Quick start (HTTP only)
import (
"github.com/redelay/go-framework/app"
"github.com/redelay/go-framework/server"
)
inst, srv, err := server.Default() // bootstrap + HTTP runner
app.Run(inst, srv) // signal handling + graceful shutdown
Multiple runners (HTTP + Kafka)
inst, err := app.Bootstrap() // transport-agnostic
httpSrv := server.FromInstance(inst, server.LoadConfig())
kafkaRunner := consumer.NewKafkaRunner(inst, consumer.LoadConfig()) // future
app.Run(inst, httpSrv, kafkaRunner) // both in parallel
No HTTP at all (consumer-only service)
inst, err := app.Bootstrap()
kafkaRunner := consumer.NewKafkaRunner(inst, consumer.LoadConfig())
app.Run(inst, kafkaRunner)
Runner interface
// Any transport adapter implements this:
type Runner interface {
Run(ctx context.Context) error
}
Planned runners: server.Server (HTTP/REST), consumer.KafkaRunner, grpc.Server, ws.Server, flowdsl.Runner.
app package
Bootstrap
import "github.com/redelay/go-framework/app"
// Zero-config from env vars:
inst, err := app.Bootstrap()
// With EventBus and Redis:
inst, err := app.Bootstrap(
app.WithEventBus(bus), // *eventbus.EventBus from go-events
app.WithRedis(rdb), // *redis.Client
)
// Or with explicit config:
cfg := app.LoadBootstrapConfig()
inst, err := app.BootstrapWith(cfg)
// Instance fields:
inst.App // *app.App
inst.MongoClient // *mongo.Client
inst.Logger // *zap.Logger
inst.Config // *app.BootstrapConfig
// Shutdown:
inst.Shutdown(ctx)
Run
// Start runners, wait for SIGINT/SIGTERM, shut down:
err := app.Run(inst, runner1, runner2, ...)
BootstrapConfig env vars
| Variable | Default | Description |
|---|---|---|
APP_NAME | redelay | Application name |
ENV | development | Environment name |
DEBUG | false | Debug mode |
MONGO_URI | mongodb://localhost:27017 | MongoDB connection |
MONGO_DATABASE | redelay | Database name |
MONGO_TIMEOUT_SECONDS | 15 | Connection timeout |
MONGO_READ_PREFERENCE | primary | primary, primaryPreferred, secondary, secondaryPreferred, nearest. Unknown values are refused at startup |
MONGO_READ_CONCERN | (driver default) | local, majority, available, linearizable, snapshot |
MONGO_WRITE_CONCERN | (driver default) | majority or a number of members |
Server package (HTTP runner)
The server package is the HTTP/REST runner. It wires chi router, middleware,
module routes, and MCP server.
Factory methods
| Method | Description |
|---|---|
Default() | Returns (*Instance, *Server, error) — bootstrap + HTTP |
FromInstance(inst, cfg) | Create HTTP runner from existing Instance |
LoadConfig() | Read HTTP-specific config from env vars |
Server methods
| Method | Description |
|---|---|
Run(ctx) | Implements app.Runner — start HTTP, block until ctx done |
Router() | Access the chi.Mux for custom routes |
Handler() | Get the http.Handler |
HTTP Config env vars
| Variable | Default | Description |
|---|---|---|
HOST | 0.0.0.0 | Listen host |
PORT | 8000 | Listen port |
PUBLIC_URL | http://localhost:8000 | Public URL |
ROUTE_PREFIX | /api/v1 | API route prefix |
CORS_ORIGINS | * | Comma-separated allowed origins |
What the HTTP runner sets up
- chi router with RealIP, RequestID, Recoverer, CORS
auth.OptionalAuth— parse JWT if present{ROUTE_PREFIX}/healthendpoint- All
RoutesProvidermodule routes underROUTE_PREFIX - OpenAPI spec at
/openapi.json+ API reference UI at/reference - MCP server at
/mcp
httputil package
import "github.com/redelay/go-framework/httputil"
httputil.WriteJSON(w, http.StatusOK, data) // JSON response
httputil.ReadJSON(r, &input) // decode request body
httputil.Error(w, http.StatusBadRequest, "detail") // {"detail": "..."}
App container (low-level)
For full manual control without Bootstrap:
import "github.com/redelay/go-framework/app"
application := app.New("myapp", logger)
application.DiscoverAndRegister(modules.ModuleDeps{DB: db, Logger: logger})
application.Start(ctx)
authMod := application.Registry().Get("auth").(*auth.Module)
application.Shutdown(ctx)
Module system
Core interfaces
// Every module must implement:
type Module interface {
Manifest() *Manifest
Startup(ctx context.Context) error
Shutdown(ctx context.Context) error
}
// Optional interfaces:
type Configurable interface { Configure(registry *Registry) error }
type RoutesProvider interface { Routes(router Router) }
type MCPProvider interface { MCPTools() []MCPTool; MCPResources() []MCPResource }
type EventsProvider interface {
Events() []*ir.Event // IR metadata — fed to modspec + AsyncAPI
Consumers() []*ConsumerRegistration // runtime consumer wiring
}
ModuleDeps
ModuleDeps is passed to every factory function. Fields that are nil when the
corresponding option is not configured.
type ModuleDeps struct {
DB *mongo.Database // always set if MONGO_URI is configured
Logger *zap.Logger // always set
EventBus EventBus // nil when no transport is configured
Redis *redis.Client // nil when REDIS_URL is not set
}
Wire optional deps at bootstrap:
import (
"github.com/redelay/go-events/eventbus"
"github.com/redelay/go-events/transport/kafka"
)
t, _ := kafka.New(kafka.Config{Brokers: []string{"kafka:9092"}})
bus := eventbus.New(t)
if err := bus.Start(ctx); err != nil { log.Fatal(err) }
inst, err := app.Bootstrap(
app.WithEventBus(bus),
// app.WithRedis(redisClient),
)
Important: Call
bus.Start(ctx)beforeapp.Bootstrap(). Start wires all registered consumer subscriptions to the transport — no messages are consumed until it is called.
EventBus interface
Modules receive a narrow EventBus interface in ModuleDeps — they can publish events
but cannot register new consumers or inspect internal state:
// modules.EventBus — the interface injected into ModuleDeps.
type EventBus interface {
Publish(ctx context.Context, msg *EventMessage) error
Close() error
}
The concrete eventbus.EventBus in go-events adds Register, Start, Stop,
UsePublish, and UseConsume. Modules only ever see the narrow interface; consumers
returned from EventsProvider.Consumers() are registered on the full struct and the bus is
started by the go-events/module lifecycle module (blank-import it; TRANSPORT picks the
backend).
EventDefinition (go-events/typed)
Compile-time-safe event descriptor. Define once per event, publish from any handler.
The canonical import is github.com/redelay/go-events/typed (implementation in
go-framework/runtime/events).
import "github.com/redelay/go-events/typed"
type OrderCreatedPayload struct {
OrderID string `json:"order_id"`
Total float64 `json:"total"`
}
var OrderCreatedEvent = typed.EventDefinition[OrderCreatedPayload]{
Name: "order.created",
EntityType: "order",
Action: "created",
Topic: "order.created",
}
// Publish from a handler (deps.EventBus injected at bootstrap):
msg, _ := OrderCreatedEvent.NewMessage(
order.ID,
typed.Actor{Type: typed.ActorTypeUser, ID: userID},
OrderCreatedPayload{OrderID: order.ID, Total: order.Total},
)
deps.EventBus.Publish(ctx, msg)
Actor types: typed.ActorTypeUser, ActorTypeAdmin, ActorTypeSystem, ActorTypeService;
typed.SystemActor is Actor{Type: ActorTypeSystem, ID: "system"}. MustNewMessage panics
instead of returning an error.
Well-known definitions:
| Variable | Package | Event name | Topic |
|---|---|---|---|
EmailSendEvent | github.com/redelay/go-module-email/events | email.send | email.send |
EventsProvider
Implement EventsProvider when a module publishes or consumes events. It serves two purposes:
- IR metadata —
Events()feedsmodspecand AsyncAPI spec generation - Runtime wiring —
Consumers()is called by the app to register handlers beforebus.Start()
func (m *OrderModule) Events() []*ir.Event {
return []*ir.Event{
{Name: "order.created", EntityType: "order", Action: "created"},
}
}
func (m *OrderModule) Consumers() []*modules.ConsumerRegistration {
return []*modules.ConsumerRegistration{
{
EventName: "payment.completed",
Topic: "payment.completed",
GroupID: "orders",
Handler: m.handlePaymentCompleted,
},
}
}
The ConsumerRegistration fields:
| Field | Type | Description |
|---|---|---|
EventName | string | Human name (used in modspec) |
Topic | string | Transport topic to subscribe to |
GroupID | string | Consumer group ID |
Handler | func(ctx, *EventMessage) error | Message handler |
BatchSize | int | Non-zero enables batch delivery |
Filter | func(*EventMessage) bool | Optional message filter |
Manifest
&modules.Manifest{
Name: "mymodule",
Description: "My custom module",
Version: "1.0.0",
DependsOn: []string{"auth"}, // started after auth
Provides: []string{"mymodule"}, // what this module provides
}
Auto-discovery
Modules register factories in init():
func init() {
modules.RegisterFactory("mymodule", func(deps modules.ModuleDeps) (modules.Module, error) {
return NewModule(deps.DB, deps.Logger), nil
})
}
Blank-import the package to trigger registration:
import _ "github.com/redelay/go-framework/modules/auth"
Registry — dual-key indexing by Name and ID
Registry.Register indexes each module by both manifest.Name and
manifest.ID when they differ. This lets admin sibling modules reach the core
module by its canonical ID regardless of how the original author capitalised
the Name field:
// core module
&modules.Manifest{ ID: "users", Name: "Users" }
// admin sibling — Configure hook
func (m *Module) Configure(r *modules.Registry) error {
peer, _ := r.Get("users") // lookup by manifest.ID — always works
m.core = peer.(*usersmod.Module)
return nil
}
manifest.ID defaults to manifest.Name when unset, so legacy modules that
only set Name keep working with no changes. When both are set and differ,
both keys resolve to the same Module pointer; the ID alias loses to an
existing entry with the same key (Name always wins on collision).
Public / admin split convention
Every module that exposes mutating HTTP endpoints splits into two packages:
| Package | Blank-imported by | Registers |
|---|---|---|
foo/ (core) | api + admin-api | Public self-service routes (signup, GET /me, PATCH /me, user-facing writes) + read-only listings safe for authed users |
foo/admin/ | admin-api only | Admin-gated writes (list-all, create-for-other-user, update-any, delete, toggle-state, administrative reads of other users' data) |
The factory name convention is <module>-admin. The admin package does not
re-register the core module's factory — it depends on the core module being
imported too (depends_on: <module> in module.yaml). Admin handlers reach
core state via accessor methods on the core Module struct (Logger, Service,
Store, etc.), or by asking the Registry for the peer module.
Admin routes sit under the AdminPrefix() (/admin by default, override via
ADMIN_ROUTE_PREFIX) and are wrapped in:
r.Group(modules.AdminPrefix()+"/<name>", func(g modules.Router) {
g.Use(auth.RequireActiveUser, auth.RequirePermission("admin:access"))
// ... admin writes ...
})
This convention is why the backend ships two binaries: cmd/api pulls in core
modules only (never the admin/ siblings), so its route table simply cannot
respond to admin-only mutations. cmd/admin-api pulls both and lives behind
the operator's VPN / Tailscale.
Module inventory — shared bundles per profile
Every backend binary blank-imports exactly one or two "bundle" packages that
enumerate which modules land in that profile. The bundles live under
backend/imports/:
| Bundle | Used by | Contains |
|---|---|---|
backend/imports/common | cmd/api + cmd/admin-api | Every module that should appear on BOTH public and admin HTTP surfaces — framework, FlowDSL nodes, addons, AI, application modules. |
backend/imports/aiwire | cmd/api + cmd/admin-api | Non-import bundle — exposes aiwire.Wire() which both HTTP main.gos call after server.Default() and before app.Run(). Builds the LLM provider from LLM_PROVIDER, registers go-ai FlowDSL node handlers with the flowexec engine, and binds the LLM usage ledger to flowexec's UsageSink. Idempotent — safe to call more than once. |
backend/imports/cli | cmd/redelayctl | Narrow set that contributes CLIProvider commands (auth, users, groups, health, assistant, …). Keeps CLI startup fast. A legacy cmd/cli entry point remains as a deprecation shim with identical behaviour. |
backend/cmd/admin-api/admin_imports.go | cmd/admin-api only | Admin submodules (*/admin/). Sibling file to admin-api's main.go, stays out of common so the public api cannot reach any admin path. |
Adding a new module is a one-line change:
- Shared by api + admin-api → add blank-import to
backend/imports/common/common.go. - Admin-only HTTP surface (has
foo/admin/withid: foo-admin) → add tobackend/cmd/admin-api/admin_imports.go. - CLI-only command provider → add to
backend/imports/cli/cli.go.
No other file needs editing. Each main.go is just five lines of wiring plus
the bundle imports.
Leak enforcement — three layers, same intent. No route or middleware from
an admin package can reach cmd/api's surface. Each layer catches a class of
mistake the others miss, so they complement rather than duplicate.
| Layer | Test | What it catches |
|---|---|---|
| Path | backend/cmd/api/admin_leak_test.go → TestPublicAPIHasNoAdminDeps | Runs go list -deps over cmd/api and fails if any import path ends in /admin or contains /admin/. Catches accidental blank imports in common. |
| Middleware | backend/cmd/api/admin_middleware_leak_test.go → TestPublicAPIHasNoAdminMiddleware | Boots the public api's module registry and fails if any public route's middleware chain contains RequirePermission("admin:access"). Catches the subtler mistake of a non-/admin/ package registering an admin-gated mutation. |
| Route | backend/cmd/api/openapi_no_flow_admin_test.go → TestOpenAPIHasNoFlowAdminPaths | Renders cmd/api's OpenAPI spec and fails if any path matches /flows, /runs, or /deployments. Domain-specific backstop for flowexec — if either of the other two passes but flowexec routes still leak, this one fires. |
Adding an admin blank-import to cmd/api, to backend/imports/common, or to
any transitive dependency of either, trips one of these tests with a
human-readable message listing the leaking paths / routes.
Why a shared package instead of duplicated lists per binary? Two or three
binaries keeping parallel import lists in sync is a known failure mode —
someone adds a module to cmd/api/main.go and forgets cmd/admin-api/main.go,
then prod admin-api silently misses a feature. The bundle pattern makes the
mistake structurally impossible: adding a module means editing the one file
that both binaries already import.
When the shared inventory grows past ~60 imports or the admin inventory past
~20, a go generate tool that scans */module.yaml and regenerates these
bundles becomes worthwhile. At current scale the manual lists are simpler to
audit and diff.
See the audit in any core module (e.g. users, assistant, flowexec) for the concrete public/admin split.
Testing & CI guardrails {#testing-ci-guardrails}
The monorepo has many Go modules (go-framework, go-flowdsl, go-events,
go-modules, go-module-email, go-ai, backend, …). infra/Makefile
provides single-command entry points that iterate the full set so CI and
pre-push checks never miss a module:
| Target | Runs | Purpose |
|---|---|---|
make vet-all | go vet ./... in every module | Static analysis across the whole monorepo — fails on first non-clean module. |
make test-all | go test ./... in every module | Test suite — fails on first non-passing module. |
make test-admin-leak | TestPublicAPIHasNoAdminDeps only | Fast, path-based regression (seconds). |
make check-admin-leak | All three leak tests + live go list -deps scan | Exhaustive admin-isolation audit (path + middleware + route layers described above). |
make check-openapi-paths | Boots admin-api against an ephemeral DB, fetches /openapi.json, greps for // | Catches route-prefix joining regressions like the /api/v1//flows bug. |
make check | vet-all + test-all + check-admin-leak + check-openapi-paths | Full CI-grade gate. Run before pushing. |
make gen-docs | Regenerates spec/docs/5.nodes/ from live module.yaml files | Rebuilds the FlowDSL node catalog. Commit the diff. |
Recent hardening worth knowing about:
openapi.joinRoutePrefix(go-framework/core/openapi/spec.go) joins two route-path segments without ever producing//. Locked in byTestJoinRoutePrefixandTestCollectRoutes_NoDoubleSlashInRealPattern— these catch the regression class themake check-openapi-pathstarget guards against at the spec level.- Router adapter
Group("/", fn)(go-framework/server/adapter.go) now useschi.Router.Group(middleware-only inline subrouter, no mount) when the prefix is empty. Multiple modules can apply admin gating viar.Group("/", g.Use(auth.RequireActiveUser, auth.RequirePermission("admin:access")))on the same parent router without colliding on chi's mount-point check. Non-empty prefixes still usechi.Router.Route.
Built-in modules
auth
Factory name: "auth" · DependsOn: ["users"] · Provides: ["auth", "jwt", "tokens"]
Implements: RoutesProvider (login, refresh, revoke under /auth), MiddlewareProvider
Routes (auto-registered):
| Method | Path | Description |
|---|---|---|
POST | /auth/login | Login with email + password (JSON or OAuth2 form) |
POST | /auth/refresh | Refresh token pair |
POST | /auth/revoke | Revoke a refresh token |
The login endpoint supports both application/json ({\"email\", \"password\"}) and
application/x-www-form-urlencoded (username=&password=) for OAuth2 password flow
compatibility with Swagger UI.
Configurable mount prefix. The auth routes mount at AUTH_ROUTE_PREFIX
(default /auth, relative to ROUTE_PREFIX). A project porting a legacy
surface points it elsewhere — e.g. AUTH_ROUTE_PREFIX=/login — and every auth
route moves with it, no per-handler change.
Extended login surface (passwordless + TOTP). Beyond password login, the
module registers a fuller authentication surface. All paths are relative to
AUTH_ROUTE_PREFIX.
| Method | Path | Description |
|---|---|---|
POST | /oauth | OAuth2 password-flow login (form-encoded) |
POST | /magic/{email} | Request a magic-link login token (emailed) |
POST | /claim · /claim/{email} | Redeem a claim token → JWT pair |
POST | /validate | Validate a claim token without redeeming it |
POST | /verify/{email} | Verify an email-confirmation token |
POST | /recover/{email} | Start a password recovery (emails a reset token) |
POST | /reset | Complete a password reset with a claim token |
POST | /totp | Complete a login that requires a second TOTP factor |
PUT | /totp | Enrol TOTP for the current user (returns the secret/QR) |
DELETE | /totp | Disable TOTP for the current user |
TOTP (totp.go) is RFC 6238, stdlib-only: it accepts a ±1-period clock
skew and compares codes in constant time. A login for a TOTP-enrolled user
returns a short-lived claim token instead of a full session; the client
completes it at POST /totp, and the issued Claims.TOTPValid records that the
second factor was satisfied.
Claim tokens (claim.go) are purpose-scoped, short-lived JWTs — a token
minted for totp cannot be redeemed as a magic, verify, or reset token.
Service.ParseClaim(tokenStr, purpose) enforces the purpose, so a leaked
magic-link token cannot be repurposed to reset a password.
API schemas:
| Schema | Purpose |
|---|---|
AuthLoginInput | Login request body |
AuthRefreshInput | Refresh token request body |
AuthRevokeInput | Revoke token request body |
AuthTokenResponse | JWT access + refresh token pair |
AuthRevokeResponse | Revoke confirmation |
Service methods:
| Method | Signature |
|---|---|
| Login | Login(ctx, email, password) (access, refresh string, err error) |
| Refresh | Refresh(ctx, refreshToken) (access, refresh string, err error) |
| Revoke | Revoke(ctx, refreshToken) error |
| Validate | ValidateAccessTokenStr(tokenStr) (*Claims, error) |
| JWT config | GetJWTConfig() *JWTConfig |
Claims struct:
type Claims struct {
UserID string `json:"sub"`
Email string `json:"email"`
IsSuperuser bool `json:"is_superuser"`
TokenType TokenType
TOTPValid bool
Permissions []string `json:"permissions,omitempty"`
Extra map[string]any
jwt.RegisteredClaims
}
Password functions:
| Function | Description |
|---|---|
HashPassword(password) | Argon2id hash (preferred) |
VerifyPassword(password, hash) | Auto-detects Argon2id or bcrypt |
NeedsRehash(hash) | True if hash uses legacy bcrypt |
Middleware:
| Function | Description |
|---|---|
AuthMiddleware(cfg) | Require valid JWT |
OptionalAuth(cfg) | Parse JWT if present |
RequireActiveUser | Ensure claims in context |
RequireSuperuser | Require superuser |
RequirePermission(perm) | Check specific permission |
RequireAnyPermission(perms...) | Check any permission |
APISecretMiddleware(secret) | Validate X-API-Secret |
Context helpers:
ctx = auth.ContextWithClaims(ctx, claims)
claims, ok := auth.ClaimsFromContext(ctx)
Token source precedence — AuthMiddleware (and OptionalAuth) looks for
the access token in two places, in order:
Authorization: Bearer <jwt>header (canonical).?access_token=<jwt>query-string fallback (RFC 6750 §2.3 / OAuth2 SSE idiom). Needed because the browserEventSourceAPI cannot set custom headers — SSE endpoints like/runs/{id}/eventsor/flows/{id}/livewould otherwise be unreachable from a browser without proxying the token through a cookie.
If both are present, the header wins. Deployments that terminate TLS at the
edge should ensure access logs redact the access_token query parameter.
Regression tests: TestAuthMiddleware_QueryParamFallback,
TestAuthMiddleware_HeaderWinsOverQueryParam.
Cross-module interfaces:
// Implement in your users module:
type LookupProvider interface {
AsAuthLookup() UserLookup
}
// Implement in your groups module (optional):
type PermissionsProvider interface {
AsPermissionsResolver() PermissionsResolver
}
users
Factory name: "users" · Provides: ["users"]
Implements: RoutesProvider (self-service under /users), auth.LookupProvider
Public routes (registered by core users module — reach any binary that
blank-imports it):
| Method | Path | Description |
|---|---|---|
POST | /users/signup | Public signup — creates a non-superuser account. is_superuser / is_active / email_validated cannot be set from this surface. |
GET | /users/me | Current user profile |
PATCH | /users/me | Update the authenticated user. Privileged fields silently stripped. |
Admin routes (registered by the users/admin sibling module — blank-import
only in admin-api):
| Method | Path | Description |
|---|---|---|
GET | /{ADMIN_PREFIX}/users/all | List users |
POST | /{ADMIN_PREFIX}/users/ | Create user with full privilege set |
POST | /{ADMIN_PREFIX}/users/toggle-state | Enable / disable user |
GET | /{ADMIN_PREFIX}/users/{id} | Get user by ID |
PUT | /{ADMIN_PREFIX}/users/{id} | Update user (any fields) |
DELETE | /{ADMIN_PREFIX}/users/{id} | Delete user |
POST | /{ADMIN_PREFIX}/users/{id}/groups/{groupID} | Grant group membership |
DELETE | /{ADMIN_PREFIX}/users/{id}/groups/{groupID} | Revoke group membership |
Every admin route is gated on admin:access + auth:active-user. The public
api binary has no write surface for other users; the admin api binary exposes
both the public self-service routes AND the admin administration routes.
Service methods:
| Method | Signature |
|---|---|
| Create | Create(ctx, *UserCreateInput) (*User, error) |
| GetByID | GetByID(ctx, id) (*User, error) |
| GetByEmail | GetByEmail(ctx, email) (*User, error) |
| List | List(ctx, filter, page, pageSize) (*PaginatedResult[*User], error) |
| Update | Update(ctx, id, *UserUpdateInput) (*User, error) |
| Delete | Delete(ctx, id) error |
| Authenticate | Authenticate(ctx, email, password) (*User, error) |
API schemas:
| Schema | Purpose |
|---|---|
UserCreateInput | Create user request body |
UserUpdateInput | Partial update request body (pointer fields) |
UserResponse | API response (no sensitive fields) |
UserListResponse | Paginated list response |
Implements auth.LookupProvider automatically.
groups
Factory name: "groups" · Provides: ["groups", "permissions"]
Service methods:
| Method | Signature |
|---|---|
| Create | Create(ctx, name, description, permissions) (*UserGroup, error) |
| GetByID | GetByID(ctx, id) (*UserGroup, error) |
| GetByIDs | GetByIDs(ctx, ids) ([]*UserGroup, error) |
| GetPermissionsForGroups | GetPermissionsForGroups(ctx, ids) ([]string, error) |
| Update | Update(ctx, id, update) (*UserGroup, error) |
| Delete | Delete(ctx, id) error |
Implements auth.PermissionsProvider automatically.
health
Factory name: "health" · Implements RoutesProvider, MountProvider,
DynamicRoutesProvider.
Serves the same health check at two places:
| Path | Via | For |
|---|---|---|
ROUTE_PREFIX/health (e.g. /api/v1/health) | Routes | The versioned API surface (Redelay convention) |
/health (root, outside the prefix) | Mounts | Load-balancer and Kubernetes probes, which hit /health without knowing the app's API version prefix |
The root path is HEALTH_ROOT_PATH (default /health); set it empty to disable
the root mount. It is advertised in /openapi.json at its absolute path via
DynamicRoutes + AbsolutePath. The response aggregates every check registered
in the shared HealthRegistry (Mongo, Redis, and any a module adds), returning
200 when all pass and 503 when any fails.
FlowDSL node subpackages
Each built-in module (auth, users, groups) has a flowdsl/ subdirectory providing FlowDSL
nodes as a separate companion module. Enable with a blank import:
import (
_ "github.com/redelay/go-framework/modules/auth"
_ "github.com/redelay/go-framework/modules/auth/flowdsl" // 7 FlowDSL nodes
)
| Subpackage | Factory | Nodes | Kinds |
|---|---|---|---|
modules/auth/flowdsl | auth-flowdsl | 7 | 1 action, 1 transform, 1 router, 4 source |
modules/users/flowdsl | users-flowdsl | 8 | 3 action, 2 transform, 3 source |
modules/groups/flowdsl | groups-flowdsl | 7 | 3 action, 1 transform, 3 source |
For the full node catalog and subpackage structure, see the FlowDSL node subpackage reference.
Generic CRUD
MongoDB CRUD
import "github.com/redelay/go-framework/crud"
type MyModel struct {
crud.BaseModel `bson:",inline"`
Name string `bson:"name" json:"name"`
}
func (m *MyModel) GetID() primitive.ObjectID { return m.ID }
c := crud.NewMongoCRUD[*MyModel](collection)
item, _ := c.Create(ctx, &MyModel{Name: "test"})
item, _ = c.FindByID(ctx, id)
item, _ = c.FindByField(ctx, "name", "test")
result, _ := c.List(ctx, crud.ListParams{
Filter: bson.M{"name": "test"},
Skip: 0, Limit: 20,
Sort: bson.D{{Key: "created_at", Value: -1}},
})
// result.Items, result.Total, result.Skip, result.Limit
Projections — ListParams.Projection
Projection takes the driver's usual bson.M{"field": 1} / {"field": 0}
form and is nil by default, which reads whole documents.
Reach for it whenever the documents are much larger than the response. The
motivating case is localized content: a catalogue whose name, description
and step lists hold every locale is roughly 39 KB per document, so a 50-item
page pulls ~1.9 MB off the server to render a grid of names and thumbnails.
Prefer an exclusion projection ({"field": 0}):
- An inclusion list has to enumerate every other field, so a field added to the model later comes back empty in list views with nothing to say why.
- Exclusion cannot damage a legacy document shape. If
instructionsis still a plain array on old rows rather than a per-locale map, naming it in an exclusion projection still just removes it, whereas includinginstructions.ensilently yields[].
Move the DTO with the projection. A projected-away field decodes as its zero
value, which is indistinguishable from "this record genuinely has none" — so a
list DTO should nil those fields and mark them omitempty, leaving the key
absent. undefined then means "not in this view" and [] means empty. Test the
pair together; each half is silent on its own.
Read preference — per query, not per deployment
The client default is primary and should stay there. A global secondary
preference is the tempting setting and the wrong one: replication is
asynchronous, so it breaks read-your-writes on every endpoint at once — a user
writes, the next poll hits a lagging member, and the write appears to vanish.
Whether a read tolerates staleness is a property of the read, so it is opted into at the call site:
import rtmongo "github.com/redelay/go-framework/runtime/mongo"
// Curated content, an analytics rollup — anything the caller did not just write.
ctx = rtmongo.Nearest(ctx) // closest member, including the primary
ctx = rtmongo.Secondary(ctx) // keep heavy reporting off the primary
items, err := store.List(ctx, params)
crud.MongoCRUD honours it on reads only — a read preference on a write is
meaningless, and routing writes through the same seam would imply otherwise. A
context with no preference uses the client default, so existing code is
unaffected.
Secondary maps to SecondaryPreferred deliberately: on a single-node
deployment (every dev machine) a hard Secondary has nowhere to go and fails
the query, where preferring one degrades to the primary.
Lifecycle events — WithEvents
Any CRUD can publish <entity>.created / .updated / .deleted after
successful writes, so other modules react to data changes without importing the
owner:
c := crud.NewMongoCRUD[*WorkoutProgram](col).WithEvents(deps.EventBus, "program")
That is the whole opt-in. Every Create, Update and Delete then publishes an
EventMessage with EntityType: "program", the entity id, and
Headers["topic"] = "program.updated" — the same routing convention the
built-in domain modules use, so flows and EventsProvider consumers subscribe
by topic name.
- No transport configured?
WithEvents(nil, …)is a no-op — wiredeps.EventBusunconditionally. - Publish failures never fail the write. The write already succeeded; the event is best-effort.
- The payload is
{entity_type, entity_id}. Consumers that need the full document re-read it by id — by the time they run it may have changed again, so the id is the only durable handle.
Typical consumer: a search module re-embedding exactly the document that changed (see Search — keeping an index fresh).
Per-user singleton — UserSingleton[T]
"Preferences", "profile", "settings for me" and their relatives are the same
resource shape everywhere: at most one document per user, every field defaulted,
and a reset that returns to those defaults. Written as ordinary CRUD each time,
they pick up the same three bugs — a 404 for a user who has simply never saved,
a partial update that clobbers unset fields, and a reset that leaves a stale row
behind. crud.UserSingleton[T] is that shape, done once.
type Prefs struct {
crud.BaseModel `bson:",inline"`
UserID string `bson:"user_id"`
RestSeconds int `bson:"rest_seconds"`
WeightUnit string `bson:"weight_unit"`
}
func (p *Prefs) SetUserID(id string) { p.UserID = id } // satisfies crud.UserOwned
func defaults(userID string) *Prefs {
return &Prefs{UserID: userID, RestSeconds: 90, WeightUnit: "kg"}
}
store := crud.NewUserSingleton[*Prefs](db.Collection("prefs"), defaults)
_ = store.EnsureIndexes(ctx, "prefs_user_unique") // one row per user
The defining behaviour is that Get never 404s: a user who has never opened
the settings screen gets defaults, not an error, and the caller need not know the
difference.
p, stored, _ := store.Get(ctx, userID) // stored=false → these are defaults
p, _ = store.Upsert(ctx, userID, bson.M{"rest_seconds": 45}) // partial $set
p, _ = store.Reset(ctx, userID) // deletes the row, returns defaults
ok, _ := store.Delete(ctx, userID) // reports whether one existed
Upsert seeds the document from defaults before applying the patch, so a
one-field save does not write zero values across every other field. Reset
deletes rather than overwriting with defaults — a row full of default values is
indistinguishable from a user who deliberately chose them, so a reset should
leave no trace.
ClickHouse CRUD
Generic CRUD for ClickHouse analytics tables. Uses ch struct tags for column mapping
(falls back to lowercase field name). Requires the ClickHouseModel interface:
import (
"github.com/redelay/go-framework/crud"
"github.com/redelay/go-framework/runtime/clickhouse"
)
type PageView struct {
Timestamp time.Time `ch:"timestamp"`
Domain string `ch:"domain"`
Path string `ch:"path"`
UserAgent string `ch:"user_agent"`
Count uint64 `ch:"count"`
}
func (p *PageView) TableName() string { return "page_views" }
conn, _ := clickhouse.NewConn(cfg, "analytics", nil)
c := crud.NewClickHouseCRUD(conn, func() *PageView { return &PageView{} })
// Single insert
c.Create(ctx, &PageView{Domain: "example.com", Path: "/", Count: 1})
// Bulk insert (preferred for ClickHouse)
c.BulkCreate(ctx, views) // uses PrepareBatch + AppendStruct
// Read
view, _ := c.Get(ctx, "domain", "example.com")
views, _ := c.List(ctx, crud.CHListParams{
Filters: map[string]any{"domain": "example.com"},
OrderBy: "timestamp DESC",
Limit: 100,
})
count, _ := c.Count(ctx, map[string]any{"domain": "example.com"})
// Raw query
rows, _ := c.Query(ctx, "SELECT * FROM page_views WHERE domain = ?", "example.com")
Available methods: Create, BulkCreate, Get, GetByField, List, Count, Query.
ClickHouse batch writer
For high-throughput scenarios (e.g. Kafka consumers writing analytics), the batch writer
queues items in memory and flushes in bulk — either when batchSize rows accumulate
or flushInterval elapses, whichever comes first. Thread-safe.
// Backed by a ClickHouseCRUD
writer := crud.BatchWriterFor(c,
crud.WithBatchSize(500),
crud.WithFlushInterval(5 * time.Second),
crud.WithBatchName("pageviews"),
)
// From any goroutine — non-blocking
writer.Enqueue(&PageView{Domain: "example.com", Path: "/"})
// On shutdown — drains remaining items
writer.Stop()
You can also supply a custom flush function for non-CRUD use cases:
writer := crud.NewClickHouseBatchWriter(func(ctx context.Context, items []*PageView) (int, error) {
// custom bulk insert logic
return len(items), nil
}, crud.WithBatchSize(1000))
OpenAPI 3.0 spec generation
The openapi package generates a full OpenAPI 3.0.3 specification from module routes,
including typed parameters, request/response schemas via $ref, validation constraints,
and configurable security schemes.
Automatic spec + UI (via openapi module)
The openapi built-in module serves the spec and an interactive API reference UI.
No code required — blank-import it:
import _ "github.com/redelay/go-framework/modules/openapi"
This adds:
| Path | Description |
|---|---|
/openapi.json | Full OpenAPI 3.0.3 spec (JSON) |
/reference | Interactive API docs UI |
UI provider selection
OPENAPI_UI | UI |
|---|---|
scalar (default) | Scalar |
swagger | Swagger UI with OAuth2 Authorize popup |
redoc | ReDoc |
disabled | No UI |
OpenAPI environment variables
| Variable | Default | Description |
|---|---|---|
OPENAPI_UI | scalar | UI provider |
OPENAPI_SPEC_PATH | /openapi.json | Spec endpoint path |
OPENAPI_UI_PATH | /reference | UI endpoint path |
Endpoint metadata (functional options)
Routes declare OpenAPI metadata via functional options on Router.Handle():
func (m *Module) Routes(router modules.Router) {
router.Group("/users", func(r modules.Router) {
r.Handle("POST", "/", http.HandlerFunc(m.handleCreate),
modules.Summary("Create user"),
modules.Description("Create a new user. Requires superuser access."),
modules.Body(UserCreateInput{}),
modules.Response(201, "User created", UserResponse{}),
modules.Response(400, "Invalid request body", modules.RedelayErrorResponse{}),
modules.Response(409, "User already exists", modules.RedelayErrorResponse{}),
modules.Security("BearerAuth"),
)
})
}
Available options:
| Option | Description |
|---|---|
Summary(s) | Operation summary |
Description(s) | Operation description |
Tags(t...) | Additional tags |
PathParam(name, desc, type?, format?) | Path parameter (auto-required) |
QueryParam(name, type, desc, default?) | Query parameter |
Body(model, desc?) | Request body from Go struct |
Response(status, desc, model?) | Response with optional schema |
Security(scheme, scopes...) | Security requirement |
EndpointDeprecated() | Mark as deprecated |
Schema generation
Go structs are reflected into JSON Schema automatically:
- Struct field names from
json:"..."tags validate:"required"→ JSON Schemarequiredarrayvalidate:"email"→format: "email"validate:"min=8"→minLength: 8(strings) orminimum: 8(numbers)*Tpointer fields →nullable: truetime.Time→format: "date-time"primitive.ObjectID→format: "objectid"[]T→type: "array"with typed items- Embedded structs merge properties
Named structs are registered in components/schemas and referenced via $ref.
Security schemes
openapi.GenerateSpec(title, version, registry,
openapi.WithBearerAuth(), // HTTP Bearer/JWT
openapi.WithOAuth2PasswordFlow("/api/v1/auth/login", nil), // OAuth2 password flow
)
The OAuth2 password flow enables Swagger UI's "Authorize" popup with username/password fields.
Schema naming convention
All API schemas use module-prefixed names to avoid collisions across modules:
| Schema | Module | Purpose |
|---|---|---|
RedelayErrorResponse | core | Standard {"detail": "..."} error body |
HealthCheckResponse | health | Health check response |
AuthLoginInput | auth | Login request body |
AuthRefreshInput | auth | Refresh token request body |
AuthRevokeInput | auth | Revoke token request body |
AuthTokenResponse | auth | JWT token pair response |
AuthRevokeResponse | auth | Revoke confirmation response |
UserCreateInput | users | Create user request body |
UserUpdateInput | users | Partial update request body |
UserResponse | users | User API response (no sensitive fields) |
UserListResponse | users | Paginated user list response |
MCP server
Automatically mounted at /mcp by the server package. Implements the MCP Streamable HTTP transport (protocol version 2025-03-26) — compatible with VS Code, Claude Desktop, Cursor, and other MCP clients.
For manual use:
import "github.com/redelay/go-framework/server/mcp"
server := mcp.NewServer()
server.DiscoverFrom(registry) // auto-discovers MCPProvider modules + routes
router.Mount("/mcp", server.Handler())
Protocol support
The MCP server handles the full MCP lifecycle:
| Method | Description |
|---|---|
initialize | Returns protocol version, server capabilities (tools, resources), and server info |
notifications/initialized | Accepted silently (202) |
ping | Returns {} |
tools/list | Lists all discovered tools |
tools/call | Invokes a tool; returns MCP content blocks |
resources/list | Lists all discovered resources |
resources/read | Reads a resource; returns MCP contents array |
IDE setup
Add to your .vscode/mcp.json:
{
"servers": {
"my-app": {
"type": "http",
"url": "http://localhost:8000/mcp"
}
}
}
Once connected, AI assistants can discover and invoke all registered tools and resources.
Built-in introspection tools
DiscoverFrom installs a family of introspection tools for AI assistants
(Claude, Cursor, Copilot, etc.) to reason about the running application.
All tools read live from the module registry — no caching.
| Tool | Purpose |
|---|---|
list_modules | Summary of every registered module with counts (events, routes, FlowDSL nodes, entities, packets). |
describe_module | Full IR for a single module by name: events, packets, entities, routes, FlowDSL nodes, settings schema, external docs. |
list_flowdsl_nodes | All FlowDSL nodes from parent and -flowdsl companion modules; optional filter by kind or module. |
describe_flowdsl_node | Full node spec: settings schema (with x-ui hints), named ports with $ref schemas, handler reference, tags. |
list_events | Every event with entity_type, action, topic, module, payload type. |
describe_event | Full event definition including inline payload schema fields or payload_ref. |
list_packets | Standalone packet / message-body definitions. |
describe_packet | Packet fields with types and required flags. |
search_redelay | Case-insensitive substring search across modules, nodes, events, and packets. |
Developer tools
Additional tools for building and validating modules and flows:
| Tool | Purpose |
|---|---|
validate_flowdsl | Parse and validate FlowDSL YAML; returns diagnostics with error codes (FDL001–FDL062). |
validate_module | Validate module YAML against naming conventions and best practices (MV001–MV072). |
scaffold_module | Generate Go module code (module.go, routes.go, handlers.go) from a module YAML definition. Returns file contents — does not write to disk. |
list_api_routes | Detailed HTTP route listing with request body types, security schemes, and operation IDs. Optional module filter. |
explain_flowdsl_node | Human-friendly node explanation: settings with types/defaults/enums, port wiring, and a usage YAML snippet. |
Module-contributed tools
Any module can contribute additional MCP tools by implementing the MCPProvider interface. These are discovered automatically by DiscoverFrom.
For example, the flowexec addon module contributes:
| Tool | Purpose |
|---|---|
list_flows | List all stored FlowDSL flows with metadata and published version. |
get_flow | Get a flow with its published version's FlowDSL document. |
list_node_handlers | List all registered runtime node handlers — validates that flow nodes have matching handlers. |
See MCPProvider for how to add tools from your own modules.
Built-in resources
| URI | Description |
|---|---|
redelay://modules.json | Live module manifest — same shape as the /modules.json HTTP endpoint consumed by the /modules UI. |
openapi://spec | OpenAPI 3.0 spec for all registered HTTP routes. |
MCPProvider
Modules expose tools and resources to the MCP server by implementing MCPProvider:
type MCPProvider interface {
MCPTools() []MCPTool
MCPResources() []MCPResource
}
Example — exposing a search tool from a custom module:
func (m *Module) MCPTools() []modules.MCPTool {
return []modules.MCPTool{
{
Name: "search_products",
Description: "Search the product catalog by name or category",
InputSchema: map[string]any{
"type": "object",
"required": []string{"query"},
"properties": map[string]any{
"query": map[string]any{"type": "string", "description": "Search term"},
"category": map[string]any{"type": "string", "description": "Filter by category"},
},
},
Handler: func(ctx context.Context, args map[string]any) (any, error) {
query, _ := args["query"].(string)
return m.service.Search(ctx, query)
},
},
}
}
func (m *Module) MCPResources() []modules.MCPResource { return nil }
Once the module is registered, its tools appear automatically in tools/list responses.
Config
Helper functions
| Function | Description |
|---|---|
GetEnv(key, default) | String env var with fallback |
GetBool(key, default) | Boolean env var |
GetInt(key, default) | Integer env var |
GetDuration(key, default) | time.Duration env var |
GetFloat64(key, default) | Float env var |
GetStringSlice(key, default) | Comma-separated slice |
Pre-built config loaders
| Function | Description |
|---|---|
LoadAppConfig() | APP_NAME, APP_HOST, APP_PORT, etc. |
LoadMongoConfig() | MONGO_URI, MONGO_DATABASE |
LoadRedisConfig() | REDIS_URL, REDIS_PREFIX |
LoadClickHouseConfig() | ClickHouse connection settings |
LoadKafkaConfig(defaultGroupID) | Kafka broker + SASL/TLS settings |
LoadNATSConfig() | NATS URL, JetStream, credentials, TLS |
LoadRedisStreamsConfig() | Redis Streams transport tuning |
KafkaConfig
| Env var | Description | Default |
|---|---|---|
KAFKA_BOOTSTRAP_SERVERS | Comma-separated broker list | localhost:9092 |
KAFKA_GROUP_ID | Consumer group ID | (arg) |
KAFKA_SECURITY_PROTOCOL | plaintext | sasl_plaintext | sasl_ssl | ssl | plaintext |
KAFKA_SASL_MECHANISM | plain | scram-sha-256 | scram-sha-512 | "" |
KAFKA_SASL_USERNAME | SASL username | "" |
KAFKA_SASL_PASSWORD | SASL password | "" |
cfg.BrokerAddrs() returns []string from the comma-separated Brokers field — ready for sarama.
NATSConfig
| Env var | Description | Default |
|---|---|---|
NATS_URL | Server URL or cluster list (nats://a:4222,nats://b:4222) | nats://localhost:4222 |
NATS_JETSTREAM | true for durable JetStream delivery | false |
NATS_CREDENTIALS_FILE | Path to .creds file | "" |
NATS_NKEY_FILE | Path to NKey seed file | "" |
NATS_TLS | Require TLS | false |
NATS_ACK_WAIT_SECONDS | JetStream ack-wait seconds | 30 |
NATS_MAX_DELIVER | JetStream max redeliveries (-1 = unlimited) | 3 |
cfg.URLs() splits the cluster list into []string.
RedisStreamsConfig
| Env var | Description | Default |
|---|---|---|
REDIS_STREAMS_URL | Redis URL; falls back to REDIS_URL | redis://localhost:6379 |
REDIS_STREAMS_BLOCK_MS | XREADGROUP block in ms | 200 |
REDIS_STREAMS_BATCH_SIZE | Messages per fetch | 10 |
REDIS_STREAMS_MAX_LEN | Approximate max stream length | 10000 |
cfg.Addr() extracts host:port from the URL for go-redis.
Runtime
Redis
client, err := redis.NewClient(cfg)
key := redis.Key(cfg, "inflight", domain) // → "prefix:inflight:example.com"
ClickHouse
conn, err := clickhouse.NewConn(cfg, "service-name", nil)
Cross-container coordination (runtime/coordination)
Transport-agnostic coordination primitives for multi-container deployments — pub/sub
invalidation, append-only streams for fan-out, and watchable KV storage. Blank-import
go-modules/coordination/module and set COORD=nats|redis|noop to auto-wire into
ModuleDeps.Coordinator. Zero external deps in the interface package; backends live
in go-modules/coordination/{nats,redis}.
if c := m.deps.Coordinator; c != nil {
_ = c.Publish(ctx, "flow.published", []byte(flowID)) // invalidation
_, _ = c.AppendStream(ctx, "live:"+flowID, frameJSON) // fan-out
_ = c.KVSet(ctx, "routes", flowID, routeJSON) // shared config
w, _ := c.KVWatch(ctx, "routes", "*") // watch for updates
}
See the full Coordination reference for the interface, backends, and conformance test suite.
Distributed primitives
Redis-backed coordination primitives in runtime/distributed. Both degrade gracefully
when Redis is nil — the lock always reports leader, the semaphore always grants.
Distributed lock (leader election)
Binary mutex backed by SET NX EX with Lua CAS release. Runs a background heartbeat
to renew the lock automatically.
import "github.com/redelay/go-framework/runtime/distributed"
lock := distributed.NewDistributedLock(redisClient, "worker-leader",
distributed.WithLockTTL(120 * time.Second),
distributed.WithHeartbeatInterval(30 * time.Second),
distributed.WithLockPrefix("myapp:dlock:"),
)
lock.Start(ctx) // tries to acquire; runs heartbeat in background
defer lock.Stop(ctx) // releases lock
if lock.IsLeader() {
// only one instance runs this
}
info, _ := lock.GetCurrentLeader(ctx) // {"token": "...", "is_self": true/false}
Distributed semaphore
Counting semaphore backed by a Redis sorted set with expiry scores. Supports configurable max workers and heartbeat renewal.
sem := distributed.NewDistributedSemaphore(redisClient, "import-workers", 3,
distributed.WithSemTTL(300 * time.Second),
distributed.WithSemHeartbeat(60 * time.Second),
distributed.WithAcquireTimeout(30 * time.Second),
distributed.WithSemPrefix("myapp:dsem:"),
)
// Manual acquire/release
token, _ := sem.Acquire(ctx)
defer sem.Release(ctx, token)
// Or use Hold — acquires, runs fn with background heartbeat, releases
sem.Hold(ctx, func(ctx context.Context) error {
// do work within semaphore slot
return nil
})
count, _ := sem.CurrentCount(ctx) // active holders
Core IR (Intermediate Representation)
go-flowdsl — import github.com/redelay/go-flowdsl/ir. See the go-flowdsl Reference for the full API.The ir package defines the canonical data model that all formats import into
and export from. Every FlowDSL, OpenAPI, AsyncAPI, or business model document is first
converted to this IR before any processing.
Document
import "github.com/redelay/go-flowdsl/ir"
doc := ir.NewDocument("My App", "1.0.0")
// doc.ID — auto-generated UUID
// doc.Modules — business/domain modules
// doc.Entities — domain aggregates with typed fields
// doc.Events — business events (entity_type + action)
// doc.Commands — state-changing intents
// doc.Queries — read operations
// doc.Actions — top-level operations (command/query/mutation/subscription)
// doc.Workflows — multi-step processes with nodes + edges
// doc.Channels — message/event channels with pub/sub
// doc.Schemas — reusable data structure definitions
// doc.Packets — data transfer payloads
// doc.Schedules — time-based triggers (cron/interval)
// doc.Migrations — database migration definitions
// doc.Metadata — arbitrary key-value metadata
// doc.Provenance — origin and mapping history
Key types
| Type | Description |
|---|---|
Document | Root container for an entire IR |
Module | Business/domain unit with entities, events, actions, workflows, config, settings, CRUD, consumers |
Entity | Business entity or domain aggregate with typed fields |
Field | Single field — type, constraints, nested properties, $ref |
FieldType | Enum: string, integer, float, boolean, array, object, date, datetime, uuid, binary, ref, enum, any |
Event | Business event — entity_type + action, optional topic and payload |
Command | State-changing intent with input/output schemas and emitted events |
Query | Read operation with input/output schemas |
Action | Top-level operation — kind (command/query/mutation/subscription), method, path, I/O, emits |
Workflow | Multi-step process with Node graph and Edge connections |
Node | Workflow step — kind (action/event/gateway/start/end/wait/condition/parallel) |
Edge | Directed connection between nodes with optional condition |
Channel | Message channel with publish/subscribe events, protocol, bindings |
Schema | Reusable data structure definition |
Packet | Data transfer structure (event payload, message body) |
Schedule | Time-based trigger — cron expression or interval |
Migration | Database migration — backend, version, up/down SQL |
Consumer | Event consumer — topic, group ID, handler reference, batch size |
Provenance | Origin tracking — source, URI, source ref, mappings, confidence |
ConfigDefinition | Module runtime configuration (entries with env vars, types, defaults) |
SettingsDefinition | Admin-UI-editable settings (groups of fields with components) |
CRUDDefinition | CRUD capabilities for an entity (create/read/update/delete/list) |
FlowDSL
go-flowdsl — import github.com/redelay/go-flowdsl/flowdsl. See the go-flowdsl Reference for the full API.The flowdsl package parses, validates, normalizes, and exports FlowDSL —
the primary DSL for defining Redelay applications.
Parse & Validate
import "github.com/redelay/go-flowdsl/flowdsl"
parsed, err := flowdsl.Parse(reader) // YAML → *FlowDSL
diag := flowdsl.Validate(parsed) // structural checks → *diagnostics.Collector
doc := flowdsl.Normalize(parsed) // *FlowDSL → *ir.Document
err = flowdsl.Export(doc, writer) // *ir.Document → FlowDSL YAML
FlowDSL YAML format
version: "1.0"
title: My Application
modules:
billing:
name: billing
description: Billing domain
depends_on: [module.users]
provides: [invoices]
entities:
invoice:
name: invoice
fields:
amount: { type: float, required: true }
currency: { type: string, default: USD }
events:
invoice_created:
name: invoice_created
entity_type: invoice
action: created
topic: billing.invoices
actions:
create_invoice:
name: create_invoice
kind: command
method: POST
path: /invoices
workflows:
payment_flow:
name: payment_flow
triggers: [invoice_created]
steps:
- id: validate
kind: action
action: validate_payment
next: charge
- id: charge
kind: action
action: charge_card
channels:
notifications:
name: notifications
protocol: kafka
schedules:
daily_report:
name: daily_report
cron: "0 9 * * *"
action: generate_report
Validation rules
| Code | Severity | Rule |
|---|---|---|
FDL001 | error | Document is nil |
FDL002 | warning | Version is empty |
FDL010 | error | Event has no entity_type |
FDL011 | error | Event has no action |
FDL020 | error | Workflow step without ID |
FDL021 | warning | Step references unknown next |
Compile pipeline
go-flowdsl — github.com/redelay/go-flowdsl/compile. The full pipeline with AsyncAPI + OpenAPI formats remains at github.com/redelay/go-framework/core/compile. See Core IR & Compilation.The core/compile package orchestrates the full import → validate → resolve → enrich → export pipeline, including AsyncAPI and OpenAPI format adapters.
Formats
| Constant | Value | Import | Export |
|---|---|---|---|
FormatFlowDSL | "flowdsl" | yes | yes |
FormatOpenAPI | "openapi" | yes | — |
FormatAsyncAPI | "asyncapi" | yes | — |
FormatBusinessModel | "businessmodel" | yes | — |
Functions
import "github.com/redelay/go-framework/core/compile"
// Import a source format into IR
doc, err := compile.Import(compile.FormatFlowDSL, reader)
// Run validation + resolution + enrichment
result := compile.Compile(doc)
// result.Document — enriched IR document
// result.Diagnostics — *diagnostics.Collector with errors/warnings
// result.Modules — dependency-ordered modules
// result.HasErrors() — true if compilation produced errors
// One-step: import + compile
result, err := compile.ImportAndCompile(compile.FormatFlowDSL, reader)
// Export IR back to a format
err = compile.Export(doc, compile.FormatFlowDSL, writer)
Compilation stages
- Validate — structural correctness (IDs, refs, module naming)
- Resolve — topological sort of modules by dependencies
- Enrich — promote module-level entities/events to document level, warn on unresolved
provides
Diagnostics
go-flowdsl — import github.com/redelay/go-flowdsl/diagnostics.The diagnostics package provides structured error/warning collection used throughout
the compilation pipeline.
import "github.com/redelay/go-flowdsl/diagnostics"
c := diagnostics.NewCollector()
c.Error("source", "CODE", "message")
c.Warning("source", "CODE", "message")
c.Info("source", "CODE", "message")
c.HasErrors() // bool
c.Errors() // []*Diagnostic (error-level only)
c.Warnings() // []*Diagnostic (warning-level only)
c.All() // []*Diagnostic (all levels)
Each Diagnostic has: Severity (error/warning/info/hint), Code, Message, Source,
and optional File, Line, Column, Pointer, Related.
Validate
go-flowdsl — import github.com/redelay/go-flowdsl/validate.The validate package checks an IR Document for structural correctness and consistency.
import "github.com/redelay/go-flowdsl/validate"
diag := validate.Validate(doc) // → *diagnostics.Collector
Validation rules
| Code | Severity | Rule |
|---|---|---|
VAL001 | error | Document is nil |
VAL002 | error | Document ID is required |
VAL003 | warning | Document version is empty |
VAL010 | error | Duplicate ID |
VAL020 | error | Module depends on unknown module |
VAL021 | warning | Event references unknown payload |
VAL022 | warning | Action references unknown input |
VAL023 | warning | Action references unknown output |
VAL030 | error | Workflow edge references unknown source node |
VAL031 | error | Workflow edge references unknown target node |
VAL040 | error | Module has no name |
VAL041 | warning | Module ID should start with module. |
Resolver
go-flowdsl — import github.com/redelay/go-flowdsl/resolver.The resolver package performs topological sorting of modules based on dependencies
using Kahn's algorithm with cycle detection.
import "github.com/redelay/go-flowdsl/resolver"
sorted, diag := resolver.Resolve(doc.Modules)
// sorted — modules in dependency order (dependencies first)
// diag — errors for duplicates, unknown deps, cycles
| Code | Severity | Rule |
|---|---|---|
RES001 | error | Duplicate module ID |
RES002 | error | Depends on unknown module |
RES003 | error | Circular dependency detected |
RES004 | error | Module is part of a dependency cycle |
Metadata
go-flowdsl — import github.com/redelay/go-flowdsl/metadata.The metadata package extracts and stamps document metadata.
import "github.com/redelay/go-flowdsl/metadata"
info := metadata.Extract(doc)
// info.Title, info.Version, info.Compiler
// info.ModuleCount, info.EntityCount, info.EventCount
// info.ActionCount, info.WorkflowCount, info.ChannelCount, info.ScheduleCount
metadata.Stamp(doc) // adds compiler version + compiled_at to doc.Metadata
names := metadata.ModuleNames(doc) // []string
names = metadata.ActionNames(doc) // []string
names = metadata.EventNames(doc) // []string
AsyncAPI import/export
The core/asyncapi package imports AsyncAPI 2.x YAML documents and exports IR Documents
to AsyncAPI 2.6 YAML.
Import
import "github.com/redelay/go-framework/core/asyncapi"
doc, err := asyncapi.ImportYAML(reader) // AsyncAPI YAML → *ir.Document
Converts channels to ir.Channel with publish/subscribe events, Kafka group ID bindings.
Export
err := asyncapi.Export(doc, writer) // *ir.Document → AsyncAPI 2.6 YAML
Export behavior:
- IR channels with publish/subscribe events → AsyncAPI channels
- If no channels exist but events are present, creates synthetic channels using
entity_type.actionas topic name - Kafka bindings for group IDs
Business model import/export
go-flowdsl — import github.com/redelay/go-flowdsl/businessmodel.The businessmodel package parses business model YAML and converts to/from IR.
Import
import "github.com/redelay/go-flowdsl/businessmodel"
spec, err := businessmodel.ParseYAML(reader) // YAML → *Spec
doc := businessmodel.Normalize(spec) // *Spec → *ir.Document
Export
err := businessmodel.Export(doc, writer) // *ir.Document → business model YAML
Export behavior:
- If modules exist, each module becomes a domain with entities, events, and processes (workflows)
- If no modules exist, creates a single domain from top-level items
Business model YAML format
title: E-Commerce Platform
version: "1.0"
domains:
- name: billing
description: Billing domain
entities:
- name: invoice
fields: [amount, currency, status]
events:
- name: invoice_created
entity_type: invoice
action: created
processes:
- name: payment_flow
triggers: [invoice_created]
steps: [validate, charge, confirm]
OpenAPI tool schema export
The core/openapi package also provides functions for exporting IR Documents as
function-calling tool definitions (JSON Schema format compatible with LLM tool-use APIs).
Export functions
import "github.com/redelay/go-framework/core/openapi"
tools := openapi.ExportActions(doc) // IR actions → tool definitions
tools = openapi.ExportEntities(doc) // IR entities → get_<name> lookup tools
tools = openapi.ExportCommands(doc) // IR commands → tool definitions
tools = openapi.ExportAll(doc) // all of the above combined
err := openapi.WriteTools(writer, tools) // serialize as JSON
Output format
[
{
"type": "function",
"function": {
"name": "create_invoice",
"description": "Create a new invoice",
"parameters": {
"type": "object",
"properties": {
"amount": { "type": "number", "description": "Invoice amount" },
"currency": { "type": "string" }
},
"required": ["amount"]
}
}
}
]
Type mapping (IR → JSON Schema)
| IR FieldType | JSON Schema type |
|---|---|
string, date, datetime, uuid | "string" |
integer | "integer" |
float | "number" |
boolean | "boolean" |
array | "array" |
object | "object" |
Supports nested properties, items for arrays, enum, format, and required fields.
CLI
There are two binaries named redelayctl in the Redelay source tree, with non-overlapping responsibilities. Which one you invoke depends on whether you're shaping a spec or operating a live app:
| Binary | Source path | Purpose | Imports |
|---|---|---|---|
Framework redelayctl | go-framework/cmd/redelayctl | Build-time YAML/IR tooling — validate, import, export, convert, ir dump. Works on files. | Zero module factories. |
Project redelayctl | backend/cmd/redelayctl | Runtime operations — create-user, assistant-ingest-docs, anything a module contributes via CLIProvider. Works against a live app. | CLI module bundle at backend/imports/cli. |
Both names are intentional: one works on source files, the other works against a running process. They never share an import graph.
Project redelayctl — contributing commands from a module
Any module that satisfies the CLIProvider interface gets its commands auto-discovered at bootstrap and exposed through ./cmd/redelayctl:
type CLIProvider interface {
CLICommands() []CLICommand
}
type CLICommand struct {
Name, Description string
Args []CLIArg
Handler func(ctx context.Context, args map[string]string) error
}
type CLIArg struct {
Name, Description, Default string
Required, Flag bool
}
Implement CLICommands() on the module struct, blank-import the module in backend/imports/cli/cli.go, and the command shows up in redelayctl help. The framework also auto-exposes every command as an MCP tool (prefixed cli_) so AI assistants can invoke the same operations.
Invoke via the Makefile helper (loads .env, resolves module bundle):
make redelayctl ARGS="help"
make redelayctl ARGS="create-user [email protected] --password=secret --superuser"
make redelayctl ARGS="assistant-ingest-docs --docs-dir ../spec/docs --url-base https://redelay.com/docs"
make cli is kept as an alias for scripts pinned to the old name.
Currently registered commands (expand as modules declare more):
| Command | Module | Purpose |
|---|---|---|
create-user | users | Create a user with optional --superuser flag. Bootstrap the first admin. |
assistant-ingest-docs | assistant | Walk a markdown directory, chunk by heading, embed, and upsert into the search index so the RAG assistant flow can ground answers in project docs. |
Dockerfiles ship both /redelayctl and a /cli alias; the alias will be removed in a future release once scripts have migrated.
Framework redelayctl — spec tooling
redelayctl (the framework binary) is the command-line tool for working with Redelay specifications.
Commands
# Validate a FlowDSL file (runs FlowDSL + IR validation)
redelayctl validate app.flow.yaml
# Import a source format and output IR as JSON
redelayctl import openapi api.yaml
redelayctl import asyncapi events.yaml
redelayctl import businessmodel model.yaml
# Export IR to a target format
redelayctl export json app.ir.yaml # IR → JSON
redelayctl export yaml app.ir.json # IR → YAML
redelayctl export flowdsl app.ir.json # IR → FlowDSL YAML
redelayctl export openapi app.ir.json # IR → OpenAPI tool schema JSON
# Convert between formats directly
redelayctl convert flowdsl json app.flow.yaml
redelayctl convert openapi flowdsl api.yaml
redelayctl convert asyncapi openapi events.yaml
# Parse FlowDSL and output canonical IR
redelayctl ir app.flow.yaml
# Print version
redelayctl version
Supported format conversions
| From | To |
|---|---|
flowdsl | json, yaml, flowdsl, openapi |
openapi | json, yaml, flowdsl, openapi |
asyncapi | json, yaml, flowdsl, openapi |
businessmodel | json, yaml, flowdsl, openapi |