This document describes the concrete architecture of the hagg project.
It explains not only how the code is structured, but more importantly why.
Throughout this document, we refers to the authors and maintainers of this project — this is our shared baseline.
Project status:
haggis still early WIP (work in progress). The structure is intentionally simple, and we expect it to evolve as we learn.The journey matters: This architecture is the result of exploration, discussion, and iteration. We document our decisions and trade-offs openly. If something seems unconventional, there's likely a reason — read on!
The application follows a server-centric architecture:
- HTML is rendered on the server (Gomponents)
- Interaction happens over HTTP (HTMX)
- surreal.js provides syntactic sugar for DOM operations
- Alpine.js is used for small client-side state only
- The backend stays explicit and boring (Chi, middleware, plain Go)
This baseline exists to make "normal web apps" feel straightforward again: forms, tables, and stateful pages — without SPA complexity.
We are not professional web developers, nor specialists in frontend or backend frameworks. This is a hobby project, created to explore ideas, deepen understanding, and learn by building real things.
So this architecture should be read as:
- Honest and practical
- Experience-driven, not academic
- Open to correction and improvement as our understanding grows
- Process-oriented — the journey and learning are as important as the output
- Chi matches a route.
- Middleware runs (HTMX triggers, auth, permissions).
- Wrapper creates a
handler.Contextand calls the handler. - A handler renders Gomponents into
handler.Context. - The wrapper commits events (via
HX-Triggerheader or initial-events script). - HTMX endpoints return partial HTML or trigger client-side updates.
- Frontend processes events (toast, auth-changed, etc.) with surreal.js.
The important part: handlers are simple functions that work with a lightweight context wrapper.
sequenceDiagram
participant B as Browser
participant C as Chi Router
participant M as Middleware
participant W as Wrapper
participant H as Handler
participant R as Renderer (Gomponents)
B->>C: HTTP request
C->>M: HTMX triggers, auth, permissions
M-->>C: ok or abort
C->>W: create context
W->>H: call handler(ctx)
H->>R: render gomponents tree
R-->>W: HTML
W-->>B: HTML + event headers (HX-Trigger)
We use Chi v5 as our HTTP router.
Why Chi?
- stdlib-compatible (
http.Handler,http.HandlerFunc) - Minimal, no framework overhead
- Composable middleware
- Idiomatically Go
Example routing (from routes.go):
func AddRoutes(r chi.Router, wrapper *handler.Wrapper, deps app.Deps) {
// Public routes
r.Get("/", wrapper.Wrap(home.Page(deps)))
r.Get("/login", wrapper.Wrap(login.Page(deps)))
// HTMX endpoints
r.Post("/htmx/login", wrapper.Wrap(login.HxLogin(deps)))
r.Post("/htmx/logout", wrapper.Wrap(login.HxLogout(deps)))
// Protected routes (require authentication + permission)
r.Group(func(r chi.Router) {
r.Use(middleware.RequirePermission(deps.Auth, deps.Users, deps.Perms, "dashboard:view"))
r.Get("/dashboard", wrapper.Wrap(dashboard.Page(deps)))
})
}We use a lightweight context wrapper instead of frameworks like Gin or Echo.
The handler.Context type:
type Context struct {
Res http.ResponseWriter // Explicit field (no embedding)
Req *http.Request // Explicit field (no embedding)
logger *slog.Logger
events []Event
}Why explicit fields?
- No embedding = no interface pollution
- Compatible with all stdlib middleware
- Clear ownership and access patterns
Helper methods (minimal):
func (c *Context) Render(node g.Node) error
func (c *Context) Toast(msg string) *toast.Builder
func (c *Context) Event(name string, data any)
func (c *Context) Logger() *slog.LoggerHandler pattern:
type HandlerFunc func(*Context) error
func (w *Wrapper) Wrap(h HandlerFunc) http.HandlerFunc {
return func(res http.ResponseWriter, req *http.Request) {
ctx := &Context{
Res: res,
Req: req,
logger: w.logger,
events: make([]Event, 0),
}
if err := h(ctx); err != nil {
w.logger.Error("handler error", "error", err)
http.Error(res, "Internal Server Error", 500)
}
// Commit events (HX-Trigger or initial-events)
}
}Usage in handlers (Factory Pattern):
Handlers use the factory pattern — a function that takes dependencies and returns a handler.HandlerFunc:
func Page(deps app.Deps) handler.HandlerFunc {
return func(ctx *handler.Context) error {
user, _ := deps.Auth.CurrentUser(ctx.Req)
content := Div(
H1(g.Text("Dashboard")),
P(g.Textf("Welcome, %s!", user.FullName())),
)
return ctx.Render(layout.Page(ctx, deps, content))
}
}Why the factory pattern?
- Dependencies are captured in the closure — no global state
- Handler signature is clean (
func(*Context) error) - Easy to test (inject mock dependencies)
We use alexedwards/scs for session management.
Why scs?
- stdlib-compatible
- Multiple store backends (cookie, SQLite, Postgres, Redis)
- Simple API
- Actively maintained
Default: SQLite-backed sessions (persistent)
Sessions are stored in SQLite, meaning they persist across server restarts:
// internal/session/manager.go
func Init(dbPath string) error {
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
return err
}
Manager = scs.New()
Manager.Store = sqlite3store.New(db)
Manager.Lifetime = 24 * time.Hour
Manager.Cookie.Name = "hagg_session"
Manager.Cookie.HttpOnly = true
Manager.Cookie.Secure = false // Set to true in production (HTTPS)
return nil
}Global Manager Pattern:
The session manager is accessed globally via session.Manager:
// Get value
uid := session.Manager.GetString(ctx.Req.Context(), "uid")
// Set value
session.Manager.Put(ctx.Req.Context(), "uid", userUID)
// Flash messages (pop = get and remove)
msg := session.Manager.PopString(ctx.Req.Context(), "flash_success")Session Middleware:
The session middleware must be registered early in the middleware stack:
r.Use(session.Manager.LoadAndSave) // MUST come before auth middlewareHTMX powers progressive enhancement:
- Form submissions without JavaScript
- Partial page updates
- Polling and lazy loading
- WebSocket support
Example:
<button hx-post="/logout" hx-swap="none">Logout</button>The backend responds with HX-Trigger: auth-changed, and the frontend refreshes the nav.
Alpine.js handles client-side state (dropdowns, modals, tabs):
<div x-data="{ open: false }">
<button @click="open = !open">Toggle</button>
<div x-show="open">Content</div>
</div>Important: Alpine is for UI state only, not application state.
surreal.js provides syntactic sugar for DOM operations:
// Vanilla JS
document.getElementById('toast-container')
// surreal.js
me('#toast-container')Why surreal.js?
- Reduces boilerplate in event handlers
- Chainable API (
me(el).classAdd('foo').classRemove('bar')) - Small (~2KB minified)
- No build step
Example (toast rendering):
function showToast({ message, level = 'info' }) {
const container = me('#toast-container-bottom-right')
const toastHtml = `
<div class="toast toast-${level} opacity-0">
${message}
</div>
`
container.insertAdjacentHTML('beforeend', toastHtml)
const toast = container.lastElementChild
me(toast).classRemove('opacity-0')
setTimeout(() => {
me(toast).classAdd('opacity-0')
setTimeout(() => me(toast).remove(), 300)
}, 5000)
}We use Bootstrap 5.3 loaded via CDN.
Why Bootstrap?
- Battle-tested, well-documented
- No build step required
- Native dark mode support (
data-bs-theme) - Rich component library
CDN integration (in skeleton.go):
// Bootstrap CSS
Link(Rel("stylesheet"), Href("https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"))
// Bootstrap Icons
Link(Rel("stylesheet"), Href("https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css"))
// Bootstrap JS (end of body)
Script(Src("https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"))Dark mode:
Bootstrap 5.3 supports dark mode natively via data-bs-theme="dark" on <html>.
Alpine.js persists the theme preference.
Custom overrides:
Minimal custom CSS in static/css/app.css for project-specific styles.
The event system is server-driven and header-based.
For HTMX requests, events are sent via the HX-Trigger response header:
HX-Trigger: {"toast": {"message": "Success!", "level": "success"}}The frontend listens with:
htmx.on('toast', (e) => {
showToast(e.detail)
})For full page loads, events are rendered as JSON in the HTML:
<script type="application/json" id="initial-events">
[
{"name": "toast", "payload": {"message": "Welcome!", "level": "info"}},
{"name": "auth-changed", "payload": null}
]
</script>The frontend processes on load:
document.addEventListener('DOMContentLoaded', () => {
const initialEventsEl = me('#initial-events')
if (initialEventsEl) {
const events = JSON.parse(initialEventsEl.textContent)
events.forEach(processEvent)
initialEventsEl.remove()
}
})Both types feed into the same event processor:
function processEvent(event) {
switch(event.name) {
case 'toast':
showToast(event.payload)
break
case 'auth-changed':
htmx.trigger(me('#nav'), 'refresh')
break
case 'permission-denied':
// ... handle permission denial
break
}
}Emit an event:
ctx.Event("auth-changed", nil)Toast shorthand:
ctx.Toast("Operation successful!").Success().Notify()Under the hood (toast):
func (t *Toast) Notify() {
t.ctx.Event("toast", t)
}sequenceDiagram
participant H as Handler
participant C as Context
participant W as Wrapper
participant B as Browser
participant F as Frontend (JS)
H->>C: ctx.Toast("Success!").Success().Notify()
C->>C: append Event{"toast", {...}}
H->>W: return nil (success)
alt HTMX Request
W->>B: HX-Trigger: {"toast": {...}}
B->>F: htmx.on('toast', ...)
F->>F: showToast(...)
else Full Page Load
W->>B: HTML with <script id="initial-events">
B->>F: DOMContentLoaded → process events
F->>F: showToast(...)
end
The toast system is event-driven and unified.
Toast builder:
type Toast struct {
Message string `json:"message"`
Level string `json:"level"` // success, error, warning, info
Timeout int `json:"timeout"` // ms, 0 = stay forever
Position string `json:"position"` // bottom-right, top-right, etc.
}Fluent API:
ctx.Toast("User created")
.Success()
.SetTimeout(3000)
.SetPosition("top-right")
.Notify()Toast rendering (using Bootstrap Toast component):
function showToast({ message, level = 'info', timeout = 5000 }) {
var container = document.getElementById('toast-container');
if (!container) {
container = document.createElement('div');
container.id = 'toast-container';
container.className = 'toast-container position-fixed bottom-0 end-0 p-3';
document.body.appendChild(container);
}
var config = getToastConfig(level); // returns { icon, border } based on level
var html = '<div class="toast align-items-center bg-body-secondary shadow border-0 border-start border-4 ' + config.border + '">' +
'<div class="d-flex"><div class="toast-body d-flex align-items-center gap-2">' +
config.icon + message +
'</div><button type="button" class="btn-close me-2 m-auto" data-bs-dismiss="toast"></button></div></div>';
container.insertAdjacentHTML('beforeend', html);
var toastEl = container.lastElementChild;
var toast = new bootstrap.Toast(toastEl, { delay: timeout });
toast.show();
toastEl.addEventListener('hidden.bs.toast', function() { toastEl.remove(); });
}- Unified: One
showToast()function for HTMX and full-page loads - Server-driven: Backend controls notification logic
- Bootstrap-native: Uses Bootstrap's Toast component
- Flexible: Supports different levels and timeouts
Authentication is session-based and deliberately minimal:
- We store the logged-in user UID in the session (key:
uid) Auth.CurrentUser(req)reads the session and loads the user fromuser.Store- See
internal/auth/auth.gofor implementation
Auth struct:
type Auth struct {
users user.Store
}
func New(users user.Store) *Auth {
return &Auth{users: users}
}
func (a *Auth) CurrentUser(req *http.Request) (*user.User, error) {
rawUID := session.Manager.Get(req.Context(), SessionKeyUID)
uid, ok := rawUID.(string)
if !ok || uid == "" {
return nil, ErrNotAuthenticated
}
return a.users.FindByUID(req.Context(), uid)
}
func (a *Auth) IsAuthenticated(req *http.Request) bool {
rawUID := session.Manager.Get(req.Context(), SessionKeyUID)
uid, ok := rawUID.(string)
return ok && uid != ""
}Middleware (RequireAuth):
// RequireAuth ensures user is logged in (UID in session)
func RequireAuth(wrapper *handler.Wrapper) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
rawUID := session.Manager.Get(r.Context(), auth.SessionKeyUID)
uid, ok := rawUID.(string)
if !ok || uid == "" {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
next.ServeHTTP(w, r)
})
}
}RequireGuest middleware:
For pages that should only be accessible to non-authenticated users (login, register):
// RequireGuest ensures user is NOT logged in
func RequireGuest(wrapper *handler.Wrapper) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
rawUID := session.Manager.Get(r.Context(), auth.SessionKeyUID)
uid, ok := rawUID.(string)
if ok && uid != "" {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
next.ServeHTTP(w, r)
})
}
}Important: This section describes a reference implementation that you can adapt or discard based on your application's needs. Not every app needs fine-grained permissions!
Use Casbin-based authorization when:
- Multi-user apps with different roles (admin, editor, viewer)
- Fine-grained access control (user:create, user:delete, report:export)
- Permissions change without code (policy file edits, no rebuild)
Skip Casbin and remove it entirely when:
- Single-user tools — just use
RequireAuth - Simple CRUD apps — resource ownership checks in handlers are enough
- "Logged in = full access" —
RequireAuthis sufficient
If your app only needs "logged in or not", use RequireAuth and delete the Casbin files.
We use Casbin as the authorization engine:
model.confdefines the evaluation model (RBAC)policy.csvdefines roles/permissions and user-role assignmentscasbinx.NewFileEnforcer()from hagg-lib loads both files
Initialization (in server.go):
enforcer, err := casbinx.NewFileEnforcer(
cfg.Casbin.ModelPath, // default: "model.conf"
cfg.Casbin.PolicyPath, // default: "policy.csv"
)
if err != nil {
log.Fatal(err)
}
deps := app.Deps{
// ...
Enforcer: enforcer,
Perms: casbinx.NewPerm(enforcer),
}Why file-based?
When we ship a single binary, we can place model.conf and policy.csv next to it.
No database required for authorization.
We model permissions as plain action strings:
dashboard:viewuser:createuser:listuser:delete
This keeps the policy readable and avoids "object explosion" early on.
Example policy (policy.csv):
# Superuser: can do everything
p, superuser, *
# Admin role
p, admin, dashboard:view
p, admin, user:create
p, admin, user:list
p, admin, user:delete
# Viewer role (read-only)
p, viewer, dashboard:view
p, viewer, user:list
# User → Role assignments
g, arudolf, superuser
g, alice, admin
g, worker, viewerThe RequirePermission middleware combines authentication and authorization:
func RequirePermission(authService *auth.Auth, users user.Store, perms *casbinx.Perm, action string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Step 1: Check authentication
rawUID := session.Manager.Get(r.Context(), auth.SessionKeyUID)
uid, ok := rawUID.(string)
if !ok || uid == "" {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
// Step 2: Load user for authorization
u, err := users.FindByUID(r.Context(), uid)
if err != nil {
http.Error(w, "User not found", http.StatusUnauthorized)
return
}
// Step 3: Check permission via Casbin
// Subject is DisplayName (adapt if your policy uses UID or email)
if !perms.Can(u.DisplayName, action) {
http.Error(w, "Permission denied", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
}Usage:
r.Group(func(r chi.Router) {
r.Use(middleware.RequirePermission(deps.Auth, deps.Users, deps.Perms, "dashboard:view"))
r.Get("/dashboard", wrapper.Wrap(dashboard.Page(deps)))
})This implementation uses DisplayName as the Casbin subject. Depending on your needs:
- UID as subject — unique and stable, but less readable in policy.csv
- Email as subject — readable, but may change
- Role in session — skip user lookup entirely
Adapt the middleware to match your policy design.
sequenceDiagram
participant B as Browser
participant M as Middleware
participant S as Session
participant U as UserStore
participant C as Casbin
participant H as Handler
B->>M: request
M->>S: Get(uid)
alt not logged in
M-->>B: redirect /login
else logged in
M->>U: FindByUID(uid)
M->>C: Can(displayName, action)
alt denied
M-->>B: 403 Forbidden
else allowed
M->>H: next.ServeHTTP()
H-->>B: HTML response
end
end
We use Gomponents for type-safe HTML rendering.
Key idea:
- A page is a factory function:
func(deps app.Deps) handler.HandlerFunc - The factory captures dependencies and returns a handler
- The handler renders Gomponents into
handler.Context
Example page (Factory Pattern):
// internal/frontend/pages/dashboard/page.go
func Page(deps app.Deps) handler.HandlerFunc {
return func(ctx *handler.Context) error {
user, _ := deps.Auth.CurrentUser(ctx.Req)
content := Div(
Class("container"),
H1(g.Text("Dashboard")),
P(g.Textf("Welcome, %s!", user.FullName())),
)
return ctx.Render(layout.Page(ctx, deps, content))
}
}Render helper (from hagg-lib):
func (c *Context) Render(node g.Node) error {
c.Res.Header().Set("Content-Type", "text/html; charset=utf-8")
return node.Render(c.Res)
}Why Gomponents?
- Type-safe (compile-time checking)
- No templates (no runtime parsing)
- Pure Go (no external DSL)
- Composable (functions returning nodes)
Why the factory pattern?
- Dependencies are captured at route registration time
- Handler signature stays clean (
func(*Context) error) - Easy to test — inject mock dependencies
server.go # Server startup, buildRouter(), middleware stack
routes.go # Route definitions (AddRoutes function)
model.conf # Casbin RBAC model
policy.csv # Casbin policies (roles → actions, users → roles)
justfile # Task runner (dev, build, test, etc.)
cmd/
main.go # CLI entry point (urfave/cli)
internal/
app/
deps.go # Dependency container (Deps struct)
auth/
auth.go # Session-based authentication
config/
config.go # Environment config loading (.env support)
db/
sqlite.go # Database connection setup
frontend/
layout/
skeleton.go # HTML skeleton (<html>, <head>, <body>)
page.go # Page wrapper with layout
nav.go # Navigation component
events.go # Initial-events renderer
pages/
home/ # Homepage
page.go
login/ # Login page + HTMX handlers
page.go
components.go
handler.go
dashboard/ # Protected dashboard
page.go
middleware/
auth.go # RequireAuth, RequireGuest
permission.go # RequirePermission (Casbin-based)
chi.go # Logger, Recovery, CORS, RateLimit
session/
manager.go # SCS session manager (SQLite backend)
ucli/
serve.go # CLI serve command
user.go # CLI user management
user/
model.go # User domain model
store.go # Store interface
store_sqlite/
store.go # SQLite implementation
migrations/
001_initial.sql # Initial schema
002_sessions.sql # SCS sessions table
static/
css/
app.css # Custom CSS overrides (Bootstrap is loaded via CDN)
js/
app.js # Main application logic
toast.js # Toast notification system
surreal.min.js # surreal.js library
alpine.min.js # Alpine.js (local copy)
htmx.min.js # HTMX (local copy)
The composition root is split between cmd/main.go (CLI) and server.go (server setup).
CLI (cmd/main.go):
- Parse CLI flags and commands (urfave/cli)
- Load config
- Open database
- Run migrations
- Delegate to
hagg.StartServer()
Server (server.go):
- Initialize session manager
- Build Chi router with middleware
- Wire dependencies
- Start HTTP server (TCP or Unix socket)
buildRouter function (from server.go):
func buildRouter(cfg *config.Config, usrStore user.Store) http.Handler {
// Create logger
logger := slog.Default()
// Create handler wrapper (from hagg-lib)
wrapper := handler.NewWrapper(logger)
// Casbin enforcer (from hagg-lib)
enforcer, err := casbinx.NewFileEnforcer(
cfg.Casbin.ModelPath,
cfg.Casbin.PolicyPath,
)
if err != nil {
log.Fatal(err)
}
// Dependencies
deps := app.Deps{
Users: usrStore,
Auth: auth.New(usrStore),
Enforcer: enforcer,
Perms: casbinx.NewPerm(enforcer),
}
// Create Chi router
r := chi.NewRouter()
// Middleware stack (order matters!)
r.Use(chimw.RealIP) // Extract real IP from proxy headers
r.Use(chimw.Compress(5)) // Gzip compression
r.Use(session.Manager.LoadAndSave) // SCS sessions (MUST be early!)
r.Use(middleware.Recovery(wrapper)) // Panic recovery
r.Use(middleware.Logger(wrapper)) // Request logging
r.Use(middleware.CORS()) // CORS headers
r.Use(middleware.RateLimit) // Rate limiting
r.Use(libmw.Secure) // Security headers
// Static files
fs := http.FileServer(http.Dir("./static"))
r.Handle("/static/*", http.StripPrefix("/static/", fs))
// Application routes
AddRoutes(r, wrapper, deps)
return r
}Deps struct (internal/app/deps.go):
type Deps struct {
Users user.Store
Auth *auth.Auth
Enforcer *casbin.Enforcer
Perms *casbinx.Perm
}This separation keeps CLI concerns separate from HTTP wiring.
As we add real pages and features, we expect:
- More page packages under
internal/frontend/pages - More store implementations (in-memory/dev vs sqlite vs postgres)
- Richer authorization (maybe object-based rules later)
- Better error handling and observability (structured logging, metrics)
- Improved event system (more event types, better error handling)
The goal is not stability at all costs, but clarity during growth.
- Simplicity — minimal abstractions, boring code
- Explicitness — no magic, clear data flow
- Replaceability — swap persistence, sessions, auth without rewriting everything
- Learning — we document decisions, iterate, and improve as we learn
- Performance at scale — this is a baseline for small-to-medium apps
- Enterprise patterns — no hexagonal architecture, no CQRS, no event sourcing
- Frontend richness — no SPA, no reactive framework, no virtual DOM
- More boilerplate than frameworks (but we know what each line does)
- Less magic than ORMs (but we control our queries)
- CDN dependency for Bootstrap (but no build step needed)
- Go stdlib philosophy — simple, explicit, composable
- HTMX — HTML over the wire, progressive enhancement
- Pico.css — clean, minimal design
- Gomponents — type-safe templates
- Chi — stdlib-compatible routing
- alexedwards — session management patterns (scs)
- surreal.js — syntactic sugar without build complexity
This architecture is not perfect, and it's not finished.
It's the result of exploration, discussion, and iteration. We document our decisions, discuss trade-offs, and learn by building.
If you're looking for production-ready stability, wait for v1.0.0. If you want to understand why things work the way they do, welcome aboard!
The journey matters as much as the destination.