A useful Go pattern appears whenever files are part of application composition rather than merely data on disk: accept an fs.FS, recursively discover files, give them stable logical names, validate them, and assemble them into a larger runtime object.

For HTML templates, I will call the single-filesystem form walkfs and the multi-source form walkmultifs.

The walking code itself is small. The interesting part is getting the Go design around it right: ownership, package dependencies, template names, collision handling, runtime overrides, function maps, testing, and application lifecycle.

This article builds that design from first principles. The goal is not the shortest possible loader, but the version of the pattern I would want copied into a new Go codebase.

Start with the standard library

The useful primitives already exist:

  • fs.FS is the filesystem boundary.
  • fs.Sub selects a subtree when necessary.
  • fs.WalkDir gives recursive discovery over any fs.FS.
  • go:embed produces an embedded filesystem without changing the consumer.
  • html/template provides the associated template set and contextual escaping.
  • fstest.MapFS makes the same code easy to test without the host filesystem.

That gives the first design rule:

Do not invent a filesystem framework when fs.FS is already the interface the consumer needs.

A template compiler can accept fs.FS values directly and return a concrete *template.Template.

When ParseFS is already enough

Before writing a walker, consider html/template.ParseFS.

For a small fixed tree with simple naming rules, it may already be the best answer. A custom walk becomes useful when discovery itself has policy attached to it, for example:

  • recurse to arbitrary depth,
  • preserve relative paths as logical names,
  • add a logical prefix to a source without changing its filesystem,
  • filter files,
  • attach source provenance to errors,
  • enforce ownership of extra named templates,
  • reject collisions rather than relying on parse order,
  • compose several independently owned filesystems.

The last few points are what turn a convenience helper into an architectural boundary.

The filename should normally be the template name

The simplest convention is one file equals one template.

If a source contains:

1card.gohtml
2edit-form.gohtml
3pages/edit.gohtml

then those file bodies should be usable directly as templates. There is no need to wrap every file in {{ define ... }} merely to give it a name.

For example, card.gohtml can simply contain:

1<article class="card">
2    <a href="{{ .URL }}">{{ .Title }}</a>
3</article>

If the compiler publishes that file as links/card.gohtml, another template can invoke it directly:

1{{ template "links/card.gohtml" .Link }}

The file path is already a useful, stable identity.

Explicit define or block declarations remain useful when one file deliberately creates additional associated templates, but they should not be required for the ordinary one-file-one-template case.

Two ways to get the logical path

There are two useful layouts. Both can produce the same runtime name:

1links/card.gohtml

The difference is whether links/ exists physically or is added at composition time.

Variant 1: flat package resources with a virtual prefix

Keeping a component’s resource package flat is convenient:

1internal/links/web/templates/
2    embed.go
3    card.gohtml
4    edit-form.gohtml

The filesystem exposed by that package contains:

1card.gohtml
2edit-form.gohtml

At composition time, give the source a virtual prefix:

1templatefs.Source{
2    Name:   "links templates",
3    Prefix: "links",
4    FS:     linktemplates.FS(),
5}

The compiler reads:

1card.gohtml

but publishes it as:

1links/card.gohtml

This is effectively an AddPrefix operation on the template name, not on the filesystem itself:

1logicalName := path.Join(src.Prefix, p)

There is no standard-library inverse of fs.Sub that adds a directory in front of an arbitrary filesystem. More importantly, one is not needed here. The read path and the published template name are separate concerns.

fs.Sub changes how a caller addresses files in an fs.FS. A virtual template prefix changes only how the parsed file is named in the template set.

If some unrelated consumer genuinely needs an fs.FS which itself appears underneath an added directory, a small filesystem wrapper may make sense. For template compilation alone, that would be extra machinery for no benefit.

Variant 2: put the namespace in the actual directory tree

The simpler naming implementation is to make the desired runtime name the actual filesystem path:

1internal/links/web/templates/
2    embed.go
3    files/
4        links/
5            card.gohtml
6            edit-form.gohtml

The package can expose an FS containing those paths:

 1package templates
 2
 3import (
 4    "embed"
 5    "io/fs"
 6)
 7
 8//go:embed files
 9var embedded embed.FS
10
11func FS() fs.FS {
12    sub, err := fs.Sub(embedded, "files")
13    if err != nil {
14        panic(err)
15    }
16    return sub
17}

Now the walker sees:

1links/card.gohtml

and can publish p unchanged.

An application which is happy to own one physical template tree can go further:

1templates/
2    shared/
3        layout.gohtml
4        pager.gohtml
5    links/
6        card.gohtml
7        edit-form.gohtml
8    images/
9        image.gohtml

