--- toc: true --- While building [Vellum](https://kita.gawa.moe/paweljw/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](https://github.com/uber-go/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: ```golang type NewDocumentsControllerParams struct { fx.In View *DocumentsView Logger *zap.Logger DocumentRepo *repos.DocumentRepo MediumRepo *repos.MediumRepo } type NewDocumentsControllerResult struct { fx.Out Controller commons.Controller `group:"controllers"` } func NewDocumentsController(p NewDocumentsControllerParams) NewDocumentsControllerResult { return NewDocumentsControllerResult{ Controller: &DocumentsController{ View: p.View, Logger: p.Logger, DocumentRepo: p.DocumentRepo, MediumRepo: p.MediumRepo, }, } } ``` 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](https://echo.labstack.com/) - 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: ```golang func(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: ```golang func(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](github.com/kelseyhightower/envconfig) It lets you express your environment-sourced configuration like this: ```golang type Config struct { // ... EmailHost string `envconfig:"EMAIL_HOST" default:"localhost" required:"true"` EmailPort int `envconfig:"EMAIL_PORT" default:"587" required:"true"` EmailUser string `envconfig:"EMAIL_USER" default:"mailtrap" required:"true"` // ... } ``` 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](https://github.com/rubenv/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: ```golang //go:embed * var migrationsFiles embed.FS func Run(dsn string) error { // ... migrations := &migrate.EmbedFileSystemMigrationSource{ FileSystem: migrationsFiles, Root: ".", } if _, err := migrate.Exec(db, "postgres", migrations, migrate.Up); err != nil { return err } return nil } ``` 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](https://github.com/robfig/cron) It's okay. Gets you where you're going, most of the time. Timezone management is [still hard](https://www.explainxkcd.com/wiki/index.php/2867:_DateTime). ### [go-playground/validator](https://github.com/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: ```golang type documentUpdateForm struct { Title string `validate:"required,min=3" form:"title"` Status string `validate:"required,oneof=private unlisted published" form:"status"` Content string `validate:"required" form:"content"` Tags string `form:"tags"` BakedPath string PublishedOn string `form:"published_on" validate:"required,datetime=2006-01-02"` } ``` 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](https://guides.rubyonrails.org/active_model_basics.html)... but this validator makes it easy to miss. ### [gomponents](https://www.gomponents.com/) 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: ```golang func (v *MediaView) New(ctx echo.Context, p MediaViewNewParams) string { return v.render(ctx, commons.ViewLayoutMetadata{ Title: "[BE] New Media", }, func(ctx echo.Context) g.Node { return cc.Group( c.PageHeader("New Media", c.SlimNeutralButton("Back", commons.JoinPath([]string{config.ADMIN_MEDIA_CONTROLLER_ROOT, p.Collection.Name})), ), h.Form( g.Attr("enctype", "multipart/form-data"), h.Class("mt-3"), h.Method("post"), h.Action(commons.JoinPath([]string{config.ADMIN_MEDIA_CONTROLLER_ROOT, p.Collection.Name})), cc.FormLabel("File", "file", cc.Input("file", "", "File", h.Type("file")), ), cc.FormSubmit("Create"), ), ) }) } ``` 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`](https://pkg.go.dev/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](https://gorm.io/) 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](/blog/failing-loudly), not a whimper. For another, gorm theoretically allows expressing relations: ```golang type Document struct { // ... ParentID sql.NullInt64 `gorm:"foreignKey:ParentID;constraint:OnDelete:CASCADE"` Parent *Document Children []Document `gorm:"foreignKey:ParentID;constraint:OnDelete:CASCADE"` // ... } ``` 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: ```golang func (r *DocumentRepo) GetByPath(path string, doc *models.Document) error { return r.Connection.DB. Where("baked_path = ?", path). Preload("Parent"). Preload("Tags"). Preload("Children", func(db *gorm.DB) *gorm.DB { return db.Order("published_on DESC, updated_at DESC, LENGTH(baked_path) ASC") }). Preload("Children.Children", func(db *gorm.DB) *gorm.DB { return db.Order("published_on DESC, updated_at DESC, LENGTH(baked_path) ASC") }). // ... Preload("Children.Tags"). Preload("Children.Children.Tags"). // ... Preload("Comments", func(db *gorm.DB) *gorm.DB { return db.Order("created_at DESC") }). First(doc).Error } ``` 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](https://gorm.io/docs/preload.html#Joins-Preloading) 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](https://kita.gawa.moe/paweljw/vellum/-/blob/4beee5df5b04ba8c6afd8e058e15d5d047a4e240/models/document.go#L48). Next time I feel like trying an ORM in Go, I'll likely give [SQLboiler](https://github.com/volatiletech/sqlboiler) a go. Or just use [sqlx](https://github.com/jmoiron/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!