Skip to content

Templates

gas/template stores raw template content and hands it out by name. It does not parse or render anything: gas/ui renders pages and gas/email renders messages, and both read their templates through gas.TemplateProvider, the interface declared in core. Where templates live (the binary, a directory, a database) is decided where the app is wired, and the renderers never notice.

gas.TemplateProvider has four methods: Get(ctx, name) returns the raw bytes, List(ctx) returns every name, Register(ctx, name, content) adds or replaces one, and RegisterFS(ctx, fsys) registers every template file of an fs.FS. Each store in this module implements it:

Package Store Writes Use it for
template/memory An in-process map Yes Tests, and the writable layer of a composite
template/fs Any fs.FS, such as an embed.FS No Templates shipped in the binary
template/dir A directory on disk, plus an in-memory overlay To the overlay Reading templates from disk without a rebuild
template/db The __gas_templates table on PostgreSQL, MySQL, or SQLite Yes Templates edited at runtime and shared by every instance
template/composite One writable store in front of read-only fallbacks To the writable store Overrides over shipped defaults
template/templatetest MockTemplate Tests of code that takes a gas.TemplateProvider

The module reads no configuration.

Terminal window
go get github.com/gasmod/gas/template

The common case is templates embedded in the binary, served by the fs store:

// embedded holds ./templates, compiled into the binary.
//
//go:embed templates
var embedded embed.FS
main.go
app := gas.NewApp(
gas.WithServiceInstance[gas.ConfigProvider](cfg),
gas.WithSingletonService[gas.Logger](gaslog.NewSlogLogger()),
// Read-only templates from the binary: "home.html",
// "emails/welcome.html", and so on.
gas.WithSingletonService[gas.TemplateProvider](templatefs.NewStore(shipped)),
)

shipped is fs.Sub(embedded, "templates"), so the names are relative to the templates directory. In development, pass os.DirFS("templates") instead and the same code reads from disk.

How each store is built and registered differs:

Store Constructor Resolves from the container Register with
fs templatefs.NewStore(fsys) returns a constructor Nothing gas.WithSingletonService[gas.TemplateProvider]
db templatedb.NewStore(opts...) returns a constructor gas.DatabaseProvider, gas.Logger, gas.MigrationManager gas.WithSingletonService[gas.TemplateProvider]
memory memory.NewStore() returns the store gas.WithServiceInstance[gas.TemplateProvider]
composite composite.NewStore(writable, readers...) returns the store gas.WithServiceInstance[gas.TemplateProvider]
dir templatedir.NewStore(dir) returns a constructor Nothing Cannot be registered; see Directory store

The constructors that return a constructor never fail; the error result is always nil, and it is there for the container’s constructor shape.

The file-based stores and every RegisterFS recognize only files ending in .html, and name each template by its slash-separated path relative to the root, such as layouts/base.html or emails/welcome.html. List on the fs and dir stores returns only those .html names. Get reads whatever path it is given. The memory and db stores keep any name passed to Register.

Code that needs a template takes gas.TemplateProvider and never imports this module’s stores:

// Mailer reads raw template content through the interface and tells a
// missing template apart from a failing store.
type Mailer struct {
templates gas.TemplateProvider
}
func (m *Mailer) Body(ctx context.Context, name string) ([]byte, error) {
content, err := m.templates.Get(ctx, name)
if template.IsNotFound(err) {
return nil, fmt.Errorf("no email template %q", name)
}
if err != nil {
return nil, err
}
return content, nil
}

Every store’s Get returns template.ErrTemplateNotFound for a missing name, and template.IsNotFound(err) reports whether an error wraps it. The fs and dir stores report any failure to read the file, a permission error included, as ErrTemplateNotFound.

The memory store returns the slice it stored, not a copy, so do not modify what Get returns.

composite.NewStore(writable, readers...) puts one writable store in front of any number of fallbacks:

// overridable puts a writable memory store in front of the shipped
// templates. Register writes to the memory store, and Get finds an override
// there before it falls back to the shipped file.
func overridable(shipped fs.FS) (gas.Option, error) {
readOnly, err := templatefs.NewStore(shipped)()
if err != nil {
return nil, err
}
store := composite.NewStore(memory.NewStore(), readOnly)
return gas.WithServiceInstance[gas.TemplateProvider](store), nil
}
  • Register and RegisterFS go to the writable store only.
  • Get tries the writable store, then each reader in order, and returns the first success. When none succeeds it returns ErrTemplateNotFound, whatever the stores’ own errors were, so a failing backend also reads as not found.
  • List merges the names from every store, sorted and deduplicated. A store whose List fails is skipped; the first such error is returned only when no store returned any name.

With a db store as the writable layer instead of memory, edits survive a restart and every instance sees them, while the embedded files stay the fallback.

