A serviceable stack for Go web development
A serviceable stack for Go web development
While building Vellum, I’ve made some library choices. Let’s discuss which I’m happy with, which I’m lukewarm about, and which I regret.
Choices I’m happy with
This doesn’t mean they don’t come with drawbacks - it merely means I find the tradeoff worth it.
Fx - application framework/DI
Fx has been at the core of a few applications I built thus far, including some I intend to maintain (or expect to be maintained) for a long time. It provides a means of managing the application lifecycle, dependency injection, and automatic instantiation of necessary structs. It provides a descriptive interface for a struct to declare its dependencies, too. For example, the DocumentsController
in Vellum declares them like so:
1type NewDocumentsControllerParams struct {
2 fx.In
3 View *DocumentsView
4 Logger *zap.Logger
5 DocumentRepo *repos.DocumentRepo
6 MediumRepo *repos.MediumRepo
7}
8
9type NewDocumentsControllerResult struct {
10 fx.Out
11 Controller commons.Controller `group:"controllers"`
12}
13
14func NewDocumentsController(p NewDocumentsControllerParams) NewDocumentsControllerResult {
15 return NewDocumentsControllerResult{
16 Controller: &DocumentsController{
17 View: p.View,
18 Logger: p.Logger,
19 DocumentRepo: p.DocumentRepo,
20 MediumRepo: p.MediumRepo,
21 },
22 }
23}
It enables reasonable decoupling. It comes with a significant downside, though. Since the dependency graph is instantiated at runtime, it’s not possible to statically ensure that your dependency graph is complete. Should NewDocumentsController
fail to be placed in Fx’s graph, via fx.Provide
, it won’t be caught until the application is ran.
Up to a point, this could be worked around by a test whose sole responsibility is to instantiate the app. That’s assuming the entire dependency graph is always instantiated on boot, though. If there are any dependencies that would only be conditionally instantiated, one either consciously tests such paths - increasing test complexity - or concedes that there are untested gaps in the app.
For example, Vellum does not have a test that instantiates the entire application, since it would be rather complex. And Vellum is not a large application by any stretch of the imagination.
Echo - web framework
So far, I haven’t found a situation in which a http.Server
would have been a better choice than Echo, purely from a software engineering standpoint. It’s biggest feature, in my opinion, is the enforced handler signature:
1func(echo.Context) error
It doesn’t seem so massive, doesn’t it? Oh, but it is - it enforces a return. Compare it to the handler signature http.Server
enforces:
1func(w http.ResponseWriter, r *http.Request)
It’s way too easy to mess up with this signature and forget to write to w
down some branching path. This, again, is not caught by static analysis. If you create a path without a return in an Echo handler, your tooling will scream at you. Also, it’s much easier to reason about and structure code when a branching path can just return early, e.g. due to a validation error.
Echo is also pretty dang performant, but in my personal opinion - even if it came with a bit of a performance hit, the improved ergonomics would be worth it.
envconfig
It lets you express your environment-sourced configuration like this:
1type Config struct {
2 // ...
3 EmailHost string `envconfig:"EMAIL_HOST" default:"localhost" required:"true"`
4 EmailPort int `envconfig:"EMAIL_PORT" default:"587" required:"true"`
5 EmailUser string `envconfig:"EMAIL_USER" default:"mailtrap" required:"true"`
6 // ...
7}
I could almost stop right here and it would have sold itself. It’s expressive enough that I didn’t have any issues describing what the config should be like, and it abstracts away a ton of boilerplate. It’s pretty much the first library I reach for in a new project.
sql-migrate
It is a fairly simple migrator. Simple is good - it’s easy to reason about. Its hidden power is in being usable both as a CLI tool, and a Go API. Vellum uses it like so:
1//go:embed *
2var migrationsFiles embed.FS
3
4func Run(dsn string) error {
5 // ...
6
7 migrations := &migrate.EmbedFileSystemMigrationSource{
8 FileSystem: migrationsFiles,
9 Root: ".",
10 }
11
12 if _, err := migrate.Exec(db, "postgres", migrations, migrate.Up); err != nil {
13 return err
14 }
15
16 return nil
17}
The migrations live in their own .sql
files. This means we have the full expressiveness of our dialect of choice at our fingertips. Need to do something tricky and hacky? Can do. Need to perform a - gasp! - data migration? sql-migrate
has no qualms about it.
I built a little auto-migrator in Vellum leveraging the function above, but I could just as easily have built it as my own CLI tool, or even use the built-in. Simple and powerful, just what I like.
Choices I’m lukewarm about
Probably not the best I could do, but they are serviceable. I would still probably reach for them if pressed for time, or not in a mood to trawl Awesome Go-style library lists.
robfig/cron
It’s okay. Gets you where you’re going, most of the time. Timezone management is still hard.
go-playground/validator
What it has going for it is - it’s the least bad validator I managed to find. And trust me, I looked. It comes with about a meter of boilerplate if you want modern features, such as errors translated into a specific language.
The validators are expressed as tags:
1type documentUpdateForm struct {
2 Title string `validate:"required,min=3" form:"title"`
3 Status string `validate:"required,oneof=private unlisted published" form:"status"`
4 Content string `validate:"required" form:"content"`
5 Tags string `form:"tags"`
6 BakedPath string
7 PublishedOn string `form:"published_on" validate:"required,datetime=2006-01-02"`
8}
Between that, and the documentation sometimes leaving quite a bit to be desired, it’s easy to make a mistake in your validations. I’m not saying I miss ActiveModel… but this validator makes it easy to miss.
gomponents
It’s absolutely awesome in theory: express your HTML tree with Go. Statically checked views, anyone?
In practice, once you start building your own custom components, it leads to beauties like this:
1func (v *MediaView) New(ctx echo.Context, p MediaViewNewParams) string {
2 return v.render(ctx, commons.ViewLayoutMetadata{
3 Title: "[BE] New Media",
4 }, func(ctx echo.Context) g.Node {
5 return cc.Group(
6 c.PageHeader("New Media",
7 c.SlimNeutralButton("Back", commons.JoinPath([]string{config.ADMIN_MEDIA_CONTROLLER_ROOT, p.Collection.Name})),
8 ),
9 h.Form(
10 g.Attr("enctype", "multipart/form-data"),
11 h.Class("mt-3"),
12 h.Method("post"),
13 h.Action(commons.JoinPath([]string{config.ADMIN_MEDIA_CONTROLLER_ROOT, p.Collection.Name})),
14 cc.FormLabel("File", "file",
15 cc.Input("file", "", "File", h.Type("file")),
16 ),
17 cc.FormSubmit("Create"),
18 ),
19 )
20 })
21}
Now, it’s entirely possible that this looks like that because I’m not very good at structuring views. It’s the simplest meaningful view I could show you, by the way. But unless you internalize what h
, g
, and cc
mean in this context, it’ll be quite hard for you to read this as HTML.
This goes in the lukewarm pile, rather than the regret pile, because it still beats the pants off of trying to get html/template
to do what you want. This is more of an indictment of html/template
than anything else.
Choices I regret
Or rather, a single choice.
gorm
Oh, gorm. You promised me so much.
For one, gorm theoretically comes with an auto-migrator interface. In practice, that interface is extremely brittle - for example, it has a hard time changing a column type, even between compatible types. For example, it will refuse to bump a varchar(255)
to a text
.
Worse still, it will refuse to do so quietly. When something goes wrong, I expect a bang, not a whimper.
For another, gorm theoretically allows expressing relations:
1type Document struct {
2 // ...
3 ParentID sql.NullInt64 `gorm:"foreignKey:ParentID;constraint:OnDelete:CASCADE"`
4 Parent *Document
5 Children []Document `gorm:"foreignKey:ParentID;constraint:OnDelete:CASCADE"`
6 // ...
7}
In practice, if you would like such a theoretically infinitely-nested tree eager-loaded in full… good luck. Double good luck if there are any other child models you’d like to load alongside the main tree.
Let this example speak for itself:
1func (r *DocumentRepo) GetByPath(path string, doc *models.Document) error {
2 return r.Connection.DB.
3 Where("baked_path = ?", path).
4 Preload("Parent").
5 Preload("Tags").
6 Preload("Children", func(db *gorm.DB) *gorm.DB {
7 return db.Order("published_on DESC, updated_at DESC, LENGTH(baked_path) ASC")
8 }).
9 Preload("Children.Children", func(db *gorm.DB) *gorm.DB {
10 return db.Order("published_on DESC, updated_at DESC, LENGTH(baked_path) ASC")
11 }).
12 // ...
13 Preload("Children.Tags").
14 Preload("Children.Children.Tags").
15 // ...
16 Preload("Comments", func(db *gorm.DB) *gorm.DB {
17 return db.Order("created_at DESC")
18 }).
19 First(doc).Error
20}
Did you notice the omission comments in there? Oh yeah. Currently, Vellum has a theoretical maximum nesting of 5 documents deep… because one of these lines preloads Children.Children.Children.Children.Children
. And, of course, Children.Children.Children.Children.Children.Tags
. For once this isn’t me holding it wrong, either, the docs state that’s how you do it.
Gorm also provides some hooks, such as BeforeSave
. If you’d like to do something useful in them, though, you get to prevent infinite recursion yourself.
Next time I feel like trying an ORM in Go, I’ll likely give SQLboiler a go. Or just use sqlx plus a repository pattern. It will have me write more boilerplate, but also give me more control over the actual SQL generated.
Conclusion
Most of my current stack, I’m happy to recommend. There are a few other moving pieces to Vellum, for example the deployment process or the Tailwind integration, but I’ll leave these for another time.
If you have any thoughts on this stack, or any recommendations for the future, please leave a comment!