After an fs.Sub(embedded, "templates"), every path is already its final logical template name. A single walk is enough and no virtual-prefix feature is required at all.

This is the simplest implementation. The trade-off is physical ownership: a central tree is less attractive when templates should live beside independently owned packages. Putting the namespace directory inside each leaf resource package keeps local ownership but adds one redundant directory level.

So the choice is mostly structural:

1flat local package + Prefix  -> less physical nesting, composition adds identity
2physical namespace directory -> source path is runtime identity, simpler compiler

Both are valid. The compiler can support both without changing the template API.

A compiler which supports both variants

A small source description is enough:

  1package templatefs
  2
  3import (
  4    "fmt"
  5    "html/template"
  6    "io/fs"
  7    "path"
  8    "sort"
  9    "strings"
 10)
 11
 12type Source struct {
 13    // Name is for diagnostics only.
 14    Name string
 15
 16    // Prefix is optional. When non-empty, it is added to the filesystem
 17    // path when choosing the logical template name.
 18    Prefix string
 19
 20    FS fs.FS
 21}
 22
 23type origin struct {
 24    Source string
 25    File   string
 26}
 27
 28func Compile(funcs template.FuncMap, sources ...Source) (*template.Template, error) {
 29    out := template.New("root").Funcs(funcs)
 30    owners := map[string]origin{}
 31
 32    for _, src := range sources {
 33        if src.FS == nil {
 34            return nil, fmt.Errorf("template source %q has a nil filesystem", src.Name)
 35        }
 36        if src.Prefix != "" && (src.Prefix == "." || !fs.ValidPath(src.Prefix)) {
 37            return nil, fmt.Errorf("template source %q has invalid prefix %q", src.Name, src.Prefix)
 38        }
 39
 40        err := fs.WalkDir(src.FS, ".", func(p string, d fs.DirEntry, walkErr error) error {
 41            if walkErr != nil {
 42                return fmt.Errorf("walk %s:%s: %w", src.Name, p, walkErr)
 43            }
 44            if d.IsDir() || path.Ext(p) != ".gohtml" {
 45                return nil
 46            }
 47
 48            b, err := fs.ReadFile(src.FS, p)
 49            if err != nil {
 50                return fmt.Errorf("read %s:%s: %w", src.Name, p, err)
 51            }
 52
 53            logicalName := p
 54            if src.Prefix != "" {
 55                logicalName = path.Join(src.Prefix, p)
 56            }
 57
 58            parsed, err := template.New(logicalName).Funcs(funcs).Parse(string(b))
 59            if err != nil {
 60                return fmt.Errorf("parse %s:%s as %q: %w", src.Name, p, logicalName, err)
 61            }
 62
 63            candidates := parsed.Templates()
 64            sort.Slice(candidates, func(i, j int) bool {
 65                return candidates[i].Name() < candidates[j].Name()
 66            })
 67
 68            ownerPrefix := src.Prefix
 69            if ownerPrefix == "" {
 70                ownerPrefix = firstSegment(logicalName)
 71            }
 72
 73            for _, candidate := range candidates {
 74                if candidate.Tree == nil {
 75                    continue
 76                }
 77
 78                name := candidate.Name()
 79                if !fs.ValidPath(name) {
 80                    return fmt.Errorf("%s:%s defines invalid template name %q", src.Name, p, name)
 81                }
 82
 83                // The file-derived template name is always allowed. Extra names
 84                // created by define/block must remain in the same namespace.
 85                if name != logicalName {
 86                    if ownerPrefix == "" || !inNamespace(name, ownerPrefix) {
 87                        return fmt.Errorf(
 88                            "%s:%s defines template %q outside namespace %q",
 89                            src.Name, p, name, ownerPrefix,
 90                        )
 91                    }
 92                }
 93
 94                if previous, exists := owners[name]; exists {
 95                    return fmt.Errorf(
 96                        "template %q defined by both %s:%s and %s:%s",
 97                        name, previous.Source, previous.File, src.Name, p,
 98                    )
 99                }
100
101                if _, err := out.AddParseTree(name, candidate.Tree); err != nil {
102                    return fmt.Errorf("add template %q from %s:%s: %w", name, src.Name, p, err)
103                }
104
105                owners[name] = origin{Source: src.Name, File: p}
106            }
107            return nil
108        })
109        if err != nil {
110            return nil, err
111        }
112    }
113
114    return out, nil
115}
116
117func firstSegment(name string) string {
118    if i := strings.IndexByte(name, '/'); i >= 0 {
119        return name[:i]
120    }
121    return ""
122}
123
124func inNamespace(name, prefix string) bool {
125    return name == prefix || strings.HasPrefix(name, prefix+"/")
126}

