Tutorial, part 2: persistence and migrations
Continuing from part 1, this section adds a real model, persists it via db.Store, and generates/applies the schema through migrations — no hand-written DDL.
Define a model
Models are plain Go structs. Add a Post type to apps/posts:
// apps/posts/models.go (as of part 2)
package posts
import "time"
type Post struct {
ID int64 `tango:"pk"`
Title string
Body string
CreatedAt time.Time
}
tango:"pk" marks the primary key explicitly — tanGO never guesses based on a field named ID. tango:"unique" and tango:"index" are also available; see the models and tags guide for the full set and supported field kinds.
Wire a store into the app
main.go already opens *sql.DB and constructs a db.Store (from part 1's scaffolding). To let posts's views use it, change the app from a bare struct to a constructor that closes over the store — the same pattern tanGO's own admin app uses:
// apps/posts/app.go (as of part 2)
package posts
import "github.com/angvp/tango"
import "github.com/angvp/tango/db"
func New(store *db.Store) tango.App {
return tango.NewApp("posts", func(registry *tango.Registry) error {
if err := registry.Models().Register(Post{}); err != nil {
return err
}
meta, _ := registry.Models().Get("Post")
return registry.Routes().Include("/posts/", tango.URLs{
tango.Path("GET", "/", listPosts(store, meta), tango.Name("list")),
tango.Path("GET", "/{id}/", getPost(store, meta), tango.Name("detail")),
tango.Path("POST", "/", createPost(store, meta), tango.Name("create")),
})
})
}
Views can't reach the registry themselves — Context deliberately exposes only the request/response surface (Param, Query, Bind, JSON, Redirect, plus raw escape hatches), not the framework's internals. So ModelMeta (looked up once, after registration) and store are both captured by closure when building each route's view, exactly like tanGO's own admin package does internally.
main.go already opens the database named by TANGO_DB_DSN (sqlite://app.db unless you set it) and passes the store it builds to project.Config(store), so the store is in scope where you list your apps. In project/project.go, replace posts.App{} with posts.New(store):
// project/project.go (as of part 2)
func Config(store *db.Store) tango.Config {
config := tango.LoadConfigFromEnv(tango.WithPortFromEnv())
config.InstalledApps = []tango.App{
posts.New(store),
admin.New(store),
}
// ...
(main.go builds store from the Driver and Source that tango.LoadDBConfigFromEnv() returned. Changing which app gets constructed with what is the kind of one-line rewiring the "no hidden setup" principle expects you to do by hand.)
CRUD views
db.Store's Create/Get/List/Update/Delete take the model's ModelMeta (looked up from the registry) and a destination value:
// apps/posts/views.go (as of part 2)
func listPosts(store *db.Store, meta model.ModelMeta) tango.View {
return func(ctx *tango.Context) error {
var posts []Post
if err := store.List(ctx.Context(), meta, db.Query{OrderBy: []string{"-CreatedAt"}}, &posts); err != nil {
return err
}
return ctx.JSON(200, posts)
}
}
func createPost(store *db.Store, meta model.ModelMeta) tango.View {
return func(ctx *tango.Context) error {
var post Post
if err := ctx.Bind(&post); err != nil {
return err
}
if err := store.Create(ctx.Context(), meta, &post); err != nil {
return err
}
return ctx.JSON(201, post)
}
}
(getPost follows the same shape, using store.Get(ctx.Context(), meta, ctx.Param("id"), &post). Remember to add "github.com/angvp/tango/model" to the imports for the model.ModelMeta parameter type.)
db.Storealso exposesQuery/QueryRowraw-SQL escape hatches when the metadata-driven CRUD surface isn't enough — see the persistence guide.
Generate and apply a migration
tango makemigrations diffs your registered models against the replayed state of existing migration files — there's no separate schema snapshot to go stale:
tango makemigrations
# created migrations/0001_auto_20260928023955.go
# created migrations/0002_auto_20260928023955.go
The first run writes two files — one for the admin's own tables (the scaffold installed the admin app) and one for Post. The timestamp in each name keeps them in order.
Inspect them — they're plain Go, not a DSL, expressing typed steps like migration.CreateTable. Apply them:
tango migrate
# applied 2 migrations: admin/0001_auto_20260928023955, posts/0002_auto_20260928023955
Running it again prints no pending migrations.
This records one row per applied migration in a tango_migrations table (app, name, applied_at). Run tango check again to confirm registration still passes, then exercise the new routes:
go run .
curl -X POST http://localhost:8000/posts/ -d '{"title":"Hello","body":"First post"}'
curl http://localhost:8000/posts/
Rolling back
tango migrate down
# rolled back last migration
This runs the migration's generated Down steps and removes its tango_migrations row. Not every migration can be rolled back: one containing a lossy step (DropColumn, DropTable) is marked irreversible, and tango migrate down on it fails explicitly rather than silently discarding data it can't restore.
Part 3 registers Post with the HTML admin, giving you list/create/edit/delete pages for free.
Continue: Tutorial, part 3: admin