readysite / docs / application.md
12.2 KB
application.md

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:

  1. During registration, each controller is added as a template function
  2. The function calls controller.Handle(request) and returns the controller copy
  3. Template methods are then called on that copy (e.g., .Message, .All)
  4. 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:

  1. Monitoring: HTTP status codes won't catch errors. Use log-based monitoring instead.
  2. Error logging: Always log errors server-side before calling RenderError.
  3. 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-Trigger header to show toast notifications
  • Use hx-swap-oob to 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:

  1. Startup: views/layouts/ and views/partials/ are pre-loaded as shared base templates
  2. 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.Handler directly
  • 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.

← Back