Skip to content
latest
The Basics / latest

Controllers

In v2 the HTTP boundary is a handler. There is no controller base type, no Echo context and no controller generator: a handler is a Go type in the flat handlers package with methods that take a context.Context and return an error.

A handler is a function over a context

1type Handler func(ctx context.Context) error

Request data and response access travel on the context through pkg/anduin. Dependencies never do. A handler returns nil on success or an error; a returned error goes to the router's error handler.

Declare dependencies and routes

Give each handler a distinct constructor and register its routes in Register:

 1type ProductHandler struct {
 2	products domains.Products
 3	renderer *inertia.Renderer
 4}
 5
 6func NewProductHandler(products domains.Products, renderer *inertia.Renderer) ProductHandler {
 7	return ProductHandler{products: products, renderer: renderer}
 8}
 9
10func (h ProductHandler) Register(rt *anduin.Router) {
11	rt.Get(routes.ProductIndex.Path(), h.Index)
12	rt.Get(routes.ProductShow.Path(), h.Show)
13	rt.Post(routes.ProductIndex.Path(), h.Create)
14}

Construct the handler once in internal/runtime and register it there:

1products := domains.NewProducts(db)
2handlers.NewProductHandler(products, renderer).Register(router)

Keep handlers/ one flat package. NewProductHandler, NewPagesHandler and NewAuthHandler disambiguate by name; there are no handler sub-packages.

Read input

Use the anduin request helpers. They return a 400 for a malformed value when the parameter is present:

 1func (h ProductHandler) Index(ctx context.Context) error {
 2	page, err := anduin.QueryInt(ctx, "page", 1)
 3	if err != nil {
 4		return anduin.BadRequest(err)
 5	}
 6
 7	result, err := h.products.Paginate(ctx, int64(page), 25)
 8	if err != nil {
 9		return err
10	}
11
12	return h.renderer.Page(ctx, "Products/Index", inertia.FromStruct(result)).Render()
13}

For path parameters use anduin.PathValue(ctx, "id"); the key is the wildcard name from the route path. For bodies use anduin.DecodeJSON or anduin.DecodeForm. A decode failure is anduin.BadRequest(err).

Mutations and validation

Decode into a named, exported payload struct, call the domain, and return its errors unchanged. The domain's Create or Update is the single validation gate:

 1type CreateProductPayload struct {
 2	Name       string `json:"name"`
 3	PriceCents int64  `json:"priceCents"`
 4}
 5
 6func (h ProductHandler) Create(ctx context.Context) error {
 7	var payload CreateProductPayload
 8	if err := anduin.DecodeJSON(ctx, &payload); err != nil {
 9		return anduin.BadRequest(err)
10	}
11
12	created, err := h.products.Create(ctx, domains.CreateProductData{
13		Name:       payload.Name,
14		PriceCents: payload.PriceCents,
15	})
16	if err != nil {
17		return err
18	}
19
20	return h.renderer.Redirect(ctx, routes.ProductShow.URL(created.ID), http.StatusSeeOther)
21}

Do not re-check field rules in the handler. pkg/validation errors come back from the domain and handlers.MapError renders them as a 422 with field errors.

The payload struct name matters: andurel sync payloads generates a matching TypeScript type for named *Payload structs decoded with anduin.DecodeJSON or anduin.DecodeForm, so the frontend form can import it.

Choose a response

Response How
Inertia page h.renderer.Page(ctx, "Products/Index", props).Render()
Page with validation errors .ValidationErrors(map[string]string{...}) or .Errors(err) on the page builder
Page with a status .Status(http.StatusUnprocessableEntity) on the page builder
Redirect after a write h.renderer.Redirect(ctx, url, http.StatusSeeOther)
External location h.renderer.Location(ctx, url)
JSON endpoint anduin.JSON(ctx, http.StatusOK, value)
Text or HTML anduin.Text / anduin.HTML
Empty success anduin.NoContent(ctx, http.StatusNoContent)

Only page-shaped responses need the renderer. A JSON endpoint is a normal route without routing.InertiaRoute() and a handler that returns anduin.JSON.

Errors

Return a shared sentinel or an anduin error and let the installed ErrorHandler translate it:

1return domains.ErrNotFound
2return anduin.BadRequest(err)

internal/runtime installs handlers.MapError(renderer), which maps domains.ErrNotFound to the Errors/NotFound page and domains.ErrConflict to Errors/Conflict, and renders validation errors as a 422. Other errors keep their own HTTP status, and 5xx details are never put in the response.

Middleware and sessions

Generic middleware comes from pkg/anduin; app policy lives in middleware/. Attach it per route or globally:

1rt.Post(routes.ProductIndex.Path(), h.Create,
2	anduin.RateLimit(store, 5, 10*time.Minute, h.onLimited))

middleware.AuthOnly(users) reloads the session's user from the database on every request and rejects a missing user or a stale session version. Read the authenticated user with middleware.ViewerFrom(ctx). Session state is read and written with kiks.Get[*middleware.Session](ctx) and kiks.Set(ctx, ...); never keep a session value in a package global.

Write it yourself

There is no andurel generate handler. Read the pattern, write the file, wire the constructor, then refresh the generated artifacts:

1andurel patterns show handler
2andurel patterns show route
3andurel sync routes --json
4andurel sync payloads --json
5andurel inspect handlers --json
6andurel verify

inspect handlers --json confirms the constructor parameters and the routes Register mounts, which is the fastest way to catch a handler that was written but never wired.

Test a handler

pkg/anduin/anduintest builds an httptest server from real registrations, so the test exercises the same router the app uses:

1srv := anduintest.New(t, func(rt *anduin.Router) {
2	handlers.NewProductHandler(products, testRenderer(t)).Register(rt)
3})
4
5resp := srv.Get(routes.ProductIndex.Path())
6resp.AssertStatus(http.StatusOK)
7resp.AssertInertia("Products/Index")

anduintest keeps a cookie jar across requests and provides Get, PostForm, PostJSON and Do. It does not install the app's error handler, so a test that asserts a mapped error page should build the router directly with anduin.WithErrorHandler(handlers.MapError(renderer)).