// databaseStore keeps templates in the __gas_templates table of the SQLite
// file at path. The store's migration is registered with the MigrationManager, so
// gas/migrate must be registered under that interface.
func databaseStore(path string) []gas.Option {
dbCfg := database.DefaultConfig()
dbCfg.Database.Driver = "sqlite"
dbCfg.Database.DSN = path
return []gas.Option{
gas.WithSingletonService[gas.DatabaseProvider](database.New(database.WithConfig(dbCfg))),
gas.WithSingletonService[gas.MigrationManager](migrate.New()),
gas.WithSingletonService[gas.TemplateProvider](
templatedb.NewStore(templatedb.WithNamespace("marketing")),
),
// Migrations run after every service has started, so seed the
// table from a ready function, not from another service's Init.
gas.WithReadyFunc(func(c *gas.ServiceContainer) error {
store, err := c.Resolve[gas.TemplateProvider]()
if err != nil {
return err
}
return store.Register(context.Background(), "banner.html", []byte("<p>Spring sale</p>"))
}),
}
}

templatedb.NewStore keeps templates in the __gas_templates table. When the store starts, it picks the queries for the database provider’s driver: postgres or pgx, mysql, sqlite or sqlite3. Any other driver fails startup with gas/template/db: unsupported driver. It then registers the migration that creates the table (version 20260322235959, “create templates table”) with the gas.MigrationManager, so register gas/migrate under that interface.

On MySQL the DSN needs two parameters. multiStatements=true, because the MySQL migration file holds two statements (the CREATE TABLE and a CREATE INDEX) and gas/migrate runs a file as one statement batch; without it the migration fails with a syntax error (Error 1064) at the CREATE INDEX (KI-049). And parseTime=true, which gas/migrate needs on MySQL; see Migrations.

templatedb.WithNamespace(ns) scopes every query to a namespace, "default" when not given, so stores with different namespaces share the table without seeing each other’s templates. Register upserts by namespace and name, and List returns the namespace’s names sorted.

*templatedb.Store has two methods beyond the interface. Exists(name) reports whether a template exists, and Delete(name) removes one and returns ErrTemplateNotFound when there was none. Both run on context.Background() rather than taking a context. Database errors from every method are wrapped with the operation and, where there is one, the template name, as in template: get "home.html": ....

templatedir.NewStore(dir) reads templates from a directory on disk. The directory is opened as an os.Root on the first Get or List that reaches disk, which confines reads to it. Register and RegisterFS write to an in-memory overlay, never to disk, and Get checks the overlay before the disk. If opening the directory fails, the Get or List that tried and every later one that reaches disk return that error: List always does, even when the overlay has names, and Get does for a name not in the overlay. Register still succeeds, and Get of an overlay name still returns its content. List returns the .html files on disk and the overlay’s names, sorted and deduplicated. If walking the directory fails, List stops there and returns what it found without an error.

// directoryStore serves templates from ./templates on disk. A dir.Store
// cannot be registered in the container (KI-033), so build it yourself,
// use it as a value, and close it yourself.
func directoryStore() (gas.TemplateProvider, func() error, error) {
onDisk, err := templatedir.NewStore("templates")()
if err != nil {
return nil, nil, err
}
return composite.NewStore(onDisk), onDisk.Close, nil
}

Wrapping the store in a composite gives the container a value with no Close, so registering the result with gas.WithServiceInstance[gas.TemplateProvider] works. Close releases the root and drops the overlay, waiting for reads in flight; a second Close returns nil. After Close, Get, List, Register, and RegisterFS return template.ErrClosed, and the store cannot be opened again.

Error Returned by When
template.ErrTemplateNotFound Get on every store; templatedb.Store.Delete No template has the name
template.ErrReadOnly Register and RegisterFS on the fs store Always
template.ErrClosed Get, List, Register, RegisterFS on the dir store After Close
gas/template/db: unsupported driver: "..." db store startup The database driver is not one the store supports

template.IsNotFound(err) is errors.Is(err, template.ErrTemplateNotFound). On the memory, dir, and db stores, RegisterFS wraps a read or register failure with the file’s path, under template: register fs:. Through a composite, Register errors are wrapped under composite: register:, so errors.Is(err, template.ErrReadOnly) still matches.

No store implements gas.HealthReporter or gas.ReadyReporter. The db store’s health is the database’s: see Database.

templatetest.MockTemplate implements gas.TemplateProvider. Each method records the call in Calls and delegates to GetFn, ListFn, RegisterFn, or RegisterFSFn when set. Unset, every method returns zero values: Get returns nil content and a nil error, not ErrTemplateNotFound. CallCount(method) counts the calls to one method, and Reset clears them.

mailer_test.go
func TestMailerReportsMissingTemplate(t *testing.T) {
t.Parallel()
mock := &templatetest.MockTemplate{
GetFn: func(context.Context, string) ([]byte, error) {
return nil, template.ErrTemplateNotFound
},
}
m := &Mailer{templates: mock}
if _, err := m.Body(context.Background(), "welcome.html"); err == nil {
t.Fatal("Body: want an error for a missing template")
}
if got := mock.CallCount("Get"); got != 1 {
t.Fatalf("Get calls = %d, want 1", got)
}
}

When the test needs real content rather than call records, a memory store filled with Register is often simpler.