Application Layer
MVC web framework for HTMX/HATEOAS architecture. Status: Complete ✅
Design Principles
| Principle | Rationale |
|---|---|
| Server-side rendering | HTML responses, not JSON |
| Request isolation | Value receiver pattern - no mutexes |
| Thin controllers | HTTP only, logic in internal/ |
| Zero frontend build | No webpack, just Go templates + CDN |
| Progressive enhancement | Works without JavaScript |
Frontend Stack
| Library | Version | CDN |
|---|---|---|
| DaisyUI | 5 | https://cdn.jsdelivr.net/npm/daisyui@5 |
| Tailwind CSS | 4 | https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4 |
| HTMX | 2.0.8 | https://cdn.jsdelivr.net/npm/htmx.org@2.0.8 |
Controller Pattern
Controllers implement the Controller interface and embed BaseController for helpers. Controllers are registered as template functions and accessed directly (e.g., {{home.Method}}).
Important: Controller methods are accessed as {{home.Method}}, NOT {{.home.Method}}. This is because controllers are registered as template functions (returning the controller instance), not as data fields on the template context.
<!-- CORRECT - controller as function -->
{{home.Message}}
{{range sites.All}}...{{end}}
<!-- WRONG - controllers are NOT in the dot context -->
{{.home.Message}}
{{range .sites.All}}...{{end}}
How it works:
- During registration, each controller is added as a template function
- The function calls
controller.Handle(request)and returns the controller copy - Template methods are then called on that copy (e.g.,
.Message,.All) - The value receiver in
Handle()ensures each request gets an isolated copy
Factory Function
Returns (name, controller) tuple. Name becomes the template function name.
func Home() (string, *HomeController) {
return "home", &HomeController{}
}
Registration
application.Serve(views,
application.WithController(controllers.Home()),
application.WithController(controllers.Sites()),
)
Application Options
| Option | Description |
|---|---|
WithController(name, ctrl) |
Register a controller as a template function |
WithFunc(name, fn) |
Register a custom template function |
WithValue(name, value) |
Register a template function returning a constant value |
WithMiddleware(mw) |
Add HTTP middleware (applied in order, last wraps outermost) |
WithViews(views) |
Add additional view filesystem (merged with primary) |
WithEmailer(emailer) |
Set the email provider (Logger or Resend) |
Setup Method
Registers routes via http.Handle. Receives *application.App for helpers.
func (c *HomeController) Setup(app *application.App) {
c.BaseController.Setup(app)
http.Handle("GET /", app.Serve("index.html", nil))
http.Handle("GET /api/click", app.Method(c, "Click", nil))
}
Handle Method
Critical: Uses VALUE receiver to create per-request copy.
func (c HomeController) Handle(r *http.Request) application.Controller {
c.Request = r
return &c
}
All other methods use pointer receivers.
Template Methods
Public methods on controller are accessible in templates as {{controllerName.MethodName}}:
<h1>{{home.Message}}</h1>
<ul>
{{range sites.All}}
<li>{{.Name}}</li>
{{end}}
</ul>
Handler Methods
Called by routes. Signature: func(w http.ResponseWriter, r *http.Request).
Delegate to internal/ packages for business logic.
Routing
| Pattern | Returns | Purpose |
|---|---|---|
app.Serve(template, bouncer) |
*View |
Render template |
app.Method(c, "Name", bouncer) |
http.Handler |
Call controller method |
View is a type that implements http.Handler and renders a template.
Route order matters - register catch-all routes (like /{handle}) last.
Request Helpers
BaseController provides these helpers (also accessible via embedded *http.Request):
| Method | Usage |
|---|---|
c.PathValue("id") |
URL path parameter (via embedded Request) |
c.QueryParam("q", "") |
Query string with default |
c.IntParam("page", 1) |
Integer param with default |
r.FormValue("name") |
POST form field |
Response Helpers
| Method | Effect |
|---|---|
c.Render(w, r, template, data) |
Render template |
c.RenderError(w, r, err) |
Error HTML (200 OK, see note below) |
c.Redirect(w, r, path) |
HX-Location or HTTP redirect |
c.Refresh(w, r) |
HX-Refresh header |
c.IsHTMX(r) |
Check if HTMX request |
RenderError and 200 OK
RenderError returns HTTP 200 OK intentionally. This is required for HTMX to swap the error HTML into the target element. If we returned 4xx/5xx, HTMX would not perform the swap and the user would see nothing.
Implications:
- Monitoring: HTTP status codes won't catch errors. Use log-based monitoring instead.
- Error logging: Always log errors server-side before calling
RenderError. - JSON APIs: For non-HTMX endpoints, return proper status codes with
http.Error().
func (c *SiteController) Create(w http.ResponseWriter, r *http.Request) {
site, err := createSite(r)
if err != nil {
log.Printf("[sites] Create failed: %v", err) // Always log
c.RenderError(w, r, err) // Returns 200 with error HTML
return
}
c.Redirect(w, r, "/sites/"+site.ID)
}
Alternative patterns:
- Use
HX-Triggerheader to show toast notifications - Use
hx-swap-oobto update an error container - Check
c.IsHTMX(r)and return different status codes for API vs HTMX
Security
Bouncers
Functions that gate route access. Return true to allow, false to block.
// Define a bouncer in your application
func RequireAuth(app *application.App, w http.ResponseWriter, r *http.Request) bool {
user := GetCurrentUser(r) // Your auth logic
if user == nil {
http.Redirect(w, r, "/signin", http.StatusSeeOther)
return false
}
return true
}
func RequireAdmin(app *application.App, w http.ResponseWriter, r *http.Request) bool {
user := GetCurrentUser(r)
if user == nil || user.Role != "admin" {
http.Error(w, "Forbidden", http.StatusForbidden)
return false
}
return true
}
// Use in routes
http.Handle("GET /admin", app.Serve("admin.html", RequireAuth))
http.Handle("POST /admin/delete", app.Method(c, "Delete", RequireAdmin))
Authentication
Authentication is application-specific. Common pattern:
// JWT tokens in HTTP-only cookies
func SetAuthCookie(w http.ResponseWriter, userID string) {
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"sub": userID,
"exp": time.Now().Add(30 * 24 * time.Hour).Unix(),
})
signed, _ := token.SignedString(jwtSecret)
http.SetCookie(w, &http.Cookie{
Name: "auth",
Value: signed,
HttpOnly: true,
SameSite: http.SameSiteLaxMode, // CSRF protection
MaxAge: 30 * 24 * 60 * 60,
})
}
func GetCurrentUser(r *http.Request) *User {
cookie, err := r.Cookie("auth")
if err != nil {
return nil
}
// Parse and validate JWT, return user
}
SameSite=Lax cookies provide CSRF protection for HTMX requests.
Templates
On-Demand Loading
Templates are loaded in two phases:
- Startup:
views/layouts/andviews/partials/are pre-loaded as shared base templates - Request: The specific view file is loaded on-demand and parsed into a clone of the base
This design solves the {{define "content"}} conflict problem - each page template is isolated and can define its own blocks without conflicting with other pages.
Magic Directories
| Directory | Behavior |
|---|---|
views/layouts/ |
Pre-loaded at startup, globally accessible |
views/partials/ |
Pre-loaded at startup, globally accessible |
views/static/ |
Served as static files |
views/**/*.html |
Loaded on-demand per render |
Structure
views/
├── layouts/base.html # Pre-loaded
├── partials/*.html # Pre-loaded
├── {feature}/index.html # Loaded on-demand
├── icons/icon-*.html # Pre-loaded (in partials/)
└── static/ # Served directly
Usage Pattern
<!-- views/layouts/base.html -->
<!DOCTYPE html>
<html>
<head>...</head>
<body>
{{template "content" .}}
</body>
</html>
<!-- views/sites/index.html -->
{{template "base.html" .}}
{{define "content"}}
<h1>Sites</h1>
{{template "site-card.html" .}}
{{end}}
<!-- views/partials/site-card.html -->
<div class="card">...</div>
Access
- Reference by filename only:
{{template "card.html" .}} - Controller methods:
{{sites.All}},{{home.Message}}(not.home- controllers are functions) - Icons use prefix:
{{template "icon-plus.html"}}
Emailer
Interface
Emailer interface with single method: Send(to, subject, templateName string, data map[string]any) error
Implementations
| Type | Package | Purpose |
|---|---|---|
Logger |
emailers |
Development - logs emails |
Resend |
emailers |
Production - sends via Resend API |
BaseEmailer
Embed BaseEmailer for template rendering. Call Init(emails, funcs) in constructor.
| Method | Purpose |
|---|---|
Init(emails, funcs) |
Parse email templates |
Render(name, data) |
Render template to string |
Usage
application.Serve(views,
application.WithEmailer(emailers.NewLogger(emails, nil)),
)
Access via embedded field: c.App.Send(to, subject, template, data)
Business Logic Separation
Controllers do:
- Route registration
- Request/response parsing
- Template method exposure
- Delegation to
internal/
Controllers don't:
- Database queries (beyond simple lookups)
- Validation logic
- External service calls
- Complex business rules
All of that lives in internal/ packages organized by domain:
internal/sites/, internal/hosting/, internal/security/, internal/billing/
HTMX Patterns
- Forms:
hx-post,hx-target,hx-swap - Delete:
hx-delete+hx-confirm - Polling:
hx-trigger="every 2s" - Partials vs full pages: Check
c.IsHTMX(r)
Navigation with hx-boost
Use hx-boost="true" on navigation containers for SPA-like navigation with full page responses:
<ul class="menu" hx-boost="true">
<li><a href="/">Home</a></li>
<li><a href="/about">About</a></li>
</ul>
Place hx-boost at the menu/nav level, not on body - keeps the enhancement scoped and hygienic. Links inside will use AJAX with proper history management, and the full page response body is swapped automatically.
SSE Streaming
Server-Sent Events for real-time updates. Use with HTMX's SSE extension.
Streamer Methods
| Method | Purpose |
|---|---|
Stream(w) |
Create SSE streamer, sets headers |
Send(event, data) |
Send string data with event name |
SendData(data) |
Send data (default "message" event) |
Render(event, template, data) |
Render template partial via SSE |
Controller Example
func (c *HomeController) Live(w http.ResponseWriter, r *http.Request) {
stream := c.Stream(w)
for {
select {
case <-r.Context().Done():
return
case <-time.After(time.Second):
stream.Render("time", "live-time.html", time.Now().Format("15:04:05"))
}
}
}
Template Usage
<!-- Include SSE extension -->
<script src="https://cdn.jsdelivr.net/npm/htmx-ext-sse@2.2.2/sse.js"></script>
<!-- Connect to SSE endpoint -->
<div hx-ext="sse" sse-connect="/api/live" sse-swap="time">
Loading...
</div>
The sse-swap attribute matches the event name in stream.Send() or stream.Render().
Design Notes
The application layer intentionally stays minimal:
- Middleware - Use Bouncer pattern for functional composition, or wrap
http.Handlerdirectly - Sessions/Auth - Project-specific; use Go stdlib cookies + your auth logic
- Form parsing - Use
r.FormValue()directly; no abstraction needed - JSON responses - Use
json.NewEncoder(w).Encode()directly - File uploads - Use
r.ParseMultipartForm()directly
These are covered by Go stdlib or are project-specific where opinionated logic doesn't add value.