Project structure
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:
| Kind | Folders | What it is |
|---|---|---|
| Surfaces | surfaces/api/, surfaces/admin/, ... | Parallel per-audience HTTP bundles: http + service + policy |
| Shared core | core/, repository/ | What every surface shares: the business rules and entities (core/) plus the module's single persistence layer (repository/) |
| Trigger units | jobs/, listeners/ | Alternative entry points: invoked by time or by an event instead of HTTP; wired into cmd/worker |
| Contracts & content | events/, mail/, notifications/ | What the module produces: event payloads, message content and templates |
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
serviceis a thin, audience-specific seam: it delegates shared rules tocoreand keeps only its own audience's flows. It depends on repository interfaces: testable with plain fakes. coreis 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 inDependencies. 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 apg.DBTX, so the same code runs on the pool or in a transaction without knowing it, or, in a--db bunproject, the bun counterpart. A module's repository uses one DB engine; for a named connection it splits into arepository/<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.
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.