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, kit version,
│                                 # engine (fiber|chi), 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, redis, 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 (Fiber: full set;
│       │                         # chi: 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>/
│       ├── register.go           # Dependencies struct + one Register<Surface>(router, deps) per surface
│       ├── core/                 # the module's SHARED business logic: entities/views, rules,
│       │                         # sentinel errors (e.g. ErrNotFound); every surface uses it
│       ├── 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/
│           └── <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 core
└── 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; add worker retrofits it into older ones.

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 corecore/, repository/What every surface shares: the business rules and entities (core/) 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. Core 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 core. See design patterns for the reasoning.

The rule of thumb: channel/driver code lives in the kit or in internal/platform, content lives in the module, triggers live in jobs//listeners/. Mail is the clearest example: the SMTP transport (gpsystem/mail, gpsystem/mail/smtp) is infrastructure, exactly like the database driver, it lives in the kit. 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 kit-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 → core → repository
  • 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 core and re-exported through core/service, 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 core and keeps only its own audience's flows. It depends on repository interfaces: testable with plain fakes.
  • core 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. Core never imports a surface package.
  • 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 generated core/; see Shared code: a decision ladder for where to put code used by more than one surface or module.

Copyright © 2026