This is deliberately ordinary Go. It is mostly standard-library glue.

The important line for the virtual-prefix variant is simply:

1logicalName = path.Join(src.Prefix, p)

With an empty prefix, the actual filesystem path is preserved.

Using the two variants

A flat local source:

1card.gohtml
2edit-form.gohtml

can be composed as:

 1tmpl, err := templatefs.Compile(funcs,
 2    templatefs.Source{
 3        Name:   "links",
 4        Prefix: "links",
 5        FS:     linktemplates.FS(),
 6    },
 7    templatefs.Source{
 8        Name:   "images",
 9        Prefix: "images",
10        FS:     imagetemplates.FS(),
11    },
12)

The resulting names include:

1links/card.gohtml
2links/edit-form.gohtml
3images/image.gohtml

A physical tree which already contains namespaces:

1shared/layout.gohtml
2shared/pager.gohtml
3links/card.gohtml
4links/edit-form.gohtml
5images/image.gohtml

needs no virtual prefix:

1tmpl, err := templatefs.Compile(funcs,
2    templatefs.Source{
3        Name: "application templates",
4        FS:   templatesFS,
5    },
6)

The resulting names are identical to the source paths.

The two approaches can even be mixed. One source may use a virtual prefix while another already exposes its final paths. Collision checks operate on the resulting logical names, so the final namespace remains deterministic.

What define is for in this model

define is optional, not the naming mechanism for ordinary files.

Prefer:

1links/card.gohtml
2links/edit-form.gohtml

with direct file bodies, then call:

1{{ template "links/card.gohtml" .Link }}
2{{ template "links/edit-form.gohtml" .Form }}

Use define only when one file intentionally contributes another associated template. For example, a namespaced file might contain its main body and an additional helper:

1<section>...</section>
2
3{{ define "links/card/badge" }}
4    <span class="badge">{{ . }}</span>
5{{ end }}

The compiler can allow that extra name because it remains inside the links namespace.

A links file defining:

1{{ define "shared/pager" }}...{{ end }}

is rejected. If the component is truly shared, its ownership should move to the shared template source instead of creating an implicit cross-component overwrite.

If the application never needs multi-template files or block, the policy can be made even stricter: reject every parsed template name except the file-derived logicalName. That is a perfectly reasonable simplification.

Why parse one file at a time?

Per-file parsing is useful even when define is uncommon.

First, the file itself gets a known logical identity before it enters the global set.

Second, if a file does contain define or block, every additional name can be attributed to the file that created it before anything is merged globally.

Third, duplicate file-derived names are detected before one source silently replaces another.

This permits errors such as:

1template "links/card.gohtml" defined by both links-a:card.gohtml and links-b:card.gohtml

rather than discovering later that one happened to win.

walkmultifs is composition, not a union filesystem

walkmultifs does not require a new filesystem implementation. It is the fact that Compile accepts several sources and maps each source path into one logical template namespace.

A union filesystem has to define semantics for:

  • duplicate paths,
  • merged directory listings,
  • source precedence,
  • a file in one source colliding with a directory in another,
  • distinguishing a miss from a source-specific error.

Those are useful questions when building an overlay filesystem. They are not necessary for component composition.

For independently owned sources, a collision should normally fail.

For overrides, precedence should be intentional and implemented one layer earlier.

That gives separate concerns:

1overlay FS       = which physical file a source exposes
2virtual Prefix   = how a source path becomes a logical template path
3source compiler  = which logical templates enter the final set

Keeping those decisions separate makes each easier to reason about.

Let resource packages stay leaf packages

go:embed is package-local, which fits component ownership well.

For the flat virtual-prefix variant, a resource package can be almost empty:

 1package templates
 2
 3import (
 4    "embed"
 5    "io/fs"
 6)
 7
 8//go:embed *.gohtml
 9var embedded embed.FS
10
11func FS() fs.FS {
12    return embedded
13}

It does not need to import the handler that conceptually owns it. Directory nesting does not imply an import relationship in Go.

These can be independent packages:

1internal/links/web
2internal/links/web/templates

The compiler needs an fs.FS, so I also would not introduce a wrapper interface merely for architecture’s sake:

1type TemplateSource interface {
2    FS() fs.FS
3}

fs.FS is already the boundary. If another required behaviour appears later, an interface can be introduced by the consumer then.

Keep the composition root above the components

The final executable, or a nearby application-composition package, is the right place to know which components exist.

