Go Admin
go-admin is a feature-gated admin orchestration runtime for Go services. It wires modules, panels, commands, navigation, and route surfaces while letting hosts swap dependencies (auth, persistence, CMS container, activity, media, export, settings).
The repo provides two modules:
- Core:
github.com/goliatone/go-admin (dependency-light, framework-agnostic orchestration)
- Quickstart:
github.com/goliatone/go-admin/quickstart (opinionated defaults + adapters for faster bootstrap)
When to use core vs quickstart
- Use core when you need strict control over dependencies, router/runtime wiring, and module registration.
- Use quickstart when you want a working admin quickly with prewired defaults (feature gates, adapter hooks, Fiber/view helpers, theme/form/dashboard integrations) and then override selectively.
Runtime lifecycle (core mental model)
- Build
admin.Config (title/base path, locales, URLs, permissions, feature-related config).
- Construct
*admin.Admin with admin.New(cfg, deps).
- Optionally register custom modules before boot with
adm.RegisterModule(...).
- Optionally register CMS bootstrap hooks with
adm.AddCMSBootstrapHook(...) to create or repair host-defined content types before route snapshots are taken.
- Call
adm.Initialize(router) once to run prepare + boot steps, reconcile dynamic CMS panels, and mount routes.
Initialize runs the boot pipeline (panels, dashboard, search, settings, jobs, notifications, translation routes, etc.), auto-registers built-in modules according to feature gates, and reconciles dynamic CMS panels before alias routes and any later quickstart content-entry UI binding snapshot panel state.
Quickstart follows the same lifecycle, but uses quickstart.NewAdminConfig(...) and quickstart.NewAdmin(...) to preload defaults and adapter wiring.
cfg := quickstart.NewAdminConfig("/admin", "Admin", "en")
adm, _, err := quickstart.NewAdmin(
cfg,
quickstart.AdapterHooks{},
quickstart.WithMinimalFeatures(),
)
if err != nil {
panic(err)
}
server := router.NewHTTPServer()
host := quickstart.NewHostRouter(server.Router(), cfg)
if err := adm.Initialize(host.Admin()); err != nil {
panic(err)
}
When your host owns additional content types, register them through a bootstrap hook instead of manually reordering startup:
adm.AddCMSBootstrapHook(func(ctx context.Context, adm *admin.Admin) error {
_, err := adm.ContentTypeService().CreateContentType(ctx, admin.CMSContentType{
Name: "Quote",
Slug: "quote",
Status: "active",
Schema: map[string]any{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": map[string]any{"title": map[string]any{"type": "string"}},
},
Capabilities: map[string]any{"panel_slug": "quotes"},
})
return err
})
If you bind quickstart content-entry UI routes, do that after Initialize(...) so canonical routes such as /admin/quotes are derived from the reconciled registry snapshot.
Core-only wiring is the same shape, but you construct directly from admin.New:
cfg := admin.Config{BasePath: "/admin", Title: "Admin", DefaultLocale: "en"}
adm, err := admin.New(cfg, admin.Dependencies{})
if err != nil {
panic(err)
}
if err := adm.RegisterModule(myModule); err != nil {
panic(err)
}
server := router.NewHTTPServer()
if err := adm.Initialize(server.Router()); err != nil {
panic(err)
}
Key extension points
- Features: provide your gate implementation with core. With quickstart, use
WithMinimalFeatures() or WithFeatureSet(...) to replace the base set and
WithFeatureOverrides(...) to merge selected values.
- Modules: register host modules with
RegisterModule; mounted modules must expose RouteContractProvider so routing ownership stays explicit.
- URLs: use
Config.URLs + URLKit route names instead of hardcoding paths.
- Scope policy: use
Admin.EffectiveScope(...) or Admin.EffectiveScopeFromRequest(...) before scoped reads, writes, background syncs, and materialized read-model updates. In single-tenant mode, missing tenant/org values are filled from configured defaults; in multi-tenant mode they remain missing unless trusted actor, claims, record, or service input provides them. Authenticated admin APIs do not trust browser query tenant_id or org_id as actor scope.
- Commands: register typed commands and message factories for panel/API dispatch.
- RPC: expose selected commands through
admin.commands.dispatch with
deny-by-default command rules; see docs/GUIDE_RPC.md.
- Panels: control canonical UI ownership (
PanelUIRouteMode*) and canonical entry behavior (PanelEntryMode*).
- Themes: wire admin
go-theme selectors separately from public-site theme
packages; see docs/GUIDE_THEME.md.
- Auth and permissions: wire authentication, authorization, roles, scope, and
diagnostics consistently; see
docs/GUIDE_AUTH_PERMISSIONS.md.
Search Setup
go-admin exposes adapters for wiring go-search into public site search and
admin global search, but host applications still own the go-search runtime,
schema setup, migrations, and indexing.
See docs/GUIDE_SEARCH.md for admin global search, public site search,
NewGoSearchBundle(...), route contracts, feature gates, permissions, and
panel DataGrid search.
The upstream media surface is intentionally split across layers:
go-admin owns the admin-facing media module, gallery page, route-key-driven layout integration, and shared schema hints for admin pages.
go-formgen owns the field-level media-picker component, renderer templates, and picker runtime used inside forms.
- Host apps keep ownership of storage providers, upload workflows, delivery URL rules, and any CMS-specific normalization around persisted media values.
That split keeps page-level media UX aligned with normal admin module/layout behavior while letting hosts keep app-specific delivery and persistence concerns out of core.
See docs/GUIDE_FORMGEN.md for the broader form generation and component
customization contract.
URL Configuration
Config.URLs is the canonical URL surface. It exposes separate namespaces for
admin and public URLs, each with its own base path, API prefix, and version.
Defaults preserve /admin for the admin UI and /api/v1 for the public API.
If you need full control, set Config.URLs.URLKit to a custom URLKit config.
Example:
cfg := admin.Config{
BasePath: "/admin", // legacy fallback + theme tokens
URLs: admin.URLConfig{
Admin: admin.URLNamespaceConfig{
BasePath: "/admin",
APIPrefix: "api",
APIVersion: "",
},
Public: admin.URLNamespaceConfig{
BasePath: "",
APIPrefix: "api",
APIVersion: "v1",
},
},
}
Routing Policy
admin/routing is the canonical routing policy package.
- Hosts own absolute roots; modules declare relative route tables.
- Mounted modules must implement
admin.RouteContractProvider.
- Startup routing validation is strict fail-fast in every environment.
- Public API mounts are opt-in and only exist when a module declares
PublicAPIRoutes.
- Protected-app routing is also opt-in. Hosts must set
Config.Routing.ProtectedAppEnabled=true before /app and /app/api roots are reserved.
- When protected-app routing is enabled and no explicit roots are supplied, the canonical defaults are
/app for the UI surface and /app/api for the protected API surface.
- Protected-app root overrides are ignored unless protected-app routing is enabled.
- Host runtime config may source these values from
app.json or any other config system, but go-admin itself only consumes the normalized canonical fields on Config.Routing.
- Quickstart supports both single-process and split-deployment composition with the same surface model: public-site deployments mount only the site surface, protected-app deployments mount
ProtectedAppUI() and ProtectedAppAPI() with protected-app auth, and admin deployments keep the existing admin surfaces and auth wiring.
Operationally, the supported diagnostics are in-process:
adm.RoutingReport() for effective roots, resolved module mounts, warnings, and conflicts.
adm.RoutingPlanner().Manifest() for deterministic route-manifest export.
A stable HTTP JSON routing report endpoint is not part of the first release of
the clean-break routing API. Use the in-process report/manifest APIs or
quickstart doctor output instead.
See docs/GUIDE_ROUTING.md for the external-module contract, release policy,
and CI manifest-review guidance.
CRUD, DataGrid, and Actions
Panel CRUD resources, DataGrid list wiring, and workflow/command-backed row and
bulk actions are documented in docs/GUIDE_CRUD.md. Form generation,
go-formgen integration, UI schema overlays, and custom form components are
documented in docs/GUIDE_FORMGEN.md. RPC command transport and workflow RPC
methods are documented in docs/GUIDE_RPC.md.
Panel Entry Modes
Panels can customize what the canonical panel URL (/admin/<panel>) renders.
PanelEntryModeList (default): render the panel list/datagrid view.
PanelEntryModeDetailCurrentUser: resolve the current authenticated user ID
and render the panel detail view for that record.
Use PanelBuilder.WithEntryMode(...) in module/panel registration. This is
complementary to PanelUIRouteMode:
PanelUIRouteModeCanonical: quickstart canonical panel UI routes are
registered and honor EntryMode.
PanelUIRouteModeCustom: module-owned routes are responsible for entry
behavior.
CMS CRUD Alignment (Read/Write Split)
Admin page CRUD now uses an explicit admin read model (AdminPageRecord) plus split read/write services. List and detail reads go through AdminPageReadService, while writes (create/update/delete/publish) use AdminPageWriteService via PageApplicationService so HTML and JSON paths stay aligned.
For translation-enabled page/content lists, grouped translation-family UX is backed by real sibling rows, not only available_locales metadata:
- Explicit locale filters such as
locale=en remain locale-scoped and return only that locale.
locale=all, group_by=family_id, and family_id=<id> sibling lookups switch list reads into translation-family expansion mode and emit one row per locale sibling.
- Grouped panel responses keep the existing
children / records family-group contract; the change is upstream row production.
If you bridge go-cms page admin reads through NewGoCMSAdminPageReadAdapter(...), the adapter remains projection-oriented unless the underlying service already expands siblings. Use NewGoCMSAdminPageReadAdapterWithContent(...) when grouped translation-family page lists must expand locale siblings through the CMS content service.
See docs/GUIDE_CMS.md for include flags, locale resolution, blocks payload rules, and view-backed fallback guidance. Migration notes live in CHANGELOG.md.
Commands (go-command)
Commands are message-driven: define a message type with a stable Type() string,
implement a command.Commander[Msg], and register a message factory for
name-based dispatch from HTTP, RPC, or panels.
New modules should register an owner-scoped set. A set validates every handler
and factory, builds an isolated runtime, then publishes the whole owner
atomically:
type PublishPageMsg struct {
IDs []string
}
func (PublishPageMsg) Type() string { return "pages.publish" }
type PublishPageCommand struct {
store *Store
}
func (c *PublishPageCommand) Execute(ctx context.Context, msg PublishPageMsg) error {
return c.store.Publish(ctx, msg.IDs)
}
set, err := adm.Commands().NewRegistrationSet("pages")
if err != nil {
return err
}
if err := admin.RegisterSetCommand(set, &PublishPageCommand{store: store}); err != nil {
return err
}
if err := admin.RegisterSetContextMessageFactory(set, "pages.publish",
func(ctx context.Context, payload map[string]any, ids []string) (PublishPageMsg, error) {
opts, _ := command.DispatchOptionsFromContext(ctx)
// opts is the same effective snapshot used by the handler and receipt.
_ = opts.CorrelationID
return PublishPageMsg{IDs: ids}, nil
},
); err != nil {
return err
}
registration, err := set.Commit()
if err != nil {
return err
}
// Retain registration and call registration.Close() during module shutdown.
RegisterCommand and RegisterMessageFactory remain available for legacy
process-global registration. Owned sets are preferable for modules and tests:
each committed owner has a private go-command runtime/registry generation,
can be committed after global registry startup, and can be closed without
disturbing other owners. CommandBus.Reset detaches both owned generations and
registrations made through that bus.
Context-aware factories receive the caller's values, deadline, and
cancellation plus the normalized effective command.DispatchOptions.
Precedence is explicit trusted options, then transport-normalized
correlation/idempotency inputs, then permitted server defaults. Payload
actor, tenant, or organization fields are never promoted to trusted context;
derive authorization and scope from authenticated context.
Configure non-inline capabilities inherited by future owned generations before
commit:
err := adm.Commands().SetOwnedRuntimeConfig(admin.OwnedCommandRuntimeConfig{
Executors: map[command.ExecutionMode]dispatcher.CommandExecutor{
command.ExecutionModeQueued: queueExecutor,
},
})
Runner options passed to RegisterSetCommand are handler-local and opt in to
in-process retries. Queue delivery retries are a separate policy. Commands that
implement go-command descriptor interfaces appear in the command catalog only
while their owner is committed; exposure remains opt-in.
CLI/cron metadata is optional via command.CLICommand (CLIOptions) and command.CronCommand (CronOptions).
Command Routing Observability
For queued command flows (for example documents.pdf.remediate), dispatch logs include routing fields:
command_id
execution_mode
dispatch_id
correlation_id
See docs/GUIDE_BKG_CMD_OBSERVABILITY.md for dispatch/remediation metrics, alert signals, and activity/audit lifecycle contracts.
FSM Lifecycle Activity Projection
When you use go-command/flow state machines and want lifecycle audit events in the admin-local feed, wire the FSM lifecycle hook to an admin activity sink adapter:
import (
"context"
coreadmin "github.com/goliatone/go-admin/admin"
"github.com/goliatone/go-command/flow"
)
hook := &flow.LifecycleActivityHook[MyMsg]{
Sink: coreadmin.NewFSMLifecycleActivitySinkAdapter(adm.ActivityFeed()),
}
machine, err := flow.NewStateMachineFromDefinition(
definition,
store,
req,
resolvers,
actions,
flow.WithExecutionPolicy[MyMsg](flow.ExecutionPolicyLightweight),
flow.WithLifecycleHooks[MyMsg](hook),
flow.WithHookFailureMode[MyMsg](flow.HookFailureModeFailOpen), // or FailClosed
)
if err != nil {
panic(err)
}
_, err = machine.ApplyEvent(context.Background(), flow.ApplyEventRequest[MyMsg]{
EntityID: "entity-1",
Event: "approve",
Msg: MyMsg{EntityID: "entity-1"},
})
Mapping behavior:
LifecycleActivityEnvelope.Verb -> ActivityEntry.Action (for example fsm.transition.attempted|committed|rejected)
LifecycleActivityEnvelope.ObjectType + ObjectID -> ActivityEntry.Object
- Envelope metadata is copied into
ActivityEntry.Metadata (including correlation fields like machine_id, machine_version, entity_id, execution_id, event, transition_id, phase)
References
- Documentation index:
docs/README.md
- Frontend reuse and server-command boundaries:
docs/GUIDE_FRONTEND.md
- Quickstart API and helpers:
quickstart/README.md
- CRUD and DataGrid wiring:
docs/GUIDE_CRUD.md
- Search wiring and go-search integration:
docs/GUIDE_SEARCH.md
- Form generation and go-formgen customization:
docs/GUIDE_FORMGEN.md
- Theme and go-theme integration:
docs/GUIDE_THEME.md
- Workflow/state-machine wiring:
docs/GUIDE_WORKFLOW.md
- RPC transport and command dispatch:
docs/GUIDE_RPC.md
- Authentication, permissions, scope, and debugging:
docs/GUIDE_AUTH_PERMISSIONS.md
- Roles and role seeding:
docs/GUIDE_ROLES.md
- Routing policy contract and CI review workflow:
docs/GUIDE_ROUTING.md
- Example runtime config conventions:
examples/web/config/app.json and examples/web/README.md
- CMS and translation workflow guide:
docs/GUIDE_CMS.md
- Background command routing observability:
docs/GUIDE_BKG_CMD_OBSERVABILITY.md
- Persisted workflow runtime:
docs/GUIDE_WORKFLOW.md#persisted-workflow-runtime
- End-to-end examples:
examples/web/main.go, examples/admin-shell/cmd/admin/main.go