CLI Reference

add surface

Add another surface (parallel HTTP bundle) to an existing module, under any name.
go tool gpsystem add surface <module> <surface> [--db <name>]

Surfaces are parallel per-audience bundles inside one module, for example a public api next to an admin or backoffice surface (see Project structure). The surface name is free-form (lowercase letters and digits, starting with a letter); every surface is generated from the same minimal template. Each gets its own TypeSpec service, generated package, handler and base path:

SurfaceBase path
api/api/v1/<module>
any other name, e.g. admin/api/v1/<surface>/<module>

The name carries no behavior: api is only special in that it mounts at the module root, while every other surface is namespaced under its own prefix so sibling surfaces never collide.

Pre-rename projects. This command used to be called add domain; the old name still works as a hidden alias for one release. The CLI reads the old domains:/domainDBs: keys in gpsystem.yaml and migrates them to surfaces:/surfaceDBs: automatically on the next save. An existing module can be moved to the new layout by hand: git mv internal/modules/<module>/{api,web,admin} internal/modules/<module>/surfaces/, then gpsystem add core <module> for the shared skeletons, updating the import paths, changing the // gpsystem:domains anchor comment in register.go to // gpsystem:surfaces, and bumping the relative paths in the //go:generate directives and the api/oapi-codegen/*.yaml configs one level deeper (because of the surfaces/ wrapper).

What it generates

A minimal skeleton: the contract (spec/typespec/modules/<module>/<surface>.tsp + models/<surface>.tsp), the oapi-codegen config (api/oapi-codegen/<module>-<surface>.yaml), a handler wired to the generated strict interface (its sample List calls a minimal service/ seam and wraps any error with errs.Wrap, so surface errors carry a stack from day one), a policy/ package with a registry stub wired up to enforce @permission/@policy tags, and a kept-empty http/mapper/ directory (.gitkeep) for your own code. Generated files carry no explanatory comments, only the gpsystem:* anchors the generator injects into. The generator wires no authentication or migrations: the kit's auth and rbac packages are available when you need them; see Policies for policies.

Alongside a module's first surface, the command also stamps the module-level shared skeletons: core/core.go + core/errors.go (the latter with an ErrNotFound errs.Define example) and repository/doc.go. Later surfaces skip these and reuse the existing shared core. For older modules without a shared core, add core retrofits the same skeletons.

Wiring happens via anchor insertions: an exported Register<Surface>(router, deps) function (e.g. RegisterAdmin) is appended at the file-level gpsystem:surfaces anchor in register.go, and the matching <module>.Register<Surface>(api, deps) call is inserted at the gpsystem:registrations anchor in cmd/<module>/main.go. Every surface therefore lives in its own function: per-surface middleware (e.g. admin JWT: adminGroup.Use(auth.Middleware(deps.Auth), rbac.RequireRole("admin")); see authentication) goes inside that surface's Register<Surface> function without touching the others.

@permission and @policy in the contract

Operations in the generated <surface>.tsp can be tagged with the @permission("...") / @policy("...") decorators: the decorator library (spec/typespec/lib/policy.tsp) came with the project scaffold, and main.tsp already imports it. The tags reach the OpenAPI spec as x-permission / x-policy extensions, and from there the generated PermissionByOperation / PolicyByOperation maps:

@post
@route("/orders")
@operationId("PlaceOrder")
@permission("orders.place")
@policy("orders.place")
placeOrder(@body body: PlaceOrderInput): Order | Unauthorized | Forbidden;

The enforcement side is scaffolded too: the policy/ package's registry stub (policy.New(), containing the gpsystem:policies anchor) is already threaded into the generated strict middleware inside Register<Surface> (policy.FiberEnforcer / policy.HTTPEnforcer, per engine). Protecting an operation therefore takes two steps: a tag in the .tsp and a reg.Register("...", ...) call at the anchor; a tagged but unregistered policy fails closed. Full semantics: Policies.

Namespaces (and thus emitted spec filenames) concatenate module + surface: news + admin and newsadmin + api both produce Shop.NewsAdmin.yaml / Shop.Newsadmin.yaml, which collide on case-insensitive filesystems. Pick names that keep the concatenations distinct.

Binding the repository to a named connection

By default the module's shared repository/ is flat, bound to the project's default database connection. --db <name> binds it to a named connection added earlier with add db, and renders the repository skeleton as a connection-named subfolder instead:

go tool gpsystem add db news analytics --connector bun
go tool gpsystem add surface news reports --db analytics
# -> internal/modules/news/repository/analytics/doc.go

<name> must already exist on the module (add db first); the command fails fast, listing the module's available connections, if it does not. The <Name>DB / <Name>Transactor fields the connection added to Dependencies are already there; only the repository directory changes. Surfaces without --db are unaffected and the module keeps the flat repository/ layout.

After generation

mise run generate
go run ./cmd/<module>

The new surface boots and serves immediately; implement it by fleshing out the service/ seam (putting rules shared across surfaces into the module's core/ package, where every surface can call them) and returning real data: the handler already calls the service and wraps its errors with errs.

Copyright © 2026