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)).