Getting Started

Project structure

The layout of a generated consumer project and the rules behind it.

This is what the shop sample application looks like fully built out: two surfaces, a worker, events and migrations:

shop/
├── gpsystem.yaml                 # generator manifest: project name, module path, framework version,
│                                 # db (pgx|bun), template checksum, modules+surfaces
├── go.mod                        # require gpsystem + tool directives (gpsystem, oapi-codegen)
├── mise.toml                     # tasks: setup / spec / generate / dev / restart / build / test
├── Dockerfile                    # 4 stages: base / development (bind mount, go run) / build / production
├── .dockerignore
├── compose.yml                   # full dev stack: postgres, valkey, mailpit, rustfs, migrate,
│                                 # worker, module services, zero published ports, Traefik/proxynet
├── .env.example
├── spec/typespec/                # ★ the API contract (source of truth)
│   ├── main.tsp                  # imports every module (anchor-managed)
│   ├── lib/                      # the @permission / @policy decorators
│   ├── shared/errors.tsp         # ProblemDetail + FieldError (mirror of httperr)
│   ├── shared/paginator.tsp
│   └── modules/<module>/         # per module: <surface>.tsp + models/
├── api/
│   ├── openapi/                  # emitted OpenAPI 3.0 (committed, for reviewable diffs)
│   └── oapi-codegen/
│       ├── templates/            # the project's codegen templates (upstream chi-server
│       │                         # + the framework's minimal strict overrides)
│       └── <module>-<surface>.yaml
├── cmd/
│   ├── <module>/main.go          # one HTTP entry point per module (~20 lines)
│   ├── worker/main.go            # background processing: asynq server + outbox relay + scheduler
│   └── migrate/main.go           # migration runner
├── internal/
│   ├── platform/config/config.go # composed env config (Server + DB + Worker + Outbox [+ JWT])
│   └── modules/<module>/
│       ├── <module>.go           # the module root package (package <module>): the SHARED business
│       │                         # logic: entities/views, rules; every surface uses it
│       ├── errors.go             # the root package's sentinel errors (e.g. <module>.ErrNotFound)
│       ├── repository/           # THE module's single persistence layer (row structs +
│       │                         # interface + implementation); repository/<conn>/ for a named connection
│       ├── events/               # the module's events (struct + EventName)
│       ├── listeners/            # the module's worker listeners
│       │   ├── register.go       # events.Listen(...) registrations for the worker
│       │   └── <name>.go         # listener bodies
│       ├── jobs/                 # the module's worker jobs
│       │   ├── register.go       # sched.Job(...) registrations for the worker
│       │   └── <name>.go         # job bodies
│       ├── mail/                 # the module's mail notifiers (payload + templates)
│       │   └── <name>.go         # notifier body + templates/<name>.{html,txt}.tmpl
│       ├── notifications/        # the module's multi-channel notifications (payload + Via)
│       │   └── <name>.go         # notification body + templates/<name>.{html,txt}.tmpl
│       └── surfaces/             # package surfaces: wiring + one folder per surface
│           ├── register.go       # Dependencies struct + one Register<Surface>(router, deps) per surface
│           └── <surface>/        # any name (api | web | admin | ...); "api" mounts at the module root
│               ├── http/handler.go   # implements the generated strict interface
│               ├── http/mapper/      # DTO ↔ service type mapping (kept empty)
│               ├── http/gen/         # oapi-codegen output, never edit
│               ├── policy/           # policy registry (enforcing @permission/@policy)
│               └── service/          # thin audience-specific seam: delegates to the module root
└── migrations/                   # timestamp-prefixed SQL migrations (20200101000000_init.sql, 20200101000100_outbox.sql, ...)

The worker layer (cmd/worker, listeners/, jobs/, events/) is included in new projects by default; for a project scaffolded with new project --no-worker, add worker adds it.

What can live inside a module

A module folder holds four distinct kinds of thing, worth naming explicitly since they're easy to conflate:

