A serviceable stack for Go web development

Last updated: 17 days ago

Published: 17 days ago

raw source | baked source

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!


Comments (0)
Add a comment