A useful dependency graph looks like this:

 1                    cmd/application
 2                   /      |       \
 3                  v       v        v
 4             links/web  images/web  templatefs
 5                |           |
 6                v           v
 7              links       images
 8
 9cmd/application
10    -> links/web/templates
11    -> images/web/templates
12    -> shared/web/templates

The arrows point one way.

The generic compiler does not import the component packages. The components do not import the composition package. The composition package imports the pieces and passes values between them.

That is the important Go property: the import graph stays a DAG.

FAQ: won’t this create import loops?

Not if ownership and composition remain separate.

This is safe:

 1application
 2    -> links/web
 3    -> links/web/templates
 4    -> templatefs
 5
 6links/web
 7    -> links
 8
 9links/web/templates
10    -> embed/io/fs

This is not:

1links/web
2    -> application/templates
3    -> links/web

Nor is this:

1common
2    -> template compiler
3    -> common

The usual cure is not a registry or an init() hook. It is to move assembly upward and pass the completed dependency downward.

If a handler needs templates, the simplest option is often to give it the compiled *template.Template directly.

If tests or multiple rendering implementations make an interface useful, define the narrow interface on the consumer side:

1type Renderer interface {
2    ExecuteTemplate(io.Writer, string, any) error
3}

*template.Template already satisfies that shape.

Do not add an interface solely to make the code look like dependency injection. Passing a concrete dependency from the composition root is dependency injection too.

Prefer associated templates over a custom include

A dependency cycle often appears when a low-level helper tries to rediscover or recompile the global template set while a template is being rendered.

Before introducing a callback, renderer provider, or global template registry, ask whether Go’s associated-template action already expresses the requirement.

With file-derived names:

1{{ template "shared/pager.gohtml" .Pager }}
2{{ template "links/card.gohtml" .Link }}

These operate inside the already-compiled associated template set. They do not require application code to find the compiler again.

Only introduce function-like rendering when its semantics are genuinely different. If that is required, inject a narrow rendering dependency from above rather than importing the application composition package from below.

Template functions need the same discipline

Template names are not the only global namespace. Function names are shared by the template set too.

Funcs must be registered before parsing templates which refer to those names, so the composition root should construct the function map before calling Compile.

If several components contribute functions, merge those maps explicitly and reject duplicate names rather than silently deciding that one component wins.

A small helper is enough:

 1func MergeFuncMaps(maps ...template.FuncMap) (template.FuncMap, error) {
 2    out := template.FuncMap{}
 3    for _, funcs := range maps {
 4        for name, fn := range funcs {
 5            if _, exists := out[name]; exists {
 6                return nil, fmt.Errorf("duplicate template function %q", name)
 7            }
 8            out[name] = fn
 9        }
10    }
11    return out, nil
12}

Prefer recognisably owned function names where ambiguity is likely:

1assetURL
2linksURL
3imageURL
4formatLocalTime

rather than letting unrelated components all register a generic name such as url.

There is also a lifetime issue. A compiled template set is normally application-scoped and executed concurrently. Functions captured into it should be safe for that lifetime.

Request-specific state is usually better passed through the execution data or view model than captured while compiling the application template set.

Compile once, execute many

Treat template compilation as startup work unless runtime editing is an explicit application feature.

A useful sequence is:

  1. construct the complete function map,
  2. construct the effective filesystems,
  3. compile and validate every source,
  4. fail startup if any template is invalid or collides,
  5. pass the completed template set to consumers,
  6. execute it concurrently without mutating it.

This moves template failures from user requests to process startup and keeps application wiring easy to understand.

Runtime overrides remain possible

Local ownership and go:embed do not require giving up development or deployment overrides.

For a virtual-prefix source, replace or overlay the source filesystem before adding its logical prefix:

 1linksFS := linktemplates.FS()
 2if cfg.TemplateDir != "" {
 3    linksFS = os.DirFS(filepath.Join(cfg.TemplateDir, "links"))
 4}
 5
 6templatefs.Source{
 7    Name:   "links",
 8    Prefix: "links",
 9    FS:     linksFS,
10}

For a physical tree, an override directory can simply mirror the same paths:

1templates/
2    shared/
3        pager.gohtml
4    links/
5        card.gohtml

If the requirement is partial override with embedded fallback, an overlay filesystem is appropriate. The compiler should still see one effective fs.FS for that source.

The compiler does not need to know whether a source came from embed.FS, os.DirFS, fstest.MapFS, an overlay, or another implementation.

Cross-source references are still fine

Isolation during parsing does not mean isolation during execution.

A shared layout can invoke a component template by its file-derived name:

1{{ template "links/card.gohtml" .Link }}

