byob-go-cli

Accept fs.FS at package boundaries for filesystem seams

byob-interfaces.3 interfaces

Problem: a package that reads files via os.Open or os.ReadFile has baked disk I/O into its API. Tests have to create temp directories, write fixture files, and clean up — or reach for path-rewriting hacks. Embedded assets end up on a parallel code path.

Idea: accept fs.FS at package boundaries instead of string paths. fs.FS is a tiny stdlib interface (Open(name string) (fs.File, error)), and fs.ReadFile, fs.WalkDir, fs.Glob, and fs.Sub all take one. In production, pass os.DirFS("/path"). In tests, pass fstest.MapFS{} — an in-memory fake with no disk touched, no temp dirs, no cleanup. For embedded assets, embed.FS already satisfies fs.FS, so the same code path works for on-disk files, embedded resources, and hermetic tests.

Tradeoffs: the interface is read-only. If your package also writes files, keep the write path separate (accept a writer or a directory path for writes). That's a clean split, not a leak — read and write really are different concerns.

When not to use: for purely invocation-level file reads at a runFunc boundary (e.g., cmd foo --config /path/to/file), parsing the path and handing the parsed value down is fine. The seam is for packages that do non-trivial filesystem work internally.

Design

package assets

import "io/fs"

type Loader struct {
    FS fs.FS
}

func (l *Loader) Load(name string) ([]byte, error) {
    return fs.ReadFile(l.FS, name)
}

// production wiring
//   loader := &assets.Loader{FS: os.DirFS("/etc/myapp")}
//
// embedded wiring
//   //go:embed defaults/*
//   var defaults embed.FS
//   loader := &assets.Loader{FS: defaults}
//
// test wiring
//   loader := &assets.Loader{FS: fstest.MapFS{
//       "config.toml": &fstest.MapFile{Data: []byte(`key = "value"`)},
//   }}