Welcome to the Monolith reference guide.
This document explains every major subsystem of the project and shows how the pieces fit together.
If you are new, start with Quick‑start then come back to read the architecture chapters.
- Introduction
- Quickstart
- Request Flow
- Practical Walk‑throughs
- Core Concepts
- Project Layout
- Extending the Monolith
- Generators
- Testing
- Development
- Server Setup
- Deployment
- Appendix
Monolith is a full‑stack Go application that demonstrates:
- Cookie‑based sessions with built‑in login
- GORM‑powered persistence (SQLite by default)
- Zero downtime deploys via Caddy retry buffering
- A tiny background job queue
- Real‑time WebSocket messaging
- Structured logging & graceful shutdown
- Embedded templates and static assets
- Built‑in performance profiling with the standard library
Everything uses the Go standard library with a handful of small, focused dependencies:
| Purpose | Package |
|---|---|
| Database driver | github.com/glebarez/sqlite |
| Sessions | github.com/gorilla/sessions |
| ORM | gorm.io/gorm |
| WebSocket library | github.com/gorilla/websocket |
| Singular/plural helpers | github.com/jinzhu/inflection |
# 1. clone & enter
git clone <repo> && cd monolith
# 2. start the server (uses air if available)
make # hot reload during development
# or without air installed
make run
# open http://localhost:9000
# 3. browse the product guides without running the full app
make guides # serves ./guides at http://localhost:9000Set the SECRET_KEY environment variable to a random string before running the server.
The first launch creates app.db and auto‑migrates the schema.
flowchart TD
A[Client Request] -->|1| B[Caddy proxy]
B -->|2| C[App :9000]
C -->|3| D[Router]
D -->|4| E[Controller]
E -- "5 if needed" --> F[(DB)]
F -->|6| E
E -->|7| G[Render View]
G -->|8| B
B -->|9| H[Encode / gzip]
H -->|10| I[Client Response]
- Visit
/signupand create an account - On success you’re logged in and redirected to
/ - Existing users go to
/loginwith their credentials - A cookie named
sessiontracks login state
Use /logout to clear the session
<script>
const sock = new WebSocket("ws://localhost:9000/ws");
sock.onopen = () => {
sock.send(JSON.stringify({command: "subscribe", identifier: "ChatChannel"}));
sock.send(JSON.stringify({command: "message", identifier: "ChatChannel", data: "Hello from JS!"}));
};
sock.onmessage = ev => console.log("got:", ev.data);
</script>All messages are persisted and broadcast to every subscriber of chat.
payload := []byte(`{"message":"Hello"}`)
jobs.GetJobQueue().AddJob(models.JobTypePrint, payload)To schedule a recurring job using a cron expression:
payload := []byte(`{"message":"Hello"}`)
jobs.GetJobQueue().AddRecurringJob(models.JobTypePrint, payload, "0 0 * * *")app/jobs/job_queue.go registers job handlers and the queue starts automatically.
# in one terminal
go run . # start app
# in another (requires an admin account and the admin generator)
curl http://localhost:9000/debug/pprof/heap > heap.out
go tool pprof heap.outmake generator job EmailThe command above creates:
app/jobs/email_job.goapp/jobs/email_job_test.goapp/models/job.go(addsJobTypeEmail)app/jobs/job_queue.go(registers the job)
Inside app/jobs/email_job.go you will find a stubbed function to implement:
func EmailJob(payload []byte) error {
var p EmailPayload
if err := json.Unmarshal(payload, &p); err != nil {
return err
}
// TODO: implement job
return nil
}make generator resource widget name:string price:intThis creates the model and the full set of REST pieces:
app/models/widget.goandapp/models/widget_test.godb/db.goupdated with the new modelapp/controllers/widgets_controller.goand test file- templates under
app/views/widgets/forindex,show,newandedit - routes injected into
app/routes/routes.go:mux.HandleFunc("GET /widgets", controllers.WidgetsCtrl.Index) mux.HandleFunc("GET /widgets/new", controllers.WidgetsCtrl.New) mux.HandleFunc("POST /widgets", controllers.WidgetsCtrl.Create) mux.HandleFunc("GET /widgets/{id}", controllers.WidgetsCtrl.Show) mux.HandleFunc("GET /widgets/{id}/edit", controllers.WidgetsCtrl.Edit) mux.HandleFunc("PUT /widgets/{id}", controllers.WidgetsCtrl.Update) mux.HandleFunc("PATCH /widgets/{id}", controllers.WidgetsCtrl.Update) mux.HandleFunc("DELETE /widgets/{id}", controllers.WidgetsCtrl.Destroy)
The generated controller functions contain placeholders, for example the index action:
func (c *WidgetsController) Index(w http.ResponseWriter, r *http.Request) {
records, _ := models.GetAllWidgets(db.GetDB())
views.Render(w, "widgets_index.html.tmpl", records)
}Each template is a basic skeleton ready to be filled in:
{{define "title"}}<title></title>{{end}}
{{define "body"}}
{{end}}make generator authenticationScaffolds a User model with session helpers, login & signup templates, and authentication middleware.
The generator also injects the following routes:
mux.HandleFunc("GET /login", controllers.AuthCtrl.ShowLoginForm)
mux.HandleFunc("POST /login", controllers.AuthCtrl.Login)
mux.HandleFunc("GET /signup", controllers.AuthCtrl.ShowSignupForm)
mux.HandleFunc("POST /signup", controllers.AuthCtrl.Signup)
mux.HandleFunc("GET /logout", controllers.AuthCtrl.Logout)make generator adminCreates an /admin dashboard with profiling helpers. If a User model does not
exist it will be generated automatically.
The generator also wires up routes for the dashboard and pprof:
mux.HandleFunc("GET /admin", middleware.RequireAdmin(controllers.AdminCtrl.Dashboard))
mux.HandleFunc("POST /admin", middleware.RequireAdmin(controllers.AdminCtrl.Dashboard))
// pprof routes
mux.HandleFunc("GET /debug/pprof/", middleware.RequireAdmin(pprof.Index))
mux.HandleFunc("GET /debug/pprof/cmdline", middleware.RequireAdmin(pprof.Cmdline))
mux.HandleFunc("GET /debug/pprof/profile", middleware.RequireAdmin(pprof.Profile))
mux.HandleFunc("GET /debug/pprof/symbol", middleware.RequireAdmin(pprof.Symbol))
mux.HandleFunc("GET /debug/pprof/trace", middleware.RequireAdmin(pprof.Trace))make generator model Widget name:string price:intFiles created:
app/models/widget.goapp/models/widget_test.godb/db.goupdated to migrate the model
The generated model file defines blank GORM hooks to customise later:
// BeforeSave is called by GORM before persisting a Widget.
func (m *Widget) BeforeSave(tx *gorm.DB) error {
return nil
}make generator controller widgets index showThis will generate:
app/controllers/widgets_controller.goapp/controllers/widgets_controller_test.go- templates
app/views/widgets/widgets_index.html.tmplandapp/views/widgets/widgets_show.html.tmpl - route entries in
app/routes/routes.go:mux.HandleFunc("GET /widgets", controllers.WidgetsCtrl.Index) mux.HandleFunc("GET /widgets/{id}", controllers.WidgetsCtrl.Show)
The controller skeleton looks like:
func (c *WidgetsController) Index(w http.ResponseWriter, r *http.Request) {
views.Render(w, "widgets_index.html.tmpl", nil)
}And the templates start with an empty body block ready for content:
{{define "body"}}
{{end}}app/config/config.go contains constants that rarely change at runtime, e.g.
const JOB_QUEUE_NUM_WORKERS = 4Everything dynamic (port and database DSN) is read from environment variables inside main.go or the relevant package:
| Variable | Default | Used in |
|---|---|---|
PORT |
9000 |
HTTP listener |
db/db.go initialises a GORM connection:
dbHandle, err = gorm.Open(sqlite.Open("app.db"), &gorm.Config{})Switching to Postgres is one line:
// import "gorm.io/driver/postgres"
gorm.Open(postgres.Open(os.Getenv("DATABASE_URL")), &gorm.Config{})db.InitDB() runs auto‑migration for every registered model so your schema stays in sync.
| Model | File | Purpose |
|---|---|---|
User |
app/models/user.go |
Registered users (email, avatar, flags) |
Job |
app/models/job.go |
Background work unit with Type & Status enums |
Message |
app/models/ws.go |
Persisted WebSocket chat message |
All models embed GORM timestamps, so you automatically get CreatedAt / UpdatedAt.
Generated models also include blank BeforeSave and AfterSave hooks. GORM
automatically invokes these methods before and after a record is persisted, so
you can implement validation or post‑processing logic as needed.
Example: Creating a user
user, _ := models.CreateUser(db.GetDB(), "[email protected]", "secret")Session helpers live in app/session/session.go:
- SecureCookie store (
gorilla/sessions) SetLoggedIn,Logout,IsLoggedIn
Authentication flow: browser posts credentials to /login which validates the
password and redirects to / on success.
If session.IsLoggedIn(r) is false, the middleware.RequireLogin decorator redirects the request to /login.
Go 1.25 ships with net/http.CrossOriginProtection, and Monolith’s CSRFMiddleware wraps this helper so unsafe cross-origin browser requests are rejected automatically. Because the standard library inspects the Origin and Sec-Fetch-Site headers, controllers and templates no longer need to embed hidden tokens or meta tags.
Enable the protection by keeping middleware.CSRFMiddleware in your handler stack:
mux := http.NewServeMux()
registerRoutes(mux, staticFiles)
handler := middleware.CSRFMiddleware(middleware.LoggingMiddleware(mux))If another trusted site must submit forms to your app, add it during initialization:
func init() {
middleware.CrossOriginProtector().AddTrustedOrigin("https://admin.example.com")
}The middleware responds with 403 Forbidden when a request fails validation. You can customize the error payload by calling CrossOriginProtector().SetDenyHandler(...).
Three middlewares are shipped:
| File | Function | Description |
|---|---|---|
app/middleware/logging.go |
LoggingMiddleware |
Structured request log using log/slog |
app/middleware/auth.go |
RequireLogin |
Gate routes behind authentication |
app/middleware/csrf.go |
CSRFMiddleware |
Block unsafe cross-origin requests using Go 1.25 protections |
Compose them like:
mux := http.NewServeMux()
mux.HandleFunc("GET /dashboard", middleware.RequireLogin(controllers.Dashboard))
handler := middleware.CSRFMiddleware(middleware.LoggingMiddleware(mux))
http.ListenAndServe(":9000", handler)All controllers are in app/controllers/ and are wired inside main.go using the new routing syntax (Go 1.25+):
mux.HandleFunc("GET /", controllers.Home)
mux.HandleFunc("POST /items/new", controllers.CreateItemHandler)Templates are parsed once during startup through views.InitTemplates(embed.FS) giving you the full power of Go’s html/template.
Assets live beside code but are embedded thanks to the embed package:
//go:embed static/*
var staticFiles embed.FS
//go:embed app/views/**
var templateFiles embed.FSstatic/is served under/static/…app/views/*.html.tmplare executed server‑side
This makes the final binary self‑contained & easy to deploy.
ws/ provides a lightweight publish/subscribe layer:
Hub– single central switchboard created at startupClient– represents one browser connection- Messages are JSON encoded and stored in the DB for history. Broadcasting is done concurrently so thousands of clients can be serviced with minimal delay.
Upgrading a request to WebSocket:
func HandleWS(hub *ws.Hub) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ws.ServeWs(hub, w, r) // handles upgrade & registration
}
}Broadcast from anywhere:
hub.Broadcast("chat", []byte("Hello, world!"))Broadcast is safe to call from any goroutine and fans the message out to
subscribers concurrently.
jobs/ is a minimal in‑process queue with workers:
jobs.RegisterHandler(models.JobTypePrint, func(j *models.Job) error {
fmt.Println(string(j.Payload))
return nil
})
jobs.GetJobQueue().AddJob(models.JobTypePrint,
[]byte(`{"message":"Hello background!"}`))Features:
- FIFO ordering backed by the
jobsDB table - Automatic retries & exponential back‑off (see
JobQueue.process()) - Configurable workers via
config.JOB_QUEUE_NUM_WORKERS - Recurring jobs with
AddRecurringJob
The email package provides a single SendEmail helper that enqueues an
email‑sending job. Emails are delivered asynchronously through Mailgun using
the REST API. Example:
err := email.SendEmail(
"Hello",
"Welcome to the app!",
"[email protected]",
[]string{"[email protected]"},
)
if err != nil {
log.Println("unable to queue email:", err)
}Set the MAILGUN_DOMAIN and MAILGUN_API_KEY environment variables so the job
workers can talk to Mailgun.
server_management/ packages the production HTTP runtime and automation scripts.
RunServerlistens on127.0.0.1:$PORTwith the standard library HTTP server and performs a graceful shutdown onSIGINT/SIGTERMso in-flight requests can complete.server_setup.shinstalls Caddy, provisions a simplemonolith.servicethat exportsSECRET_KEY/PORT, and deploys the bundledCaddyfileso Caddy reverse-proxies to the app.deploy.shbuilds a Linux binary, uploads it alongside the Caddyfile, atomically flipscurrent -> release, restarts the systemd service, and reloads Caddy.
Zero downtime now comes from the Caddy reverse proxy. The Caddyfile configures lb_try_duration and lb_try_interval so any request that lands while the service restarts is retried until the new process begins listening (or the duration expires).
After running make generator admin and creating an admin user, the /debug/pprof/* routes become available:
GET /debug/pprof/
GET /debug/pprof/profile # CPU profile
GET /debug/pprof/heap # Heap snapshot
Example CPU profile for 30 s:
go tool pprof http://localhost:9000/debug/pprof/profile?seconds=30Debugging the application with Visual Studio Code is also supported. Open
the project in VS Code and use the Launch Package configuration provided in
.vscode/launch.json to run the server under the debugger.
.
├── main.go # Program entry‑point
├── app/
│ ├── config/ # Compile‑time configuration knobs
│ ├── controllers/ # HTTP controllers (HTML + auth callbacks)
│ ├── middleware/ # Reusable HTTP middleware (logging, cross-origin protection, auth)
│ ├── session/ # Session helpers
│ ├── routes/ # Route definitions
│ ├── services/ # Business logic helpers
│ ├── jobs/ # Simple in‑process job queue
│ ├── models/ # GORM models (User, Job, Message)
│ └── views/ # `embed`ded HTML templates
├── db/ # DB connection bootstrap
│ └── db.go
├── ws/ # WebSocket hub, client & message types
├── static/ # `embed`ded public files
├── server_management/ # HTTP runtime + deployment scripts (Caddy retries)
└── tests, Makefile, etc.
Create services/email.go:
package services
func SendWelcome(to string) error {
// …
}Import and call it from controllers or jobs – services keep business logic away from HTTP glue.
Use the generator to scaffold a job:
make generator job EmailThis creates app/jobs/email_job.go with a stub EmailJob function, registers it
in app/jobs/job_queue.go and adds JobTypeEmail to app/models/job.go.
hub.Subscribe(client, "notifications")
hub.Broadcast("notifications", []byte(`{"title":"Build finished"}`))Generators scaffold common pieces of the application. They can be run through
the main program or via make:
go run main.go generator <type> [...options]
# or
make generator <type> [...options]Supported types are model, controller, resource, authentication, job and admin.
make generator model Widget name:string price:intCreates app/models/widget.go with a Widget struct and updates db/db.go so the
model is automatically migrated. The file also defines empty BeforeSave and
AfterSave hooks which you can use to validate your model before and after it
is saved.
Controllers are typically named using the plural form:
make generator controller widgets index showThis generates app/controllers/widgets_controller.go, inserts matching routes into
app/routes/routes.go and creates templates like app/views/widgets/widgets_index.html.tmpl.
Example routes when generating index and show actions:
mux.HandleFunc("GET /widgets", controllers.WidgetsCtrl.Index)
mux.HandleFunc("GET /widgets/{id}", controllers.WidgetsCtrl.Show)The resource generator produces a model and a full REST controller in one step. Pass the singular name; the controller and routes will be pluralised.
make generator resource widget name:string price:intThis creates the model, a widgets controller with all CRUD actions, placeholder
tests and templates, and RESTful routes under /widgets.
The following routes are injected:
mux.HandleFunc("GET /widgets", controllers.WidgetsCtrl.Index)
mux.HandleFunc("GET /widgets/new", controllers.WidgetsCtrl.New)
mux.HandleFunc("POST /widgets", controllers.WidgetsCtrl.Create)
mux.HandleFunc("GET /widgets/{id}", controllers.WidgetsCtrl.Show)
mux.HandleFunc("GET /widgets/{id}/edit", controllers.WidgetsCtrl.Edit)
mux.HandleFunc("PUT /widgets/{id}", controllers.WidgetsCtrl.Update)
mux.HandleFunc("PATCH /widgets/{id}", controllers.WidgetsCtrl.Update)
mux.HandleFunc("DELETE /widgets/{id}", controllers.WidgetsCtrl.Destroy)make generator authenticationGenerates a basic user model, session management and routes for user signup, login and logout. Routes added:
mux.HandleFunc("GET /login", controllers.AuthCtrl.ShowLoginForm)
mux.HandleFunc("POST /login", controllers.AuthCtrl.Login)
mux.HandleFunc("GET /signup", controllers.AuthCtrl.ShowSignupForm)
mux.HandleFunc("POST /signup", controllers.AuthCtrl.Signup)
mux.HandleFunc("GET /logout", controllers.AuthCtrl.Logout)make generator job MyJobCreates app/jobs/my_job_job.go with a stub MyJobJob function, registers it in
app/jobs/job_queue.go and adds JobTypeMyJob to app/models/job.go.
make generator adminScaffolds an /admin dashboard for profiling and wraps it in admin-only
middleware. If no User model exists it will be generated along with the
authentication pieces. It registers the following routes:
mux.HandleFunc("GET /admin", middleware.RequireAdmin(controllers.AdminCtrl.Dashboard))
mux.HandleFunc("POST /admin", middleware.RequireAdmin(controllers.AdminCtrl.Dashboard))
mux.HandleFunc("GET /debug/pprof/", middleware.RequireAdmin(pprof.Index))
mux.HandleFunc("GET /debug/pprof/cmdline", middleware.RequireAdmin(pprof.Cmdline))
mux.HandleFunc("GET /debug/pprof/profile", middleware.RequireAdmin(pprof.Profile))
mux.HandleFunc("GET /debug/pprof/symbol", middleware.RequireAdmin(pprof.Symbol))
mux.HandleFunc("GET /debug/pprof/trace", middleware.RequireAdmin(pprof.Trace))Run the unit tests by running following in the root of the repo:
make testapp/controllers/controllers_test.go shows how to spin up an in‑memory HTTP server and assert redirects.
If you have air installed, then you can start a development server with hot reloading by running the following in the root of the repo:
make
Otherwise, just run the app with:
make run
You can also create a standalone binary with:
make buildExport a strong SECRET_KEY locally (the setup script refuses to run without it), then run:
make server-setup root@{{ip address of server}}Edit server_management/Caddyfile with your domain and any desired tweaks before running the setup. The script installs Caddy, writes /etc/systemd/system/monolith.service to launch the binary directly on 127.0.0.1:$PORT, and uploads the Caddyfile so the proxy terminates TLS and retries upstream requests during deploys.
For example,
make server-setup [email protected]Run the following from the root of the repo:
make deploy {{ip address of server}}
where ip address of server is the hostname and IP address of your server.
For example,
make deploy [email protected]make deploy wraps server_management/deploy.sh, which:
- Builds a Linux/amd64 binary with
go build. - Uploads the binary and the repository's Caddyfile into a timestamped release directory.
- Atomically flips
/opt/monolith/currentto the new release, restartsmonolith.service, and reloads Caddy.
Zero downtime is achieved because Caddy's lb_try_duration/lb_try_interval settings keep retrying upstream connections while the service restarts. By default the script prunes old releases after deployment; set PRUNE=false to skip pruning or override KEEP to change how many releases are retained.
| Name | Description | Default |
|---|---|---|
PORT |
TCP port the app listens on (Caddy reverse-proxies to this) | 9000 |
DATABASE_URL |
Postgres DSN (if you switch drivers) | – |
MAILGUN_DOMAIN |
Mailgun domain used for sending mail | – |
MAILGUN_API_KEY |
Private API key for Mailgun | – |
SECRET_KEY |
Key used to sign session cookies; must be set to a random string | – |
| Command | Effect |
|---|---|
make |
Run a hot reloaded development server using air |
make build |
Build a statically linked binary |
make run |
go run ./... |
make test |
go test ./... |
make clean |
Clear test cache |
make deploy |
Zero downtime deploy via server_management/deploy.sh |