Project structure
This page shows what nucleus new myapp writes to disk, where your own code
goes, and how to choose between the two supported layouts.
The scaffold is a minimal empty skeleton: a composition root, config, a
.gitignore, a README.md, a container image definition, and an empty
migrations/ directory. It generates no feature code — there is no internal/<resource>/ tree. The
skeleton runs immediately and serves the framework's built-in /healthz
endpoint with no modules mounted. You add features by writing modules and
calling .Mount().
Skeleton layout — api template (lightweight, core-only)
myapp/
├── main.go # Composition root — nucleus.New().FromConfigFile("nucleus.yml").WithoutDefaults().WithStorage().WithRateLimit().Start()
├── nucleus.yml # Runtime configuration (port, databases.default.url, …)
├── migrations/ # Empty — add *.up.sql / *.down.sql here as you build features
├── Dockerfile # Multi-stage build; distroless runtime pinned by digest, runs as uid 65532
├── .dockerignore # Keeps .git, .env and local database files out of the build context
├── go.mod
├── go.sum # written by nucleus new (absent with --offline until go mod tidy)
└── .gitignore
The api skeleton calls .WithoutDefaults(): no Casbin enforcer, no mail.
Routes are unauthenticated until you add access control. It also calls
.WithStorage(), which builds storage only once nucleus.yml declares it:
with no storage: block there is no store and no storage/ directory, and
nucleus add s3 (or gcs, azure) plus a block selecting it gives the
application its bucket. Remove .WithStorage() and a declared storage block
is ignored, with one ERROR line at boot naming the option; from v2.0.0 that
configuration refuses to start (DEP-2026-013). Mail works the same way
through .WithMail(), which nucleus add accounts adds: until the chain
carries it, a mail_driver in nucleus.yml is ignored, with one ERROR line
at boot naming the option (DEP-2026-015). The skeleton also calls
.WithRateLimit(), which mounts the rate limiter once nucleus.yml sets
rate_limit_requests above 0 and not before; remove it and the
rate_limit_* keys are ignored, with one ERROR line at boot naming the
option (DEP-2026-016).
Authorization is the one default the skeleton leaves out on purpose — its
contract is "no authz" — and takes back with one call. Without it there is
no enforcer, so rbac_policy_file loads nothing and metrics_public: false
gates nothing — set either and the boot log says so in one ERROR line, and
from v2.0.0 the application refuses to start (DEP-2026-017). The policy rows
a mounted module declares are discarded for the same reason:
nucleus generate module writes rows that keep writes for an authenticated
subject, and on this skeleton those writes answer anyone — the boot log
names the routes in one ERROR line (DEP-2026-017). Leave profiling_enabled
off here too: with no policy to put it behind, /debug/pprof would answer
anyone, and the boot log says so in one ERROR line (DEP-2026-018).
To authorize the skeleton's routes, add .WithAuthz() beside
.WithoutDefaults():
nucleus.New().
FromConfigFile("nucleus.yml").
WithoutDefaults().
WithAuthz().
WithStorage().
WithRateLimit().
Start()
It mounts the default stack's authorization and nothing else of that stack:
a default-deny enforcer that loads rbac_policy_file and the rows modules
declare, decodes the bearer token ahead of the rate limiter, and treats an
API key's scopes as subjects. Every route outside the bootstrap allow-list
(/healthz, /livez, /readyz, /login, …) then answers 403 until a
policy row allows it — with no policy file and no module rows, that is
every route you add, and the boot log says authz: default-deny with 0 policy rows. Write the rows in a rbac_policy.csv beside nucleus.yml
(the mvc layout below shows one), or declare them on your modules. A
service that wants the rest of the default stack too starts from the mvc
layout, or drops .WithoutDefaults().
Skeleton layout — mvc template (full-stack with RBAC)
myapp/
├── main.go # Composition root — nucleus.New().FromConfigFile("nucleus.yml").Start()
├── nucleus.yml # Runtime configuration (includes rbac_policy_file)
├── rbac_policy.csv # Casbin policy; grants anonymous access to built-in endpoints
├── migrations/ # Empty
├── Dockerfile # Multi-stage build; also copies rbac_policy.csv, which the enforcer needs
├── .dockerignore # Keeps .git, .env and local database files out of the build context
├── go.mod
├── go.sum # written by nucleus new (absent with --offline until go mod tidy)
└── .gitignore
The mvc skeleton omits .WithoutDefaults(): a default-deny Casbin
enforcer is active. rbac_policy.csv grants public access to the built-in
health endpoint; widen it as you add your own routes. The admin panel
(orbit) is not included in the scaffold — mount it explicitly with
.Mount(orbit.Module(...)) when you need it.
Adding your first feature: the module layout
Once you have a skeleton running, add a feature by creating a module package
under internal/. Below is the layout of a single notes REST resource,
which you can use as a concrete model:
myapp/
└── internal/
└── notes/
├── module.go # nucleus.Module[C] — OnStart wires rt.DB(); Routes registers r.Resource(...)
├── controller.go # Handler methods (Index, Show, Create, Update, Destroy)
└── note.go # Domain model struct (optional; embed model.BaseModel)
The module struct in module.go is the seam between the framework and your
domain code. Import it in main.go and pass it to .Mount(...):
package main
import (
"log"
"github.com/acme/myapp/internal/notes"
"github.com/jcsvwinston/nucleus/pkg/nucleus"
// The framework links no database driver: each ships as its own module
// and the application imports the one it uses, the way
// database/sql drivers have always been wired. Drop this line and the
// build still succeeds — startup then stops with the line to add back.
_ "github.com/jcsvwinston/nucleus/drivers/sqlite"
)
func main() {
err := nucleus.New().
FromConfigFile("config/nucleus.yaml").
// No WithoutDefaults() here (DX-11): this example is the model the
// quickstart tells you to copy onto the mvc scaffold, so it runs
// with the same barriers the scaffold turns on — default-deny authz
// and CSRF. Its config supplies the policy rows and the CSRF
// exemption; copy those too if you write your own config.
Mount(notes.Module()).
Start()
if err != nil {
log.Fatalf("myapp: %v", err)
}
}
Where it runs from. Relative
databases.default.urland--configpaths resolve from the process working directory, so run the application from its own directory — from anywhere else those paths break.
Two layouts, and when to use each
Nucleus supports two project layouts. Both compile and run identically, so the choice is purely organisational — the framework does not push you toward either one.
Feature-folder (module) layout
Groups code by feature: one package per module under internal/<feature>/.
This is the layout shown above. Each
feature owns its routes, controller, model, and service behind a single
Module you .Mount(...).
Use it when features are cohesive units you want to add, move, or remove as a whole.
Layered layout (generate resource)
nucleus generate resource instead emits a layout grouped by architectural
role:
internal/
├── models/ # data structures and persistence
├── controllers/ # HTTP handlers
├── services/ # business logic
├── repositories/ # SQL access
└── contracts/ # request/response types
Use it when you prefer role-based folders and want the generator to scaffold each resource for you.
You can mix the two: start layered, then extract a feature folder when a feature grows its own surface.
What goes where
| Path | Purpose |
|---|---|
main.go | Composition root. Calls nucleus.New() and mounts modules. The entry point for go run .. |
nucleus.yml | Single source of truth for runtime configuration (port, databases.default.url, …). |
migrations/ | SQL files named 001_create_<resource>.up.sql / .down.sql. Managed by nucleus migrate. |
rbac_policy.csv | Casbin RBAC CSV policy (mvc template only). |
internal/<resource>/module.go | nucleus.Module[C] value — OnStart captures rt.DB(), Routes registers r.Resource(...). |
internal/<resource>/controller.go | HTTP handlers. One file per resource keeps the diff surface small. |
internal/<resource>/<resource>.go | Domain model struct (embeds model.BaseModel). |
internal/<resource>/service.go | Orchestration above repositories (optional). |
internal/<resource>/repository.go | SQL access via *sql.DB (optional). |
Templates
nucleus new accepts a --template flag:
| Template | Defaults |
|---|---|
api | REST only — nucleus.New().WithoutDefaults().WithStorage() (no authz, no mail; storage once nucleus.yml declares it). |
mvc | Full stack — RBAC enforcer, built-in endpoints. Mount orbit for the admin panel. |
nucleus new myapp --template api
nucleus new myapp --template mvc
Why this layout
- Composition root at
main.go— nocmd/server/nesting.go run .is the single start command regardless of template. internal/<resource>/— each resource is a self-contained package. Refactors stay private until you decide otherwise.migrations/at the top level means SQL is reviewable as data, not embedded in code, and the CLI can manage it without reflection.nucleus.ymlkeeps configuration out of source files — the only Go-level configuration is the call tonucleus.New().nucleus.Module[C]is the single seam:OnStartruns beforeRoutes, so the database handle captured viart.DB()is always non-nil when routes are registered.