Controllers

Controller Architecture

Use request-local controllers with uniform constructors and automatic rendering.

By Guillermo Alvarez - Published - Updated

Shape a controller

A concrete controller embeds the application base controller and keeps only the services it needs:

type PostsController struct {
    controllers.BaseController
    posts *postservice.Service
}

The constructor receives the application context:

func New(ctx context.Context) (*PostsController, error) {
    base, err := controllers.NewBaseController(ctx)
    if err != nil {
        return nil, err
    }

    posts, ok := postservice.FromContext(ctx)
    if !ok {
        return nil, fmt.Errorf("posts service missing")
    }

    return &PostsController{
        BaseController: base,
        posts:          posts,
    }, nil
}

Do not add renderer, response writer, request, service, or view-path parameters to concrete controller constructors.

Keep controllers request-local

Routes point at a constructor, not a shared controller instance:

router.Get("/posts", posts.New, (*posts.PostsController).Index)

For each request, GoLazy constructs a fresh controller, runs the selected action, and then renders if the action did not already write a response. Mutable state such as Set, headers, layout selection, and status code stays on that one request.

Write standard actions

The standard action signature is:

func (c *PostsController) Index(
    w http.ResponseWriter,
    r *http.Request,
) error

Most HTML actions can ignore w, set template data, and return nil:

func (c *PostsController) Index(_ http.ResponseWriter, _ *http.Request) error {
    c.Set("title", "Posts")
    c.Set("posts", c.posts.List())
    return nil
}

GoLazy renders the default controller/action view after the action returns. Use Render("other") only when an action should render a different view.

Return expected errors

Use lazycontroller.Error for expected HTTP failures:

post, ok := c.posts.Get(r.PathValue("post_id"))
if !ok {
    return lazycontroller.Error(
        http.StatusNotFound,
        fmt.Errorf("post not found"),
    )
}

c.Set("post", post)
return nil

Unexpected errors become 500 Internal Server Error. The wrapped error is kept for callers and future logging, while the user receives the status response. By default, controller errors render the shared app/error view. Add app/views/app/error.html.tpl to override the framework's built-in mobile friendly error view for the whole application. The raw .error value is set only in development/detail mode. When the error carries a backtrace, including lazyerrors errors and recovered panics, .backtrace is also set only in development/detail mode. The default view shows workspace-relative or module-relative frame paths and, in lazydev, lets you click a frame to open it through $EDITOR with VS Code -g support and terminal-editor launch heuristics. Production error views should rely on .status and .statusText for safe output.

Set response metadata

Response helpers record metadata without committing the response early:

c.Status(http.StatusCreated)
c.Header().Set("Cache-Control", "no-store")
c.Set("post", post)
return nil

Automatic rendering can still run because Status does not call WriteHeader. If an action writes the response body manually, use the standard http.ResponseWriter methods directly.

Use Generators for typed action arguments and MIME and Formats for multi-format actions.