Creating an App¶
This guide walks through building a custom app from scratch, using a "notes" app as the example.
The App Interface¶
Every app implements burrow.App:
Name() returns a unique identifier. Register() receives the shared AppConfig with the database, registry, config, and layouts.
Step 1: Define the Model¶
package notes
import (
"time"
"github.com/uptrace/bun"
)
type Note struct {
bun.BaseModel `bun:"table:notes,alias:n"`
ID int64 `bun:",pk,autoincrement" json:"id"`
UserID int64 `bun:",notnull" json:"user_id"`
Title string `bun:",notnull" json:"title"`
Content string `bun:",notnull,default:''" json:"content"`
CreatedAt time.Time `bun:",nullzero,notnull,default:current_timestamp" json:"created_at"`
DeletedAt time.Time `bun:",soft_delete,nullzero" json:"-"`
}
Key points:
bun.BaseModelwith table name and aliasbun:",soft_delete,nullzero"onDeletedAtenables soft-delete- JSON tags control API serialisation
Step 2: Write the Migration¶
Create migrations/001_create_notes.up.sql:
CREATE TABLE IF NOT EXISTS notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
title TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted_at DATETIME
);
CREATE INDEX IF NOT EXISTS idx_notes_user_id ON notes (user_id);
Embed it in the package:
Step 3: Create the Repository¶
type Repository struct {
db *bun.DB
}
func NewRepository(db *bun.DB) *Repository {
return &Repository{db: db}
}
func (r *Repository) Create(ctx context.Context, note *Note) error {
_, err := r.db.NewInsert().Model(note).Exec(ctx)
return err
}
func (r *Repository) ListByUserID(ctx context.Context, userID int64) ([]Note, error) {
var notes []Note
err := r.db.NewSelect().Model(¬es).
Where("user_id = ?", userID).
Order("created_at DESC").
Scan(ctx)
return notes, err
}
func (r *Repository) Delete(ctx context.Context, noteID, userID int64) error {
_, err := r.db.NewDelete().Model((*Note)(nil)).
Where("id = ? AND user_id = ?", noteID, userID).
Exec(ctx)
return err
}
Step 4: Write the Templates¶
Create 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 }}
Embed the templates:
Step 5: Write the Handlers¶
type Handlers struct {
repo *Repository
}
func NewHandlers(repo *Repository) *Handlers {
return &Handlers{repo: repo}
}
func (h *Handlers) List(w http.ResponseWriter, r *http.Request) error {
user := auth.UserFromContext(r.Context())
if user == nil {
return burrow.NewHTTPError(http.StatusUnauthorized, "not authenticated")
}
notes, err := h.repo.ListByUserID(r.Context(), user.ID)
if err != nil {
return burrow.NewHTTPError(http.StatusInternalServerError, "failed to list notes")
}
return burrow.RenderTemplate(w, r, http.StatusOK, "notes/list", map[string]any{
"Notes": notes,
})
}
func (h *Handlers) Create(w http.ResponseWriter, r *http.Request) error {
user := auth.UserFromContext(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 := h.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.
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¶
type App struct {
repo *Repository
handlers *Handlers
}
func New() *App {
return &App{}
}
func (a *App) Name() string { return "notes" }
func (a *App) Dependencies() []string { return []string{"auth"} } // (1)!
func (a *App) Register(cfg *burrow.AppConfig) error {
a.repo = NewRepository(cfg.DB)
a.handlers = NewHandlers(a.repo)
return nil
}
func (a *App) MigrationFS() fs.FS { // (2)!
sub, _ := fs.Sub(migrationFS, "migrations")
return sub
}
func (a *App) TemplateFS() fs.FS { // (3)!
sub, _ := fs.Sub(templateFS, "templates")
return sub
}
func (a *App) NavItems() []burrow.NavItem { // (4)!
return []burrow.NavItem{
{
Label: "Notes",
URL: "/notes",
Icon: bsicons.JournalText(), // "github.com/oliverandrich/burrow/contrib/bsicons"
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.handlers.List))
r.Post("/", burrow.Handle(a.handlers.Create))
})
}
HasDependencies— ensuresauthis registered before this appMigratable— the framework runs SQL migrations at startupHasTemplates— contributes.htmltemplate files to the global template setHasNavItems— contributes navigation entries to layoutsHasRoutes— registers HTTP handlers on the Chi router
File Layout¶
For multi-file apps, name files by their purpose rather than repeating the package name:
| File | Content |
|---|---|
app.go |
App struct, Name(), Register(), 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¶
In main.go:
srv := burrow.NewServer(
session.New(),
auth.New(),
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 |
|---|---|---|
Migratable |
MigrationFS() fs.FS |
Provide SQL migrations |
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(r *http.Request) template.FuncMap |
Contribute request-scoped template functions |
Configurable |
Flags(configSource func(key string) cli.ValueSource) []cli.Flag + Configure(cmd *cli.Command) error |
Add CLI flags |
HasCLICommands |
CLICommands() []*cli.Command |
Add CLI subcommands |
Seedable |
Seed(ctx context.Context) error |
Seed initial data |
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 |
HasShutdown |
Shutdown(ctx context.Context) error |
Clean up on shutdown |
See Core Interfaces for the full reference.