Creating an App¶
This guide walks through building a custom app from scratch, using a "notes" app as the example.
Skip the boilerplate with burrow generate app
For a minimal stub with app.go, app_test.go, and a starter
template, run:
Output lands at ./internal/notes/ (override with --path). The
stub gives you the App struct, a registered route, and an
embedded template — you fill in the model, repository, and
handlers below. See CLI reference
for all flags.
The App Interface¶
Every app implements burrow.App:
Name() returns a unique identifier. Apps that need setup (database access, flag values) implement Configurable — see Configuration.
Step 1: Define the Model¶
models.go:
package notes
import (
"time"
"github.com/oliverandrich/den/document"
)
type Note struct {
document.Base
UserID string `json:"user_id"`
Title string `json:"title" den:"index"`
Content string `json:"content"`
CreatedAt time.Time `json:"created_at"`
}
Key points:
document.Baseprovides ULID-based ID, revision, and timestampsjsontags control serialization,dentags add indexes
Step 2: Register Documents¶
Apps declare their document types by implementing HasDocuments. Den creates tables and indexes automatically on startup — no SQL migration files needed.
In app.go:
//go:embed templates/*.html
var templateFS embed.FS
//go:embed translations
var translationFS embed.FS
type App struct {
repo *Repository
}
func New() *App { return &App{} }
func (a *App) Name() string { return "notes" }
// Documents tells Burrow which document types this app uses.
// Tables and indexes are created automatically on startup.
func (a *App) Documents() []document.Document {
return []document.Document{&Note{}}
}
Step 3: Create the Repository¶
repository.go:
type Repository struct {
db *den.DB
}
func NewRepository(db *den.DB) *Repository {
return &Repository{db: db}
}
func (r *Repository) Create(ctx context.Context, note *Note) error {
return den.Save(ctx, r.db, note)
}
func (r *Repository) ListByUserID(ctx context.Context, userID string) ([]Note, error) {
return den.NewQuery[Note](r.db,
where.Field("user_id").Eq(userID),
).Sort("created_at", den.Desc).All(ctx)
}
func (r *Repository) Delete(ctx context.Context, noteID, userID string) error {
note, err := den.NewQuery[Note](r.db,
where.Field("id").Eq(noteID),
where.Field("user_id").Eq(userID),
).First(ctx)
if err != nil {
return err
}
return den.Delete(ctx, r.db, note)
}
Step 4: Write the Templates¶
templates/list.html:
{{ define "notes/list" -}}
<h1>{{ t "notes-title" }}</h1>
<ul>
{{ range .Notes }}
<li><a href="/notes/{{ .ID }}">{{ .Title }}</a></li>
{{ end }}
</ul>
{{- end }}
The {{ t "notes-title" }} call uses the t template function provided by the i18n system. It looks up a translation key from your app's TOML files. If you don't need i18n, use a plain string instead: <h1>My Notes</h1>.
Step 5: Write the Handlers¶
handlers.go:
import (
"net/http"
"github.com/oliverandrich/burrow"
"github.com/oliverandrich/burrow/contrib/auth"
)
func (a *App) List(w http.ResponseWriter, r *http.Request) error {
user := auth.CurrentUser[auth.EmptyProfile](r.Context())
if user == nil {
return burrow.NewHTTPError(http.StatusUnauthorized, "not authenticated")
}
notes, err := a.repo.ListByUserID(r.Context(), user.ID)
if err != nil {
return burrow.NewHTTPError(http.StatusInternalServerError, "failed to list notes")
}
return burrow.Render(w, r, http.StatusOK, "notes/list", map[string]any{
"Notes": notes,
})
}
func (a *App) Create(w http.ResponseWriter, r *http.Request) error {
user := auth.CurrentUser[auth.EmptyProfile](r.Context())
if user == nil {
return burrow.NewHTTPError(http.StatusUnauthorized, "not authenticated")
}
var req struct {
Title string `form:"title" validate:"required"`
Content string `form:"content"`
}
if err := burrow.Bind(r, &req); err != nil {
return err // (1)!
}
note := &Note{
UserID: user.ID,
Title: req.Title,
Content: req.Content,
}
if err := a.repo.Create(r.Context(), note); err != nil {
return burrow.NewHTTPError(http.StatusInternalServerError, "failed to create note")
}
http.Redirect(w, r, "/notes", http.StatusSeeOther)
return nil
}
Binddecodes the request body and validates it. Returns a*burrow.ValidationErrorwhen validation fails — see Validation.
Handlers are methods on *App
Handlers are defined as methods on your *App struct, giving them direct access to all dependencies (repositories, services, config) without extra wiring. No separate Handlers struct needed.
How Handle() processes errors
See the Routing guide for details on how burrow.Handle() converts returned errors to HTTP responses.
Step 6: Assemble the App¶
app.go:
type App struct {
repo *Repository
}
func New() *App {
return &App{}
}
func (a *App) Name() string { return "notes" }
func (a *App) Dependencies() []string { return []string{"auth"} } // (1)!
func (a *App) Configure(cfg *burrow.AppConfig, _ *cli.Command) error {
a.repo = NewRepository(cfg.DB)
return nil
}
func (a *App) Documents() []document.Document { // (2)!
return []document.Document{&Note{}}
}
func (a *App) TemplateFS() fs.FS { // (3)!
sub, _ := fs.Sub(templateFS, "templates")
return sub
}
func (a *App) TranslationFS() fs.FS { return translationFS } // (6)!
func (a *App) NavItems() []burrow.NavItem { // (4)!
return []burrow.NavItem{
{
Label: "Notes",
URL: "/notes",
Icon: "notes/icon_journal_text", // see Navigation › Icon convention
Position: 20,
AuthOnly: true,
},
}
}
func (a *App) Routes(r chi.Router) { // (5)!
r.Route("/notes", func(r chi.Router) {
r.Use(auth.RequireAuth())
r.Get("/", burrow.Handle(a.List))
r.Post("/", burrow.Handle(a.Create))
})
}
HasDependencies— ensuresauthis registered before this appHasDocuments— the framework registers document types and creates collections at startupHasTemplates— contributes.htmltemplate files to the global template set. Usesfs.Sub()to strip thetemplates/prefix.HasNavItems— contributes navigation entries to layouts. Entries withAuthOnly: trueare only shown to authenticated users — this filtering is handled automatically by the layout.HasRoutes— registers HTTP handlers on the Chi routerHasTranslations— contributes TOML translation files. Returns theembed.FSdirectly (notfs.Sub) because the i18n loader expects thetranslations/directory to be present.
File Layout¶
For multi-file apps, name files by their purpose rather than repeating the package name:
| File | Content |
|---|---|
app.go |
App struct, Name(), Configure(), Routes(), framework wiring |
context.go |
Package doc comment, context key types, context helpers |
handlers.go |
HTTP handlers |
middleware.go |
Middleware functions |
models.go |
Domain models |
repository.go |
Data access layer |
templates/ |
HTML template files ({{ define "appname/..." }}) |
Small apps can keep everything in app.go — split only when a file grows large or mixes distinct responsibilities.
Step 7: Register the App¶
cmd/server/main.go:
srv := burrow.NewServer(
session.New(),
auth.New[auth.EmptyProfile](),
healthcheck.New(),
notes.New(), // Add your app here
)
Auto-sorting
NewServer automatically sorts apps by their HasDependencies declarations. You can list them in any order, and the framework will ensure dependencies are registered first.
Optional Interfaces¶
Your app can implement any combination of these interfaces:
| Interface | Method | Purpose |
|---|---|---|
HasDocuments |
Documents() []document.Document |
Register document types |
HasRoutes |
Routes(r chi.Router) |
Register HTTP handlers |
HasMiddleware |
Middleware() []func(http.Handler) http.Handler |
Add global middleware |
HasNavItems |
NavItems() []burrow.NavItem |
Contribute navigation entries |
HasTemplates |
TemplateFS() fs.FS |
Contribute HTML template files |
HasFuncMap |
FuncMap() template.FuncMap |
Contribute static template functions |
HasRequestFuncMap |
RequestFuncMap(ctx context.Context) template.FuncMap |
Contribute context-scoped template functions |
HasFlags |
Flags(configSource func(key string) cli.ValueSource) []cli.Flag |
Add CLI flags |
Configurable |
Configure(cfg *AppConfig, cmd *cli.Command) error |
App initialisation and configuration |
HasCLICommands |
CLICommands() []*cli.Command |
Add CLI subcommands |
HasMigrations |
Migrations() []NamedMigration |
Versioned, run-once database migrations applied at boot |
HasDependencies |
Dependencies() []string |
Declare required apps |
HasAdmin |
AdminRoutes(r chi.Router) + AdminNavItems() []NavItem |
Contribute admin panel |
HasStaticFiles |
StaticFS() (prefix string, fsys fs.FS) |
Contribute static assets |
HasTranslations |
TranslationFS() fs.FS |
Contribute translation files |
HasJobs |
RegisterJobs(q Queue) |
Register background job handlers |
PostConfigurable |
PostConfigure(cfg *AppConfig, cmd *cli.Command) error |
Second-pass configuration after all apps are configured |
HasShutdown |
Shutdown(ctx context.Context) error |
Clean up on shutdown |
See Core Interfaces for the full reference.
Example: Writing Middleware¶
Middleware follows the standard Go pattern — func(http.Handler) http.Handler. Return it from Middleware() to apply it globally:
// Middleware adds a request timing header to all responses.
func (a *App) Middleware() []func(http.Handler) http.Handler {
return []func(http.Handler) http.Handler{
func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
w.Header().Set("X-Response-Time", time.Since(start).String())
})
},
}
}
Middleware from all apps runs in dependency-sorted order — apps registered earlier in NewServer() that have no dependency conflicts have their middleware applied first. Within a single app, middleware runs in the slice order returned by Middleware().