and a component page can invoke a shared file:

1{{ template "shared/pager.gohtml" .Pager }}

The compiler merges all accepted parse trees into one associated template set before execution.

The boundary controls who contributes a name, not who may invoke it.

Test both path-mapping variants

fstest.MapFS makes both layouts easy to test.

Virtual prefix:

 1links := fstest.MapFS{
 2    "card.gohtml": {
 3        Data: []byte(`card`),
 4    },
 5}
 6
 7tmpl, err := templatefs.Compile(nil, templatefs.Source{
 8    Name:   "links",
 9    Prefix: "links",
10    FS:     links,
11})
12// tmpl.Lookup("links/card.gohtml") != nil

Physical path:

 1all := fstest.MapFS{
 2    "links/card.gohtml": {
 3        Data: []byte(`card`),
 4    },
 5}
 6
 7tmpl, err := templatefs.Compile(nil, templatefs.Source{
 8    Name: "all templates",
 9    FS:   all,
10})
11// tmpl.Lookup("links/card.gohtml") != nil

Tests should cover at least:

  • recursive discovery,
  • direct physical path naming,
  • virtual-prefix naming,
  • collisions after prefixing,
  • valid file-derived templates with no define,
  • extra define names inside their owning namespace if supported,
  • extra definitions outside their owning namespace,
  • parse errors with source and filename in the error,
  • invalid prefixes,
  • nil filesystems,
  • cross-source {{ template }} calls,
  • duplicate function-map entries if functions are composed,
  • one complete application compile using the production source list,
  • representative renders of top-level templates.

Compile tests prove syntax, mapping, and ownership. Render tests catch missing referenced templates and view-data contract mistakes that a filesystem walk cannot prove by itself.

Migrating an existing application

For an existing application, do not begin by moving every file.

A safer sequence is:

  1. Capture current template-loading and rendering behaviour in tests.
  2. Decide whether the desired logical paths should be represented physically or by virtual prefixes.
  3. Introduce the compiler while still pointing it at the existing resources.
  4. Make the file-derived logical path the default template identity.
  5. Remove unnecessary define wrappers that exist only to rename whole files.
  6. Register all template functions before compilation.
  7. Remove reverse dependencies that rediscover or recompile templates from inside rendering helpers.
  8. Add full-production compile and representative render tests.
  9. Move component resources to leaf packages if local ownership is desired.
  10. Preserve override behaviour by choosing or overlaying the effective filesystem before compilation.

This separates where a file physically lives from what logical template path it publishes.

What to avoid

Requiring define just to name a file

The file already has a path. Use that path unless there is a deliberate reason to introduce another name.

Building a prefixed filesystem when only the template name needs a prefix

If the compiler can read card.gohtml and publish links/card.gohtml, a new fs.FS wrapper is unnecessary.

Package self-registration through init()

A global registry hides the application’s dependency list and makes alternate assemblies and tests harder to understand. Explicit composition keeps imports visible.

A central compiler package that imports every component

The application composition layer is allowed to know every component. A low-level compiler should not.

Last-one-wins component collisions

Replacement is useful for deliberate overlays. It is a poor default for two independent owners claiming the same logical template name. Fail early instead.

Wrapper interfaces around fs.FS for architecture’s sake

fs.FS is already the consumer boundary. Keep it until another behaviour genuinely needs abstraction.

Request-scoped template compilation

If templates are application resources, compile them once. Pass request state as data and keep the compiled set immutable while serving.

Combining override, physical layout, and logical naming semantics

These are separate questions:

1where is the file?       -> fs.FS / overlay
2what is it called?       -> path or Prefix + path
3who contributes it?      -> Source / composition root

Keeping them separate is the main simplification.

The architectural point

The implementation remains small:

 1component resource packages
 2        |
 3        v
 4   standard fs.FS values
 5        |
 6        +---- physical path retained ---------+
 7        |                                     |
 8        +---- or virtual Prefix + path -------+
 9                                              v
10                                  strict template compiler
11                                              |
12                                              v
13                             one associated template set
14                                              |
15                                              v
16                              consumers receive templates

walkfs is recursive discovery over an abstract filesystem.

walkmultifs is explicit composition of several independently owned sources.

A virtual prefix is just a mapping from source path to logical template path. A physical namespace directory makes that mapping the identity function.

The important part is not inventing a clever multi-filesystem object. It is preserving useful ownership while assembling one runtime template namespace with ordinary Go tools: fs.FS, go:embed, html/template, path composition, explicit imports, and startup compilation.

That is the version of the pattern I would want to copy into a new Go project.

References