Handlers
> Organize HTTP request handling with Velocity handlers and Context-based handlers.
Handlers in Velocity handle HTTP requests and responses, providing a clean way to organize your application logic following the MVC pattern. Handlers use Context-based functions that receive a *router.Context and return an error.
Quick Start
Creating and using handlers in Velocity:
// internal/handlers/user_handler.go
package handlers
import (
"myapp/internal/models"
"github.com/velocitykode/velocity/router"
"github.com/velocitykode/velocity/view"
)
type UserHandler struct{}
func NewUserHandler() *UserHandler {
return &UserHandler{}
}
func (c *UserHandler) Index(ctx *router.Context) error {
users, err := models.User{}.All()
if err != nil {
return ctx.JSON(500, map[string]string{"error": "Failed to load users"})
}
return view.Render(ctx, "users/index", view.Props{
"users": users,
})
}
func (c *UserHandler) Show(ctx *router.Context) error {
id := ctx.Param("id")
user, err := models.User{}.Find(id)
if err != nil {
return ctx.JSON(404, map[string]string{"error": "User not found"})
}
return view.Render(ctx, "users/show", view.Props{
"user": user,
})
}Handler Structure
Basic Handler
package handlers
import (
"myapp/internal/models"
"github.com/velocitykode/velocity/auth"
"github.com/velocitykode/velocity/router"
"github.com/velocitykode/velocity/view"
)
type PostHandler struct{}
func NewPostHandler() *PostHandler {
return &PostHandler{}
}
// Show all posts
func (c *PostHandler) Index(ctx *router.Context) error {
posts, err := models.Post{}.With("User").OrderBy("created_at", "DESC").Get()
if err != nil {
return ctx.JSON(500, map[string]string{"error": "Failed to load posts"})
}
return view.Render(ctx, "posts/index", view.Props{
"posts": posts,
})
}
// Show single post
func (c *PostHandler) Show(ctx *router.Context) error {
id := ctx.Param("id")
post, err := models.Post{}.With("User", "Comments.User").Find(id)
if err != nil {
return ctx.JSON(404, map[string]string{"error": "Post not found"})
}
return view.Render(ctx, "posts/show", view.Props{
"post": post,
})
}
// Show create form
func (c *PostHandler) Create(ctx *router.Context) error {
return view.Render(ctx, "posts/create", view.Props{})
}
// Store new post
func (c *PostHandler) Store(ctx *router.Context) error {
// Bind and validate request
var input struct {
Title string `json:"title"`
Body string `json:"body"`
}
if err := ctx.Bind(&input); err != nil {
return ctx.JSON(400, map[string]string{"error": "Invalid input"})
}
// Get authenticated user
user := auth.FromContext(ctx).User(ctx.Request).(*models.User)
// Create post
post, err := models.Post{}.Create(map[string]any{
"title": input.Title,
"body": input.Body,
"user_id": user.ID,
})
if err != nil {
return ctx.JSON(500, map[string]string{"error": "Failed to create post"})
}
view.Location(ctx, fmt.Sprintf("/posts/%d", post.ID))
return nil
}
// Show edit form
func (c *PostHandler) Edit(ctx *router.Context) error {
id := ctx.Param("id")
post, err := models.Post{}.Find(id)
if err != nil {
return ctx.JSON(404, map[string]string{"error": "Post not found"})
}
return view.Render(ctx, "posts/edit", view.Props{
"post": post,
})
}
// Update post
func (c *PostHandler) Update(ctx *router.Context) error {
id := ctx.Param("id")
post, err := models.Post{}.Find(id)
if err != nil {
return ctx.JSON(404, map[string]string{"error": "Post not found"})
}
var input struct {
Title string `json:"title"`
Body string `json:"body"`
}
if err := ctx.Bind(&input); err != nil {
return ctx.JSON(400, map[string]string{"error": "Invalid input"})
}
post.Update(map[string]any{
"title": input.Title,
"body": input.Body,
})
view.Location(ctx, fmt.Sprintf("/posts/%d", post.ID))
return nil
}
// Delete post
func (c *PostHandler) Destroy(ctx *router.Context) error {
id := ctx.Param("id")
post, err := models.Post{}.Find(id)
if err != nil {
return ctx.JSON(404, map[string]string{"error": "Post not found"})
}
if err := post.Delete(); err != nil {
return ctx.JSON(500, map[string]string{"error": "Failed to delete post"})
}
view.Location(ctx, "/posts")
return nil
}Create(map[string]any{...}) and Update(map[string]any{...}) are
deny-by-default. The model must declare AssignableFields() (allowlist) or
ProtectedFields() (denylist), or opt out with AllowAllColumns() bool;
otherwise the write is rejected with a *orm.MassAssignmentError. See
CRUD Operations.Base Handler
Create a base handler with common functionality:
// internal/handlers/base_handler.go
package handlers
import (
"github.com/velocitykode/velocity/router"
"github.com/velocitykode/velocity/view"
"github.com/velocitykode/velocity/auth"
"myapp/internal/models"
)
type BaseHandler struct{}
// JSON response helper
func (c *BaseHandler) JSON(ctx *router.Context, data interface{}, status ...int) error {
statusCode := 200
if len(status) > 0 {
statusCode = status[0]
}
return ctx.JSON(statusCode, data)
}
// Error response helper
func (c *BaseHandler) Error(ctx *router.Context, message string, status ...int) error {
statusCode := 500
if len(status) > 0 {
statusCode = status[0]
}
return ctx.JSON(statusCode, map[string]string{
"error": message,
})
}
// Not found response
func (c *BaseHandler) NotFound(ctx *router.Context) error {
return c.Error(ctx, "Resource not found", 404)
}
// Forbidden response
func (c *BaseHandler) Forbidden(ctx *router.Context) error {
return c.Error(ctx, "Access forbidden", 403)
}
// Unauthorized response
func (c *BaseHandler) Unauthorized(ctx *router.Context) error {
return c.Error(ctx, "Authentication required", 401)
}
// Redirect helper
func (c *BaseHandler) Redirect(ctx *router.Context, url string, status ...int) error {
statusCode := 302
if len(status) > 0 {
statusCode = status[0]
}
return ctx.Redirect(statusCode, url)
}
// Render with validation errors
// ctx.FlashErrors writes the errors to an encrypted flash cookie so they
// survive a redirect and are available to the next view render. Its
// companion, ctx.FlashInput, does the same for old form input.
func (c *BaseHandler) FlashErrors(ctx *router.Context, errors map[string][]string) {
ctx.FlashErrors(errors)
}
// Authorization helper
func (c *BaseHandler) authorize(ctx *router.Context, action string, resource interface{}) bool {
user := auth.FromContext(ctx).User(ctx.Request)
if user == nil {
return false
}
// Implement your authorization logic here
// For example, check if user can perform action on resource
return true
}
// Get authenticated user
func (c *BaseHandler) user(ctx *router.Context) *models.User {
return auth.FromContext(ctx).User(ctx.Request).(*models.User)
}Resource Handlers
Velocity supports RESTful resource handlers:
// internal/handlers/api/user_handler.go
package api
import (
"errors"
"myapp/internal/models"
"github.com/velocitykode/velocity/auth"
"github.com/velocitykode/velocity/router"
"github.com/velocitykode/velocity/validation"
)
type UserHandler struct {
BaseHandler
}
// GET /api/users
func (c *UserHandler) Index(ctx *router.Context) error {
page := ctx.Query("page")
if page == "" {
page = "1"
}
users, err := models.User{}.
With("Profile").
Paginate(page, 15)
if err != nil {
return c.Error(ctx, "Failed to load users")
}
return c.JSON(ctx, users)
}
// GET /api/users/{id}
func (c *UserHandler) Show(ctx *router.Context) error {
id := ctx.Param("id")
user, err := models.User{}.With("Profile", "Posts").Find(id)
if err != nil {
return c.NotFound(ctx)
}
return c.JSON(ctx, user)
}
// POST /api/users
type StoreUserInput struct {
Name string `json:"name"`
Email string `json:"email"`
Password string `json:"password"`
}
// Rules makes StoreUserInput a router.Validatable (the same interface
// vform.FormRequest aliases), so ctx.BindValid picks the rules up.
func (in *StoreUserInput) Rules() validation.Rules {
return validation.Rules{
"name": {validation.Required(), validation.String(), validation.Max(255)},
"email": {validation.Required(), validation.Email(), validation.Unique("users", "email")},
"password": {validation.Required(), validation.String(), validation.Min(8)},
}
}
func (c *UserHandler) Store(ctx *router.Context) error {
var input StoreUserInput
// BindValid binds the JSON body and then runs input.Rules() through
// the same DB-backed rule set ctx.Validate uses, so `unique` resolves
// here too. Unlike ctx.Validate it neither flashes errors nor
// redirects back, which is what an API handler wants: the response
// shape stays yours.
if err := ctx.BindValid(&input); err != nil {
var ve validation.ValidationErrors
if errors.As(err, &ve) {
return ctx.JSON(422, map[string]interface{}{"errors": ve.All()})
}
// Bind/decode failure or no validator wired, not a field error.
return ctx.JSON(400, map[string]interface{}{"error": "Invalid input"})
}
hashedPassword, err := auth.FromContext(ctx).Hash(input.Password)
if err != nil {
return c.Error(ctx, "Failed to hash password")
}
user := models.User{
Name: input.Name,
Email: input.Email,
Password: hashedPassword,
}
if err := user.Save(); err != nil {
return c.Error(ctx, "Failed to create user")
}
return ctx.JSON(201, user)
}
// PUT /api/users/{id}
type UpdateUserInput struct {
Name string `json:"name"`
Email string `json:"email"`
// userID is unexported, so binding never writes it and it never
// reaches the validated data map. Rules() reads it to scope the
// uniqueness check to "every row but this one".
userID uint
}
func (in *UpdateUserInput) Rules() validation.Rules {
return validation.Rules{
"name": {validation.Nullable(), validation.String(), validation.Max(255)},
"email": {
validation.Nullable(),
validation.Email(),
validation.Unique("users", "email").Except(in.userID),
},
}
}
func (c *UserHandler) Update(ctx *router.Context) error {
id := ctx.Param("id")
user, err := models.User{}.Find(id)
if err != nil {
return c.NotFound(ctx)
}
input := UpdateUserInput{userID: user.ID}
if err := ctx.BindValid(&input); err != nil {
var ve validation.ValidationErrors
if errors.As(err, &ve) {
return ctx.JSON(422, map[string]interface{}{"errors": ve.All()})
}
return ctx.JSON(400, map[string]interface{}{"error": "Invalid input"})
}
if err := user.Update(map[string]any{
"name": input.Name,
"email": input.Email,
}); err != nil {
return c.Error(ctx, "Failed to update user")
}
return c.JSON(ctx, user)
}
// DELETE /api/users/{id}
func (c *UserHandler) Destroy(ctx *router.Context) error {
id := ctx.Param("id")
user, err := models.User{}.Find(id)
if err != nil {
return c.NotFound(ctx)
}
if err := user.Delete(); err != nil {
return c.Error(ctx, "Failed to delete user")
}
ctx.Response.WriteHeader(204)
return nil
}Validation rules are typed values
Rules are constructor values collected into a validation.Rules set keyed
by field, never strings. Parameters ride along pre-split, so a value
containing , or | needs no escaping. The same rule shape is accepted by
ctx.Validate, ctx.BindValid, vform.FormRequest.Rules(),
validation.Check / CheckW / CheckData, and the dbrules.Check*
helpers.
Nullable() is the partial-update lever: a field whose value is empty
(nil or "") skips every other rule on that field, so an omitted name
in a PUT body is left alone while a supplied one still has to pass
String() and Max(255).
Unique(table, column) and Exists(table, column) describe the DB-backed
checks; Unique(...).Except(id) and .IDColumn(name) return new rule
values rather than mutating the receiver, so a package-level rule set stays
immutable and safe to share across goroutines.
Choosing a validation entry point
ctx.Validate(rules, messages...) is the web-form entry point: on failure
it calls ctx.FlashErrors and ctx.FlashInput, redirects back through the
view engine when one is wired, and returns router.ErrValidationAborted,
which the router treats as “response already written”. Return that error
directly rather than writing a second response:
func (c *PostHandler) Store(ctx *router.Context) error {
if err := ctx.Validate(validation.Rules{
"title": {validation.Required(), validation.Min(3)},
"body": {validation.Required(), validation.Min(10)},
}); err != nil {
// Errors and old input are already flashed and the redirect back
// is already written; returning err stops the router from
// emitting another response.
return err
}
// Only reached when every field passed.
return nil
}For JSON APIs use ctx.BindValid (as above), which validates without
flashing or redirecting, or reach for vform.Validate[T] when you want the
bound struct and a *validation.Result to shape the envelope yourself.
Handler Middleware
Apply middleware to handlers:
// routes/web.go
package routes
import (
"myapp/internal/handlers"
"myapp/internal/middleware"
"github.com/velocitykode/velocity/router"
)
func WebRoutes(r router.Router) {
// Public routes
homeHandler := &handlers.HomeHandler{}
postHandler := &handlers.PostHandler{}
r.Get("/", homeHandler.Index)
r.Get("/posts", postHandler.Index)
r.Get("/posts/{id}", postHandler.Show)
// Protected routes. Group takes a path prefix ("" for none) and an
// optional closure that receives the group's router.
r.Group("", func(r router.Router) {
r.Use(middleware.Auth)
r.Get("/posts/create", postHandler.Create)
r.Post("/posts", postHandler.Store)
r.Get("/posts/{id}/edit", postHandler.Edit)
r.Put("/posts/{id}", postHandler.Update)
r.Delete("/posts/{id}", postHandler.Destroy)
})
}Form Handling
Handle form submissions:
func (c *PostHandler) Store(ctx *router.Context) error {
// Parse form data
if err := ctx.Request.ParseForm(); err != nil {
return c.Error(ctx, "Invalid form data")
}
// Manual validation
title := strings.TrimSpace(ctx.Request.FormValue("title"))
body := strings.TrimSpace(ctx.Request.FormValue("body"))
if title == "" {
c.FlashErrors(ctx, map[string][]string{
"title": {"Title is required"},
})
// Flashing only writes the cookie; view.Back issues the redirect
// that lets the next render read it.
view.Back(ctx)
return nil
}
if len(body) < 10 {
c.FlashErrors(ctx, map[string][]string{
"body": {"Body must be at least 10 characters"},
})
view.Back(ctx)
return nil
}
// Create post
post := models.Post{
Title: title,
Body: body,
UserID: c.user(ctx).ID,
}
if err := post.Save(); err != nil {
return c.Error(ctx, "Failed to create post")
}
// Flash a success message and redirect. view.For binds the view
// engine to this request so flash and the terminal redirect chain.
view.For(ctx).
Flash("success", "Post created successfully").
Redirect(fmt.Sprintf("/posts/%d", post.ID))
return nil
}File Uploads
Handle file uploads with the Context helpers. ctx.FormFile parses the
multipart form (under the router’s body-size limit) and returns the
*multipart.FileHeader; ctx.SaveFile writes it under the router’s
configured FileRoot with kernel-enforced path containment, so a
traversal or symlinked dst cannot escape the upload directory. Pass
FileValidationOption values to enforce size, extension, and MIME limits
before any bytes are written:
func (c *UserHandler) UpdateAvatar(ctx *router.Context) error {
header, err := ctx.FormFile("avatar")
if err != nil {
return c.Error(ctx, "No file uploaded")
}
// Save under FileRoot, validating size and type before writing.
fileName := fmt.Sprintf("avatars/%d_%s", c.user(ctx).ID, header.Filename)
if err := ctx.SaveFile(header, fileName,
router.MaxFileSize(10<<20), // 10MB max
router.AllowedMIMETypes("image/jpeg", "image/png", "image/gif"),
); err != nil {
return c.Error(ctx, "Failed to save file")
}
// Update user avatar
user := c.user(ctx)
user.Update(map[string]any{
"avatar": fileName,
})
return c.JSON(ctx, map[string]string{
"message": "Avatar updated successfully",
"avatar": fileName,
})
}Request/Response Helpers
Useful helpers for handlers:
// Get query parameters. The framework also exposes ctx.QueryDefault,
// ctx.QueryInt, ctx.QueryInt64, ctx.QueryFloat64, and ctx.QueryBool for
// typed access.
func (c *BaseHandler) getQuery(ctx *router.Context, key string, defaultValue ...string) string {
if len(defaultValue) > 0 {
return ctx.QueryDefault(key, defaultValue[0])
}
return ctx.Query(key)
}
// Get form value
func (c *BaseHandler) getForm(ctx *router.Context, key string, defaultValue ...string) string {
value := ctx.FormValue(key)
if value == "" && len(defaultValue) > 0 {
return defaultValue[0]
}
return value
}
// Parse JSON body
func (c *BaseHandler) parseJSON(ctx *router.Context, v interface{}) error {
return ctx.Bind(v)
}
// Get client IP. Prefer ctx.IP(): it resolves the real client address
// through the router's trusted-proxy policy instead of blindly trusting
// X-Forwarded-For, which a client can spoof.
func (c *BaseHandler) getClientIP(ctx *router.Context) string {
return ctx.IP()
}Error Handling
Centralized error handling:
// internal/handlers/error_handler.go
package handlers
import (
"github.com/velocitykode/velocity/router"
"github.com/velocitykode/velocity/view"
)
type ErrorHandler struct{}
func (c *ErrorHandler) NotFound(ctx *router.Context) error {
ctx.Response.WriteHeader(404)
return view.Render(ctx, "errors/404", view.Props{
"url": ctx.Request.URL.Path,
})
}
func (c *ErrorHandler) InternalError(ctx *router.Context, err error) error {
// ctx.Log() returns the request-scoped logger; it logs structured
// key/value pairs after the message.
ctx.Log().Error("Internal server error", "error", err, "url", ctx.Request.URL.Path)
ctx.Response.WriteHeader(500)
return view.Render(ctx, "errors/500", view.Props{
"error": err.Error(),
})
}
func (c *ErrorHandler) Forbidden(ctx *router.Context) error {
ctx.Response.WriteHeader(403)
return view.Render(ctx, "errors/403", view.Props{})
}Testing Handlers
Test your handlers:
// internal/handlers/user_handler_test.go
package handlers
import (
"net/http"
"net/http/httptest"
"testing"
"net/url"
"strings"
"github.com/stretchr/testify/assert"
"myapp/internal/models"
"github.com/velocitykode/velocity/router"
)
func TestUserHandler_Index(t *testing.T) {
// Setup test database
setupTestDB()
defer teardownTestDB()
// Create test user
user := models.User{
Name: "Test User",
Email: "test@example.com",
}
user.Save()
// Create request and response recorder
req := httptest.NewRequest("GET", "/users", nil)
w := httptest.NewRecorder()
// Create context
ctx := &router.Context{
Request: req,
Response: w,
}
// Call handler
handler := &UserHandler{}
err := handler.Index(ctx)
// Assert response
assert.NoError(t, err)
assert.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, w.Body.String(), "Test User")
}
func TestUserHandler_Store(t *testing.T) {
setupTestDB()
defer teardownTestDB()
// Create form data
form := url.Values{}
form.Add("name", "New User")
form.Add("email", "new@example.com")
req := httptest.NewRequest("POST", "/users", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
// Create context
ctx := &router.Context{
Request: req,
Response: w,
}
// Call handler
handler := &UserHandler{}
err := handler.Store(ctx)
// Assert response
assert.NoError(t, err)
assert.Equal(t, http.StatusFound, w.Code)
// Verify user was created
user, err := models.User{}.FindBy("email", "new@example.com")
assert.NoError(t, err)
assert.Equal(t, "New User", user.Name)
}Best Practices
- Keep handlers thin - Move business logic to models or services
- Use base handler - Share common functionality across handlers
- Validate input - Always validate user input before processing
- Handle errors gracefully - Provide meaningful error messages
- Use middleware - Apply cross-cutting concerns like authentication
- Return appropriate status codes - Use correct HTTP status codes
- Test handlers - Write unit tests for handler methods
- Separate concerns - Keep API and web handlers separate