Skip to content

10-Minute Tour

A linear walkthrough that gets you from go get to a working app exercising every major Den feature once: types, registration, CRUD, queries, soft-delete, relations, hooks, and indexes. Read it top-to-bottom; each section assumes the previous ones.

The example builds a tiny task tracker — Project documents that link to Task documents, with hooks, validation, soft delete, and an indexed status field.


1. Project setup

mkdir tracker && cd tracker
go mod init tracker
go get github.com/oliverandrich/den@latest

Drop the following into main.go. The whole file compiles and runs end-to-end; later sections add code on top of what's already there.

package main

import (
    "context"
    "errors"
    "fmt"
    "log"

    "github.com/oliverandrich/den"
    _ "github.com/oliverandrich/den/backend/sqlite" // register sqlite:// scheme
    "github.com/oliverandrich/den/document"
    "github.com/oliverandrich/den/where"
)

func main() {
    ctx := context.Background()
    _ = ctx
}

Run it (go run .) to confirm the toolchain and dependency wiring work. We'll fill in main step by step.


2. Define and register the document types

A document is a Go struct that embeds document.Base. The den tag carries metadata (indexes, uniqueness, full-text); the json tag carries the field name. Composable embeds add features: document.SoftDelete opts the type into soft delete.

type Project struct {
    document.Base
    Name string `json:"name" den:"unique"` // unique across all Projects
}

type Task struct {
    document.Base
    document.SoftDelete                                       // adds DeletedAt + filtered queries
    ProjectLink den.Link[Project] `json:"project"`            // typed reference
    Title       string            `json:"title"`
    Status      string            `json:"status" den:"index"` // indexed for status filters
}

Open the database and register both types in one expression:

db, err := den.OpenURL(ctx, "sqlite:///tracker.db",
    den.WithTypes(&Project{}, &Task{}),
)
if err != nil { log.Fatal(err) }
defer db.Close()

Registration creates the project and task tables, the unique index on Project.Name, the secondary index on Task.Status, and the soft-delete machinery on Task. It's idempotent — calling on every startup is safe.


3. Add a hook

Any document can implement hook interfaces to run code at lifecycle points. They're plain Go methods on the type — no registration, no decorators.

func (t *Task) BeforeInsert(ctx context.Context) error {
    if t.Status == "" {
        t.Status = "todo" // default if the caller didn't set it
    }
    return nil
}

func (t *Task) Validate(ctx context.Context) error {
    if t.Title == "" {
        return errors.New("task title is required")
    }
    return nil
}

BeforeInsert runs before validation, so a missing title still fails (Validate runs on the post-hook state). Den has a hook for every CRUD verb plus Validator, BeforeSaver, AfterSaver, and soft-delete pairs — see Lifecycle Hooks for the full list and ordering.


Link[T] is a typed reference to another collection. It serializes as just the target's ID string (the JSON key follows the struct's json tag — here "project"). den.NewLink(parent) extracts the parent's ID into a fresh Link[Project].

proj := &Project{Name: "Den Docs"}
if err := den.Save(ctx, db, proj); err != nil { log.Fatal(err) }

task := &Task{
    ProjectLink: den.NewLink(proj),
    Title:       "Write 10-minute tour",
}
if err := den.Save(ctx, db, task); err != nil { log.Fatal(err) }

fmt.Println("created", proj.ID, task.ID, "status:", task.Status)

Both saves take the insert branch because the structs were just constructed with empty IDs — Save generates a ULID and stamps CreatedAt. The BeforeInsert hook populated task.Status to "todo"; Validate accepted the non-empty title.

Re-running the program will fail at the Project save: Project.Name is tagged den:"unique", so the second "Den Docs" collides and returns ErrDuplicate. Make the name dynamic (timestamp suffix, random value) or wrap the seed in a one-time setup if you want repeat-safe runs.

To dereference the link later, either fetch the target separately or hydrate via WithFetchLinks() (next section).


Find all open tasks for a project, with the linked Project hydrated:

tasks, err := den.NewQuery[Task](db,
    where.Field("project").Eq(proj.ID),
    where.Field("status").Eq("todo"),
).WithFetchLinks().Sort(den.FieldCreatedAt, den.Desc).All(ctx)
if err != nil { log.Fatal(err) }

for _, t := range tasks {
    fmt.Printf("- [%s] %s (project: %s)\n", t.Status, t.Title, t.ProjectLink.Value.Name)
}

Without WithFetchLinks(), task.ProjectLink.Value is nil (only task.ProjectLink.ID is populated). The den:"index" tag on Status makes the second condition use a real index instead of a JSONB scan.

For streaming over large result sets, swap .All(ctx) for .Iter(ctx) — Den returns a Go 1.23 iter.Seq2[*T, error] that releases memory as you go.


6. Update one field atomically

Save covers the load-mutate-save case (e.g. fetch a task, change three fields, save again). For changing one or two specific fields without the read round-trip — and without racing other writers — reach for QuerySet.UpdateOne:

done, err := den.NewQuery[Task](db, where.Field(den.FieldID).Eq(task.ID)).
    UpdateOne(ctx, den.SetFields{"status": "done"})
if err != nil { log.Fatal(err) }
fmt.Println("marked done:", done.Title, "→", done.Status)

Rule of thumb: use Save when you have a *T in hand and want to persist its current state; use UpdateOne when you want to flip specific fields atomically on a row identified by ID or by predicate. Same for UpsertOne and GetOrCreate — see CRUD Operations.

SetFields keys are JSON tag names ("status", not "Status"). Mistakes are caught before the write opens — see the Recipes page for more single-purpose patterns.


7. Soft-delete and querying deleted rows

Because Task embeds document.SoftDelete, Delete flips DeletedAt instead of removing the row, and queries auto-filter the deleted row out. To see deleted rows, opt in via IncludeDeleted():

if err := den.Delete(ctx, db, done); err != nil { log.Fatal(err) }

active, _ := den.NewQuery[Task](db).Count(ctx)
all, _    := den.NewQuery[Task](db).IncludeDeleted().Count(ctx)
fmt.Printf("active: %d, including deleted: %d\n", active, all)

If you actually want the row gone, pass den.HardDelete() to Delete. To clean up cascades (deleted parent → linked children), pass den.WithLinkRule(den.LinkDelete). See Soft Delete and Relations.


8. Inspect indexes

Den's secondary indexes are real SQL indexes. Meta returns the runtime metadata for any registered type:

meta, _ := den.Meta[Task](db)
for _, idx := range meta.Indexes {
    fmt.Printf("- %s: %v (unique=%v)\n", idx.Name, idx.Fields, idx.Unique)
}

You'll see the index Den auto-created for Status plus the soft-delete filter index. Add more by tagging more fields, or use unique_together/index_together for composite indexes. Drift between code and DB can be caught with the stale-index sweep.


You shipped a working app

That's the full surface in ten minutes:

  • Types and registration: §2
  • Lifecycle hooks and validation: §3
  • Save + typed relations: §4
  • Queries with eager links + indexes: §5
  • Atomic field updates: §6
  • Soft delete: §7
  • Index inspection: §8

From here:

  • CRUD Operations for the rest of the write surface (SaveAll, UpdateOne, UpsertOne, GetOrCreate, DeleteAll, …)
  • Queries for cursor pagination, projections, aggregations, FTS
  • Relations for cascade rules, BackLinks, nested fetch
  • Hooks for the full hook ordering across paths
  • Recipes for copy-paste patterns
  • Reference / Struct Tags for every den: value with its context