Getting Started

The shop sample application

A single worked example project, every code sample in the docs builds on it.

The documentation's examples come from one coherent project: shop is a webshop backend with two surfaces, a worker binary, events and a scheduled job. You don't need to build it to read the docs, but every page references this project's files, so the details add up to one coherent whole.

What it models

  • The api surface (public): product listing with cursor pagination, placing an order (PlaceOrder), with validation, a policy check and a transaction.
  • The admin surface: product CRUD and image upload to object storage, behind RequireRole("admin").
  • The orderPlaced event: dispatched from the order-placement transaction, reaching the queue through the outbox.
  • The sendOrderConfirmation listener: runs in the worker, sends an MJML e-mail.
  • The nightlySalesReport job: runs every night, replica-safe.

How it's generated

The entire scaffold comes from the CLI: with exactly these commands:

go tool gpsystem new project shop --module-path github.com/acme/shop \
  --dir ./shop                    # --engine chi and/or --db bun also available
cd shop

go tool gpsystem new module shop                 # api surface, by default
go tool gpsystem add surface shop admin
go tool gpsystem add event shop orderPlaced
go tool gpsystem add listener shop orderPlaced sendOrderConfirmation --queue mail
go tool gpsystem add job shop nightlySalesReport --cron "0 3 * * *"
go tool gpsystem new migration create_products_and_orders

mise run generate                                # tsp → OpenAPI → server code → build

The shape of the project

Every file below is exactly what the generator + your business logic produce, click through the tree. The api surface mounts at the module root (/api/v1/shop/...), admin under its own prefix (/api/v1/admin/shop/...), see add surface. The shared ordering rule lives in the module's core/ package and the persistence in the module-level repository/; the api surface's service/ is a thin seam over them. The generated // gpsystem:* anchor comments are mostly omitted for readability; in your own project leave them in place, later add surface / add event / add listener calls insert at those points.

cmd/shop/main.go
package main

import (
    "context"
    "log"

    "github.com/gofiber/fiber/v3"

    "github.com/gp-system/gpsystem/dbx/pg"
    "github.com/gp-system/gpsystem/envconf"
    "github.com/gp-system/gpsystem/events/outbox"
    "github.com/gp-system/gpsystem/server/fiberx"

    "github.com/acme/shop/internal/modules/shop"
    "github.com/acme/shop/internal/platform/config"
)

func main() {
    cfg := envconf.MustLoad[config.Config]()
    ctx := context.Background()
    pool := pg.MustNewPool(ctx, cfg.DB)

    err := fiberx.Run(ctx, cfg.Server, func(app *fiber.App) error {
        api := app.Group("/api/v1")
        deps := shop.Dependencies{
            DB:         pg.NewDB(pool),
            Transactor: pg.NewTransactor(pool),
            Dispatcher: outbox.NewDispatcher(outbox.NewStore(pg.NewDB(pool))),
        }
        shop.RegisterApi(api, deps)
        shop.RegisterAdmin(api, deps)
        return nil
    }, fiberx.WithCloser("pgxpool", func(context.Context) error {
        pool.Close()
        return nil
    }))
    if err != nil {
        log.Fatal(err)
    }
}

The cmd/shop/main.go above uses the Fiber engine (the default). With --engine chi, only the HTTP entry point and the wiring inside register.go's Register<Surface> functions change; everything else in the tree is identical:

cmd/shop/main.go (chi)
package main

import (
    "context"
    "log"

    "github.com/go-chi/chi/v5"

    "github.com/gp-system/gpsystem/dbx/pg"
    "github.com/gp-system/gpsystem/envconf"
    "github.com/gp-system/gpsystem/events/outbox"
    "github.com/gp-system/gpsystem/server/chix"

    "github.com/acme/shop/internal/modules/shop"
    "github.com/acme/shop/internal/platform/config"
)

func main() {
    cfg := envconf.MustLoad[config.Config]()
    ctx := context.Background()
    pool := pg.MustNewPool(ctx, cfg.DB)

    err := chix.Run(ctx, cfg.Server, func(r chi.Router) error {
        api := chi.NewRouter()
        deps := shop.Dependencies{
            DB:         pg.NewDB(pool),
            Transactor: pg.NewTransactor(pool),
            Dispatcher: outbox.NewDispatcher(outbox.NewStore(pg.NewDB(pool))),
        }
        shop.RegisterApi(api, deps)
        shop.RegisterAdmin(api, deps)
        r.Mount("/api/v1", api)
        return nil
    }, chix.WithCloser("pgxpool", func(context.Context) error {
        pool.Close()
        return nil
    }))
    if err != nil {
        log.Fatal(err)
    }
}

See the Fiber and chi pages for the two register.go wiring variants.

Follow the order

The path of a POST /api/v1/shop/orders request through the system, step by step, with the page that documents each hop:

#What happensDocumented at
1The generated code binds and validates the requestValidation
2The enforcer middleware evaluates the orders.place permission and policyPolicies
3The service delegates to the module core; inside WithinTransaction: order insert + stock decrement + outbox rowTransactions, Outbox
4The relay forwards the orderPlaced event to the Redis queueQueue
5The worker fans out to listeners; sendOrderConfirmation sends the e-mailWorker, Events, Mail
6The whole path is a single trace, from the HTTP request to the e-mailOpenTelemetry
7On failure: the errs chain → problem+json to the client, stack to SentryError model, Sentry

Next step: your first module, the same journey, sitting at your own machine.

Copyright © 2026