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
Controllers are plain structs with a zero-argument constructor. Services
are not stored on the struct — they arrive as typed parameters on each
handler method via the di.H wrapper. Global services (i18n bundle, pool,
event bus, user session) are installed as request-scoped middleware by
pkg/server/builder.go and resolved per request via composables.*.
// modules/finance/presentation/controllers/expense_controller.go
type ExpenseController struct {
basePath string
}
func NewExpensesController() application.Controller {
basePath := "/finance/expenses"
return &ExpenseController{
basePath: basePath,
}
}
func (c *ExpenseController) Descriptor() application.ControllerDescriptor {
return application.Descriptor(
"finance.expense",
0,
application.Route("", c.basePath),
)
}Descriptor().ID is the stable controller identity used by composition. It is
not derived from the mux path. Descriptor().Order controls registration order,
and Descriptor().Routes gives composition enough metadata to validate route
collisions before the router is mounted. Override another controller by setting
Descriptor().Replaces; do not remove controllers by path strings.
Applet engine controllers follow the same SDK controller descriptor model when
they are mounted into the application. New SDK-facing controller examples should
implement Descriptor(), not Key().
Route Registration
Register routes with middleware. The global i18n, logger, tenant, and session middlewares are already installed by the server builder; each controller only attaches the access-control middlewares it needs.
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(),
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 component.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.WithComponents(finance.NewComponent()))
defer env.Teardown()
t.Run("create expense", func(t *testing.T) {
suite := itf.NewSuiteBuilder(t).WithComponents(modules.Components()...).Build()
// 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 ITF Suite
After the composition refactor, controllers have zero-argument constructors
and handler dependencies arrive via di.H parameter injection at request
time. Use the ITF suite to wire the full composition container and exercise
the HTTP surface end-to-end:
func TestExpenseController_List(t *testing.T) {
suite := itf.NewSuiteBuilder(t).
WithComponents(modules.Components()...).
Build()
t.Run("returns list", func(t *testing.T) {
suite.GET("/finance/expenses").
Expect(t).
Status(http.StatusOK)
})
}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