Controllers & HTMX Integration
This guide covers controller implementation in IOTA SDK, focusing on request handling, form validation, HTMX integration, and template rendering. We’ll use the Expense controller as a comprehensive real-world example.
Controller Architecture
Controllers in IOTA SDK handle:
- HTTP request routing and middleware application
- DTO parsing and validation from form data
- Service coordination for business operations
- Response rendering with Templ templates
- HTMX detection for partial vs full-page responses
Controller Structure
Basic Controller Setup
// modules/finance/presentation/controllers/expense_controller.go
type ExpenseController struct {
app application.Application
basePath string
}
func NewExpensesController(app application.Application) application.Controller {
return &ExpenseController{
app: app,
basePath: "/finance/expenses",
}
}
// Key returns the unique identifier for this controller
func (c *ExpenseController) Key() string {
return c.basePath
}Route Registration
Register routes with middleware and dependency injection:
func (c *ExpenseController) Register(r *mux.Router) {
router := r.PathPrefix(c.basePath).Subrouter()
// Apply common middleware
router.Use(
middleware.Authorize(), // Check authentication
middleware.RedirectNotAuthenticated(), // Redirect if not logged in
middleware.ProvideUser(), // Add user to context
middleware.ProvideDynamicLogo(c.app),
middleware.ProvideLocalizer(c.app), // Add i18n support
middleware.NavItems(), // Navigation items
middleware.WithPageContext(), // Page metadata
)
// Register routes with DI
router.HandleFunc("", di.H(c.List)).Methods(http.MethodGet)
router.HandleFunc("/export", di.H(c.Export)).Methods(http.MethodPost)
router.HandleFunc("/{id:[0-9a-fA-F-]+}", di.H(c.GetEdit)).Methods(http.MethodGet)
router.HandleFunc("/new", di.H(c.GetNew)).Methods(http.MethodGet)
router.HandleFunc("", di.H(c.Create)).Methods(http.MethodPost)
router.HandleFunc("/{id:[0-9a-fA-F-]+}", di.H(c.Update)).Methods(http.MethodPost)
router.HandleFunc("/{id:[0-9a-fA-F-]+}", di.H(c.Delete)).Methods(http.MethodDelete)
// HTMX endpoints for dynamic content
router.HandleFunc("/selects/accounts", di.H(c.GetAccountsSelect)).Methods(http.MethodGet)
router.HandleFunc("/selects/categories", di.H(c.GetCategoriesSelect)).Methods(http.MethodGet)
// File attachment endpoints
router.HandleFunc("/{id:[0-9a-fA-F-]+}/attachments", di.H(c.AttachFile)).Methods(http.MethodPost)
router.HandleFunc("/{id:[0-9a-fA-F-]+}/attachments/{uploadId:[0-9]+}", di.H(c.DetachFile)).Methods(http.MethodDelete)
}Some modules register a controller by having another controller call its Register on a sub-router (e.g. the Finance module mounts the expense controller under the financial overview router rather than registering it directly in module.go).
Request Handlers
List Handler with Pagination
func (c *ExpenseController) List(
r *http.Request,
w http.ResponseWriter,
logger *logrus.Entry,
expenseService *services.ExpenseService,
) {
// Get pagination params from request
params := composables.UsePaginated(r)
// Build find params
findParams := &expense.FindParams{
Offset: params.Offset,
Limit: params.Limit,
SortBy: expense.SortBy{
Fields: []repo.SortByField[expense.Field]{
{
Field: expense.CreatedAt,
Ascending: false,
},
},
},
Search: r.URL.Query().Get("Search"),
}
// Apply date filters from query params
if v := r.URL.Query().Get("CreatedAt.To"); v != "" {
t, err := time.Parse(time.RFC3339, v)
if err != nil {
logger.Errorf("Error parsing CreatedAt.To: %v", err)
http.Error(w, "Invalid date format", http.StatusBadRequest)
return
}
findParams.Filters = append(findParams.Filters, expense.Filter{
Column: expense.CreatedAt,
Filter: repo.Lt(t),
})
}
if v := r.URL.Query().Get("CreatedAt.From"); v != "" {
t, err := time.Parse(time.RFC3339, v)
if err != nil {
logger.Errorf("Error parsing CreatedAt.From: %v", err)
http.Error(w, "Invalid date format", http.StatusBadRequest)
return
}
findParams.Filters = append(findParams.Filters, expense.Filter{
Column: expense.CreatedAt,
Filter: repo.Gt(t),
})
}
// Fetch data
expenseEntities, err := expenseService.GetPaginated(r.Context(), findParams)
if err != nil {
logger.Errorf("Error retrieving expenses: %v", err)
http.Error(w, "Error retrieving expenses", http.StatusInternalServerError)
return
}
countParams := *findParams
countParams.Limit = 0
countParams.Offset = 0
total, err := expenseService.Count(r.Context(), &countParams)
if err != nil {
logger.Errorf("Error counting expenses: %v", err)
http.Error(w, "Error counting expenses", http.StatusInternalServerError)
return
}
// Build view props
props := &expensesui.IndexPageProps{
Expenses: mapping.MapViewModels(expenseEntities, mappers.ExpenseToViewModel),
PaginationState: pagination.New(c.basePath, params.Page, int(total), params.Limit),
}
// Determine response type
isEmbedded := r.URL.Query().Get("embedded") == "true"
if isEmbedded {
// Embedded view: return just the content
templ.Handler(expensesui.ExpensesEmbedded(props), templ.WithStreaming()).ServeHTTP(w, r)
} else if htmx.IsHxRequest(r) {
// HTMX request: return table partial
templ.Handler(expensesui.ExpensesTable(props), templ.WithStreaming()).ServeHTTP(w, r)
} else {
// Regular request: return full page
templ.Handler(expensesui.Index(props), templ.WithStreaming()).ServeHTTP(w, r)
}
}Form Handling
Creating Entities from Forms
func (c *ExpenseController) Create(
r *http.Request,
w http.ResponseWriter,
logger *logrus.Entry,
expenseService *services.ExpenseService,
moneyAccountService *services.MoneyAccountService,
expenseCategoryService *services.ExpenseCategoryService,
) {
// Parse form into DTO
dto, err := composables.UseForm(&dtos.ExpenseCreateDTO{}, r)
if err != nil {
logger.Errorf("Error parsing form: %v", err)
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Get tenant from context
tenantID, err := composables.UseTenantID(r.Context())
if err != nil {
logger.Errorf("Error getting tenant: %v", err)
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
// Parse and validate related entity IDs
accountID, err := uuid.Parse(dto.AccountID)
if err != nil {
logger.Errorf("Invalid account ID: %v", err)
http.Error(w, "Invalid account ID", http.StatusBadRequest)
return
}
categoryID, err := uuid.Parse(dto.CategoryID)
if err != nil {
logger.Errorf("Invalid category ID: %v", err)
http.Error(w, "Invalid category ID", http.StatusBadRequest)
return
}
// Load related entities
account, err := moneyAccountService.GetByID(r.Context(), accountID)
if err != nil {
logger.Errorf("Error retrieving account: %v", err)
http.Error(w, "Error retrieving account", http.StatusInternalServerError)
return
}
cat, err := expenseCategoryService.GetByID(r.Context(), categoryID)
if err != nil {
logger.Errorf("Error retrieving category: %v", err)
http.Error(w, "Error retrieving category", http.StatusInternalServerError)
return
}
// Validate DTO
if errorsMap, ok := dto.Ok(r.Context()); !ok {
// Return form with validation errors
entity, err := dto.ToEntityWithReferences(tenantID, account, cat)
if err != nil {
logger.Errorf("Error building entity: %v", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
props := &expensesui.CreatePageProps{
Errors: errorsMap,
Expense: mappers.ExpenseToViewModel(entity),
}
templ.Handler(expensesui.CreateForm(props), templ.WithStreaming()).ServeHTTP(w, r)
return
}
// Build domain entity
entity, err := dto.ToEntityWithReferences(tenantID, account, cat)
if err != nil {
logger.Errorf("Error building entity: %v", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Persist to database
createdEntity, err := expenseService.Create(r.Context(), entity)
if err != nil {
logger.Errorf("Error creating expense: %v", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Handle file attachments
for _, uploadID := range dto.Attachments {
if uploadID > 0 {
if err := expenseService.AttachFileToExpense(r.Context(), createdEntity.ID(), uploadID); err != nil {
logger.Errorf("Error attaching file: %v", err)
// Don't fail the whole operation for attachment errors
}
}
}
// Redirect to list page
shared.Redirect(w, r, c.basePath)
}Updating Entities
func (c *ExpenseController) Update(
r *http.Request,
w http.ResponseWriter,
logger *logrus.Entry,
expenseService *services.ExpenseService,
expenseCategoryService *services.ExpenseCategoryService,
) {
// Parse ID from URL
id, err := shared.ParseUUID(r)
if err != nil {
logger.Errorf("Error parsing expense ID: %v", err)
http.Error(w, "Error parsing id", http.StatusInternalServerError)
return
}
// Parse form
dto, err := composables.UseForm(&dtos.ExpenseUpdateDTO{}, r)
if err != nil {
logger.Errorf("Error parsing form: %v", err)
http.Error(w, "Error parsing form", http.StatusBadRequest)
return
}
// Load existing entity
existing, err := expenseService.GetByID(r.Context(), id)
if errors.Is(err, persistence.ErrExpenseNotFound) {
logger.Errorf("Expense not found: %v", err)
http.Error(w, "Expense not found", http.StatusNotFound)
return
}
if err != nil {
logger.Errorf("Error retrieving expense: %v", err)
http.Error(w, "Error retrieving expense", http.StatusInternalServerError)
return
}
// Load category if changed
categoryID, err := uuid.Parse(dto.CategoryID)
if err != nil {
logger.Errorf("Invalid category ID: %v", err)
http.Error(w, "Invalid category ID", http.StatusBadRequest)
return
}
cat, err := expenseCategoryService.GetByID(r.Context(), categoryID)
if err != nil {
logger.Errorf("Error retrieving category: %v", err)
http.Error(w, "Error retrieving category", http.StatusInternalServerError)
return
}
// Validate DTO
if errorsMap, ok := dto.Ok(r.Context()); !ok {
props := &expensesui.EditPageProps{
Expense: mappers.ExpenseToViewModel(existing),
Errors: errorsMap,
}
templ.Handler(expensesui.EditForm(props), templ.WithStreaming()).ServeHTTP(w, r)
return
}
// Apply updates to entity
entity, err := dto.Apply(existing, cat)
if err != nil {
logger.Errorf("Error applying changes: %v", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Save changes
if _, err := expenseService.Update(r.Context(), entity); err != nil {
logger.Errorf("Error updating expense: %v", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Handle file attachments
for _, uploadID := range dto.Attachments {
if uploadID > 0 {
if err := expenseService.AttachFileToExpense(r.Context(), id, uploadID); err != nil {
logger.Errorf("Error attaching file: %v", err)
}
}
}
shared.Redirect(w, r, c.basePath)
}HTMX Integration
Detecting HTMX Requests
import "github.com/iota-uz/iota-sdk/pkg/htmx"
// Check if request came from HTMX
if htmx.IsHxRequest(r) {
// Return partial HTML
} else {
// Return full page
}HTMX Triggers
Set response headers for HTMX to trigger client-side actions:
// Redirect after HTMX request
func (c *ExpenseController) AttachFile(...) {
// ... attachment logic
if htmx.IsHxRequest(r) {
// Set HX-Redirect header
htmx.Redirect(w, fmt.Sprintf("%s/%s", c.basePath, id.String()))
} else {
http.Redirect(w, r, fmt.Sprintf("%s/%s", c.basePath, id.String()), http.StatusSeeOther)
}
}
// Trigger custom events
func (c *ExpenseController) Delete(...) {
// ... deletion logic
if htmx.IsHxRequest(r) {
// Return empty response with JSON trigger payload
htmx.SetTrigger(w, "expenseDeleted", `{"id":"`+id.String()+`"}`)
} else {
shared.Redirect(w, r, c.basePath)
}
}Dynamic Select Elements
Serve HTMX-powered dropdowns:
func (c *ExpenseController) GetAccountsSelect(
r *http.Request,
w http.ResponseWriter,
logger *logrus.Entry,
moneyAccountService *services.MoneyAccountService,
) {
value := r.URL.Query().Get("value")
form := r.URL.Query().Get("form")
accounts, err := moneyAccountService.GetAll(r.Context())
if err != nil {
logger.Errorf("Error retrieving accounts: %v", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Build attributes for the select element
attrs := templ.Attributes{"name": "AccountID"}
if form != "" {
attrs["form"] = form
}
props := &expensesui.AccountSelectProps{
Value: value,
Accounts: mapping.MapViewModels(accounts, mappers.MoneyAccountToViewModel),
Attrs: attrs,
}
templ.Handler(expensesui.AccountSelect(props), templ.WithStreaming()).ServeHTTP(w, r)
}Template Rendering
Using Templ Components
import "github.com/a-h/templ"
// Render a full page
templ.Handler(expensesui.Index(props), templ.WithStreaming()).ServeHTTP(w, r)
// Render a partial component (for HTMX)
templ.Handler(expensesui.ExpensesTable(props), templ.WithStreaming()).ServeHTTP(w, r)
// Render a form with errors
templ.Handler(expensesui.CreateForm(props), templ.WithStreaming()).ServeHTTP(w, r)Template Structure
// templates/pages/expenses/list.templ
package expensesui
templ Index(props *IndexPageProps) {
@layouts.Default(layouts.DefaultProps{
Title: "Expenses",
}) {
<div class="container mx-auto p-6">
<h1 class="text-2xl font-bold mb-4">Expenses</h1>
<!-- Search and filters -->
<form hx-get="/finance/expenses" hx-target="#expenses-table" class="mb-4">
<input type="search" name="Search" placeholder="Search..." />
</form>
<!-- Expenses table -->
<div id="expenses-table">
@ExpensesTable(props)
</div>
<!-- Pagination -->
@components.Pagination(props.PaginationState)
<!-- New button -->
<a href="/finance/expenses/new" class="btn btn-primary">New Expense</a>
</div>
}
}
templ ExpensesTable(props *IndexPageProps) {
<table class="w-full">
<thead>
<tr>
<th>Date</th>
<th>Category</th>
<th>Amount</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
for _, exp := range props.Expenses {
<tr>
<td>{ exp.Date }</td>
<td>{ exp.CategoryName }</td>
<td>{ exp.Amount }</td>
<td>
<a href={ templ.SafeURL("/finance/expenses/" + exp.ID) }>Edit</a>
<button
hx-delete={ "/finance/expenses/" + exp.ID }
hx-confirm="Are you sure?"
hx-target="closest tr"
hx-swap="outerHTML">
Delete
</button>
</td>
</tr>
}
</tbody>
</table>
}Error Handling
HTTP Status Codes
// Bad request - invalid input
http.Error(w, "Invalid request", http.StatusBadRequest)
// Not found - resource doesn't exist
if errors.Is(err, persistence.ErrExpenseNotFound) {
http.Error(w, "Expense not found", http.StatusNotFound)
return
}
// Forbidden - permission denied (authenticated but not allowed)
// Use composables.CanUser or strict checks (e.g. CanUserStrict) for resource-level auth.
// The framework can render a dedicated forbidden page for HTML requests.
if err := composables.CanUser(ctx, permission); err != nil {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
// Internal error - unexpected failure
logger.Errorf("Unexpected error: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)Structured Error Responses
For API endpoints, return structured errors:
type ErrorResponse struct {
Code string `json:"code"`
Message string `json:"message"`
Details map[string]string `json:"details,omitempty"`
}
func respondWithError(w http.ResponseWriter, code string, message string, status int) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(ErrorResponse{
Code: code,
Message: message,
})
}Testing Controllers
Integration Test with ITF
func TestExpenseController_Create(t *testing.T) {
// Setup ITF (Integration Test Framework)
env := itf.Setup(t, itf.WithModules(finance.NewModule()))
defer env.Teardown()
t.Run("create expense", func(t *testing.T) {
suite := itf.HTTP(t, modules.BuiltInModules...)
// Create test account and category in fixture data (replace with project IDs as needed)
accountID := "00000000-0000-0000-0000-000000000001"
categoryID := "00000000-0000-0000-0000-000000000002"
suite.POST("/finance/expenses").Form(url.Values{
"Amount": {"100.00"},
"AccountID": {accountID},
"CategoryID": {categoryID},
"Comment": {"Test expense"},
"AccountingPeriod": {time.Now().Format("2006-01-02")},
"Date": {time.Now().Format("2006-01-02")},
}).Expect(t).Status(http.StatusSeeOther).RedirectTo("/finance/expenses")
})
}Unit Test with Mock Services
func TestExpenseController_List(t *testing.T) {
// Create mocks
mockService := new(MockExpenseService)
controller := &ExpenseController{
app: nil,
basePath: "/finance/expenses",
}
t.Run("returns list", func(t *testing.T) {
// Setup request
req := httptest.NewRequest(http.MethodGet, "/finance/expenses", nil)
w := httptest.NewRecorder()
// Mock service response
mockService.On("GetPaginated", mock.Anything, mock.Anything).
Return([]expense.Expense{testExpense}, nil)
mockService.On("Count", mock.Anything, mock.Anything).
Return(int64(1), nil)
// Execute
controller.List(req, w, testLogger, mockService)
// Assert
assert.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, w.Body.String(), "Test Expense")
})
}Best Practices Summary
Controller Design
- Single Responsibility: Each handler does one thing
- Early Validation: Check permissions and parse inputs first
- Error Handling: Return appropriate HTTP status codes
- Logging: Log errors with context for debugging
HTMX Patterns
- Progressive Enhancement: Pages work without JavaScript
- Partial Updates: Return only changed HTML
- HX-Redirect: Redirect after POST/PUT/DELETE
- HX-Trigger: Notify client of changes
Form Handling
- DTO Validation: Use go-playground/validator
- Localized Errors: Translate validation messages
- Preserve Input: Return form with values on error
- Display Errors: Show field-level validation errors
Testing
- Integration Tests: Use ITF for end-to-end flows
- Mock Services: Unit test with mocked dependencies
- Test Permissions: Verify unauthorized access is blocked
- Test HTMX: Verify partial responses work correctly
Common Patterns Checklist
When implementing a controller:
- Define basePath and Key() method
- Register routes with appropriate middleware
- Use di.H() for dependency injection
- Parse IDs from URL parameters
- Parse and validate DTOs from forms
- Check permissions using composables.CanUser()
- Handle validation errors with localized messages
- Support both full-page and HTMX partial responses
- Use proper HTTP status codes
- Log errors with context
- Write integration tests with ITF
- Test HTMX behavior separately
Next Steps
- Deep dive into Repository Patterns
- Master Service Layer
- Study Complex Features
- Review Module Development Guide
Last updated on