Guide 3 of 12
Resources, controllers and routing
A whole resource
bogie g scaffold post title:string body:text
This is rails g scaffold: the model (everything Models and migrations covers), the wire shapes in app/views/posts.go, and app/controllers/posts_controller.go with five real handlers over the store, registered. There is no HTML and no service layer.
create db/migrate/..._create_posts.sql
create db/queries/posts.sql
create app/domain/post.go
create app/models/posts.go
create app/models/posts_test.go
create app/views/posts.go
create app/controllers/posts_controller.go
create app/controllers/posts_controller_test.go
insert app/controllers/application.go: Posts *PostsController
insert app/application.go: server.Posts = controllers.NewPostsController(store, log)
insert app/controllers/routes.go: s.Posts.SetupRoutes(&r.RouterGroup)
run sqlc generate
The model is named in the singular and the views and controller in the plural, as Rails names them: post, but PostsController and posts.go.
The store interface
Declared beside the controller that uses it (rule 2), holding only the methods its actions call:
type postsStore interface {
CreatePost(ctx context.Context, p domain.Post) (domain.Post, error)
GetPost(ctx context.Context, id string) (domain.Post, error)
ListPosts(ctx context.Context, limit int32) ([]domain.Post, error)
UpdatePost(ctx context.Context, p domain.Post) (domain.Post, error)
DeletePost(ctx context.Context, id string) error
}
*models.Store satisfies it without either package knowing about the other; a test hands the controller a fake that is a few lines.
SetupRoutes and one action
func (ctl *PostsController) SetupRoutes(router *gin.RouterGroup) {
posts := router.Group("/posts")
posts.GET("", ctl.Index)
posts.POST("", ctl.Create)
posts.GET("/:id", ctl.Show)
posts.PATCH("/:id", ctl.Update)
posts.DELETE("/:id", ctl.Destroy)
}
func (ctl *PostsController) Show(c *gin.Context) {
p, err := ctl.Store.GetPost(c.Request.Context(), c.Param("id"))
if err != nil {
ctl.fail(c, err)
return
}
c.JSON(http.StatusOK, postView(p))
}
A handler converts HTTP into domain values, calls one store method, and converts the result back with postView. *gin.Context never travels below app/controllers (rule 5): nothing below this file ever sees c.
fail: where error mapping lives
func (ctl *PostsController) fail(c *gin.Context, err error) {
switch {
case errors.Is(err, domain.ErrNotFound):
c.JSON(http.StatusNotFound, views.ErrorMessage("no such post"))
case errors.Is(err, domain.ErrInvalid):
c.JSON(http.StatusUnprocessableEntity, views.Error(err))
default:
ctl.Log.ErrorContext(c.Request.Context(), "posts: request failed",
"path", c.FullPath(), "err", err, "request_id", c.GetString("request_id"))
c.JSON(http.StatusInternalServerError, views.ErrorMessage("internal error"))
}
}
domain.ErrNotFound is 404, domain.ErrInvalid is 422 (validation runs in the store, as Rails validates on save), and anything else is logged with its request id and answered 500 without leaking its text. A controller test hands the fake store an error and asserts the status; no real database is needed.
Done with a scaffold: bogie d scaffold post removes all of it, registration included. When a resource outgrows CRUD, a service gives the logic a home that knows nothing about HTTP.
A controller by hand
bogie g controller comments index show create
create app/controllers/comments_controller.go
create app/controllers/comments_controller_test.go
insert app/controllers/application.go: Comments *CommentsController
insert app/application.go: server.Comments = controllers.NewCommentsController(log)
insert app/controllers/routes.go: s.Comments.SetupRoutes(&r.RouterGroup)
One Rails controller is one Go file: a CommentsController type holding its dependencies, SetupRoutes, and one method per action, with a test beside it. The actions are stubs answering 501 until filled in:
func (ctl *CommentsController) Create(c *gin.Context) {
c.JSON(http.StatusNotImplemented, views.ErrorMessage("comments#create is not implemented yet"))
}
index, show, create, update and destroy get their resourceful routes (GET /comments, GET /comments/:id, and so on). Any other action name is a plain GET under that name: bogie g controller comments search mounts GET /comments/search. new and edit render forms and are refused outright:
bogie: generate controller: new renders a form; this is an API-only service, so there is no new action
Name a resource in the plural, as Rails does: the name is used as typed, so bogie g controller post index answers at /post, not /posts, and says so:
note Rails names a resource in the plural: posts would answer at /posts; post answers at /post
The three markers
Go has no autoload: a controller exists only once it is constructed and mounted. That is three one-line insertions, each above a marker comment, and the generator makes all three, idempotently:
app/controllers/application.go, above// bogie:controllers: a named field,Comments *CommentsController.app/application.go, above// bogie:wire: its construction,server.Comments = controllers.NewCommentsController(log).app/controllers/routes.go, above// bogie:routes: its mount,s.Comments.SetupRoutes(&r.RouterGroup).
--pretend reports what would be written without touching anything; --force overwrites a file that differs. A marker missing from a file is a named error with no partial write; the generator never guesses. bogie d controller comments removes the file and all three lines. bogie doctor checks every marker exists exactly once and that every controller it finds is both constructed and mounted.
Giving a controller a dependency
A freshly generated controller holds only a logger. Wiring in a store or a service is three edits, all in files you already own, with no generator for the step itself:
- Beside the controller, declare the slice it needs (rule 2), only the methods it calls:
type commentsStore interface { GetComment(ctx context.Context, id string) (domain.Comment, error) } - Add a field and a constructor parameter:
Store commentsStore,NewCommentsController(store commentsStore, log *slog.Logger). - On its wire line in
app/application.go, pass the real thing:server.Comments = controllers.NewCommentsController(store, log).*models.Storesatisfies any interface made of its own methods, so nothing on the store's side changes.
The test hands in a fake with the same methods; it needs no database. A scaffolded controller, above, shows the finished shape.
Namespaces
bogie g controller admin/reports index show
create app/controllers/admin_controller/server.go
create app/controllers/admin_controller/routes.go
create app/controllers/admin_controller/reports_controller.go
create app/controllers/admin_controller/reports_controller_test.go
insert app/controllers/application.go: Admin *admin_controller.Server
insert app/application.go: server.Admin = admin_controller.NewServer(log)
insert app/controllers/routes.go: s.Admin.SetupRoutes(&r.RouterGroup)
insert app/controllers/admin_controller/server.go: Reports *ReportsController
insert app/application.go: server.Admin.Reports = admin_controller.NewReportsController(log)
insert app/controllers/admin_controller/routes.go: s.Reports.SetupRoutes(admin)
A Rails namespace (namespace :admin, Admin::ReportsController) is a Go package, app/controllers/admin_controller/, written on first use: its own server.go with its own // bogie:controllers marker, and its own routes.go mounting the /admin group above its own // bogie:routes. The package is registered at the root with the same three lines a controller gets; each controller inside it then gets two more, into the namespace's own files. Middleware the whole namespace shares, an auth check or a shared secret, goes on that group in routes.go, so the exposure boundary is visible in one place; bogie g authentication writes that middleware, covered in Authentication. One level of namespace is supported; a version such as v1 is a route group inside the namespace's routes.go, not a nested namespace. bogie d controller admin/reports removes the controller, and the package too once it holds no controller.
Views: wire structs, never the domain type
app/views/posts.go holds PostRequest and PostResponse; a domain type never doubles as a wire type, so a field added to the database does not silently appear on the wire, and params.permit's job is done by the request struct only having the fields it has:
type PostRequest struct {
Title string `json:"title"`
Body string `json:"body"`
}
Testing a controller
With a fake store, no database: the fake records what it was asked for and answers with what the test chose, so a 404 test, a 422 test and a 500-that-leaks-nothing test all run without Postgres. A namespaced controller's test mounts it on the namespace's own Server the same way.