App

Application Structure

Learn the conventional directories and ownership boundaries of a GoLazy application.

By Guillermo Alvarez - Published Updated

The application tree

A GoLazy application keeps its executable small, places web presentation behavior under app, and keeps business services at the top level:

.mise/
  tasks/
app/
  controllers/
  helpers/
  mailers/
  public/
    assets/
    javascript/
  styles/
  views/
cmd/app/
init/
js.toml
package.json
lib/
mise.toml
secrets/
services/
test/

The structure is conventional, but every directory contains ordinary Go, standard-library templates, or static files.

app/controllers

Controllers translate HTTP requests into application work. A concrete controller embeds the application BaseController, resolves its services in New, and exposes action methods.

Controller constructors receive only context.Context:

func New(ctx context.Context) (*PostsController, error)

Controllers are request-local. Never cache a concrete controller or share it between requests because render data and response state are mutable.

app/helpers

Helpers contain template functions owned by the application. Keep them as plain Go functions and expose the template names through RegisterHelpers:

func RegisterHelpers() map[string]any

init/app.go passes that map to lazyapp.Config.Helpers, and templates call helpers by their registered names.

app/mailers

Mailers render email bodies with the same view system as controllers. Keep mailer types under app/mailers and place their templates in matching view directories:

app/mailers/poll_mailer.go
app/views/layouts/mailer.text.tpl
app/views/layouts/mailer.html.tpl
app/views/poll_mailer/admin_link.text.tpl
app/views/poll_mailer/admin_link.html.tpl

Mail delivery is configured as a service in init/context.go. The framework provides golazy.dev/lazymailer for standard-library MIME message building, SMTP delivery, memory delivery, and custom delivery implementations.

init

init/app.go is the application composition entry point. It calls lazyapp.New, passes embedded views and public files, provides the app context initializer, and points the framework at Draw.

init/context.go initializes shared application dependencies once and places them into a context.

init/routes.go is the routing table. Its public entry point is:

func Draw(router *lazyroutes.Scope)

Draw receives the framework-created route scope. It does not create or return the application handler, and it does not install dispatch middleware.

services

Services contain core application behavior that does not belong to HTTP handling or template rendering. They live outside app because the web layer is only one presentation of the business behavior. Each service defines typed WithContext and FromContext helpers so its dependency contract stays visible.

Services should be deterministic where practical and independently testable.

app/views

Views use Go's html/template syntax:

app/views/layouts/<layout>.html.tpl
app/views/<controller>/<action>.html.tpl

The default layout is layouts/app.html.tpl. Controller view names are derived from route metadata, so ordinary actions can set data and return nil instead of passing a view path manually.

app/public

Public files are embedded, registered with lazyassets, and served after application route lookup. For example:

app/public/styles.css

is available as:

/styles.css
/styles-<hash>.css

The logical path is useful for direct requests. Templates should call asset_path to link the content-hashed permanent path. Explicit application routes take precedence over public files.

The sample app keeps browser JavaScript source outside public files and generated browser JavaScript under public files:

app/js/app.js
app/js/controllers/hello_controller.js
app/public/assets/importmap.json
app/public/assets/lazyshaft/

app/js is app-owned source. The assets/lazyshaft files and importmap are generated by lazy js from js.toml and app/js.

app/styles

Stylesheet source files that need a build step live outside public files. The default Tailwind input is:

app/styles/application.css

lazy tailwind compiles that source into:

app/public/styles.css

Keep templates linked to /styles.css through stylesheet; the output remains a normal public asset and receives the same fingerprinted permalink, ETag, and cache policy as any other public file.

Read Stylesheets for the full Tailwind workflow.

js.toml

js.toml declares JavaScript library entrypoints for lazy js. Keep it small:

[entrypoint.turbo]
module = "@hotwired/turbo"

[entrypoint.stimulus]
module = "@hotwired/stimulus"

Read lazy js for the full manifest shape and verification workflow.

mise.toml and secrets

The sample app includes a mise.toml development toolchain file. It installs Node.js and the age, sops, and usage command-line tools, and loads .secrets/development.env for commands run through mise. It does not install Go because Go already bundles multi-version support through the module go directive and toolchain selection. Mise requires a one-time trust approval before reading this config because it loads an environment file; lazy new performs that trust step for generated apps.

The checked-in .secrets/development.env file is only for development examples. Applications should read ordinary environment variables, such as SECURE_COOKIE_KEY, and production deployments are responsible for supplying real values through their platform or secret store.

Standalone mise task scripts live under .mise/tasks. The sample app includes .mise/tasks/hello.go, which mise discovers as hello and runs with:

mise run hello

This keeps small project scripts close to the app while avoiding extra mise.toml entries.

cmd/app

The executable initializes the application and starts it with lazyapp.App.ListenAndServe. The framework uses ADDR, then PORT, then :3000 as the listen address.

Keep business logic out of main. It should remain possible to construct the complete handler in tests without starting a network listener.

lib

Application-specific adapters can live in lib. The sample app keeps its Goldmark adapter in lib/markdown, leaving the framework independent of that third-party dependency.

test

Package tests remain next to their code. Full application integration tests live in test and construct the same application handler used by the executable.

Framework boundaries

Generic behavior belongs in the golazy.dev module:

  • golazy.dev/lazycontroller owns controller rendering and typed HTTP errors.
  • golazy.dev/lazyroutes owns route scopes, route metadata, controller action routing, and REST resources.
  • golazy.dev/lazydispatch owns middleware dispatch, route-only response buffering, dynamic route ETags, and request flow.
  • golazy.dev/lazyassets owns public and generated asset registration, fingerprints, integrity values, ETags, cache policy, and unpacking.
  • golazy.dev/lazysse owns Server-Sent Events formatting and flushing.
  • golazy.dev/lazyapp wires the application context, views, routes, dispatcher, and public files into one handler.
  • golazy.dev/lazyview owns rendering, helpers, and template engines.

Application packages must not be imported by the framework.