KindFoldersWhat it is
Surfacessurfaces/api/, surfaces/admin/, ...Parallel per-audience HTTP bundles: http + service + policy
Shared core<module>.go + errors.go, repository/What every surface shares: the business rules and entities (the module root package) plus the module's single persistence layer (repository/)
Trigger unitsjobs/, listeners/Alternative entry points: invoked by time or by an event instead of HTTP; wired into cmd/worker
Contracts & contentevents/, mail/, notifications/What the module produces: event payloads, message content and templates
A surface is a per-audience bundle: a public api, an admin, a web, each a parallel HTTP face of the same module. The module root package (the package named after the module, at the top of the module folder) is the module's shared business logic (plain structs with methods, rules written once), not a DDD "domain layer". The per-surface service is the audience-specific seam: in DDD terms it plays the application service role and delegates shared rules to the module root. See design patterns for the reasoning.

The rule of thumb: channel/driver code lives in the framework or in internal/platform, content lives in the module, triggers live in jobs//listeners/. Mail is the clearest example: the SMTP transport (the standalone mail module, github.com/gp-system/mail, mail/smtp) is infrastructure, exactly like the database driver: wired in by the framework, not owned by your module. What email to send, with what subject and template, is the contact module's business content: it lives in internal/modules/contact/mail/. See mail for the worked example.

The same split applies one level up to notifications: a notification's channels (mail, database, broadcast) are framework-provided (gpsystem/notify, plus gpsystem/notify/database and gpsystem/notify/broadcast for the driver-specific pieces), while what a notification says (its payload, subject, and templates) is module content in internal/modules/<module>/notifications/.

Layer rules

The dependency direction is one-way:

http (handler) → service → <module> (module root) → repository (interface)
  • The handler never touches the database; it binds/validates input, calls its own surface's service and maps the result. Handlers never import the repository package: not-found and similar sentinels are defined in the repository or in the module root and re-exported through the root/service packages, so a handler only ever checks its own surface's service package (errors.Is(err, service.ErrNotFound)).
  • The per-surface service is a thin, audience-specific seam: it delegates shared rules to the module root and keeps only its own audience's flows. It depends on repository interfaces: testable with plain fakes.
  • The module root package is the module's shared business logic: the entities/views, the rules written once, and the sentinel errors. Events are dispatched where the rule lives, inside the transaction, through the dispatcher received in Dependencies. The module root never imports a surface package (which is why the wiring lives one level down, in surfaces/register.go).
  • The module-level repository/ contains the SQL, the row structs and the interface + implementation, once per module, shared by every surface. It receives a pg.DBTX, so the same code runs on the pool or in a transaction without knowing it, or, in a --db bun project, the bun counterpart. A module's repository uses one DB engine; for a named connection it splits into a repository/<conn>/ subfolder (add db).
  • policy/ holds the surface's request-level authorization rules: the policy layer calls them through the generated enforcer middleware.

Two binaries, one lifecycle

cmd/<module> runs the HTTP side, cmd/worker the background processing; both build on the same app lifecycle: identical graceful shutdown, identical telemetry wiring. You scale them separately: more HTTP replicas, more worker replicas. The outbox and the scheduler are replica-safe.

Generated vs. yours

Only http/gen/ is regenerated output (and marked as such). Everything else (handlers, services, repositories, the .tsp files, listener/job bodies) is normal code you edit. The generators only extend existing files at // gpsystem:* anchor comments; see the CLI overview.

The api/openapi/*.yaml files are committed on purpose: API changes show up as reviewable diffs in pull requests, and go build never depends on the Node toolchain.

Where does shared code go?

There is no pkg/ and no internal/pkg/ in a generated project: internal/ already means private, and a second layer under it just invites a junk drawer. internal/platform/config/ is the only app-wide shared folder a fresh project ships with. Inside a module, rules shared by several surfaces belong in the module's generated root package; see Shared code: a decision ladder for where to put code used by more than one surface or module.

Copyright © 2026