Project structure and principles
A Popcorn Web project has more than one structure. The directory tree is the most visible, but it sits between two others: the tool environment that builds and runs the application, and the request path inside one handler.
The same design choice appears at all three scales. Keep familiar Go and Web interfaces visible. Package the repetitive work around them so a project does not have to invent it again.
The development cradle
Section titled “The development cradle”During development, the application binary does not run alone. pw dev watches
its sources, generates Go, applies migrations, builds assets, starts the binary,
and replaces it after a change. Around that process it provides the local
identity provider, structured log capture, telemetry receiver, database tools,
template storybook, and diagnostics.
The outer box is a tool boundary, not a runtime dependency. pw build produces
the application binary; production runs that binary without pw, the console,
the development identity provider, or the storybook. Development is richer
because pw dev is allowed to coordinate the tools around the binary, not
because those tools have been hidden inside the release.
Principle: one command owns the environment
Section titled “Principle: one command owns the environment”A new project should not begin with a list of unrelated installations and
connection strings. pw init creates a runnable environment, and pw dev
brings it back with one command. Tools that nearly every application needs —
generation, migration, seed data, local identity, logs, traces, template
inspection, and database inspection — arrive connected to the project already.
This borrows the strongest habit of modern frontend tooling: a framework can package an existing compiler, watcher, stylesheet tool, or protocol more usefully than a README that asks every team to wire the same pieces together. The pieces remain recognizable. Tailwind is still Tailwind, OpenTelemetry is still OTLP, OIDC is still OIDC, and the running service is still a Go binary.
Two configurations, two readers
Section titled “Two configurations, two readers”The cradle also explains why the project has two kinds of configuration. They look similar because both use TOML. They answer to different programs.
| Input | Read by | Decides |
|---|---|---|
popcornweb.toml |
pw |
project root, main package, generation scopes, migrations, assets, and development tools |
config.{APP_ENV}.toml |
application binary | server, database, authentication, sessions, observability, and application settings |
| environment variables and application flags | application binary | deployment-time overrides of runtime settings |
dev.logs belongs in popcornweb.toml because it controls the process running
beside the application. server.port belongs in config.dev.toml because the
application binds it. The same distinction holds in production: the release
binary needs runtime configuration, but it has no reason to read the project
layout that built it.
Folders follow features
Section titled “Folders follow features”Inside the cradle sits an ordinary Go module. pw init starts it shallow: one
handler package and one query package. When separate areas acquire separate
owners, the tree grows by feature rather than by technical layer.
myapp/├── popcornweb.toml├── config.dev.toml├── cmd/myapp/main.go├── templates/│ ├── document.pw.html the one document shell│ └── 400|404|500.pw.html├── migrations/├── webroot/│ ├── index.go root mux; mounts the feature areas│ ├── home_handler.go│ ├── home.pw.html│ ├── admin/│ │ ├── index.go admin mux│ │ ├── dashboard_handler.go│ │ ├── dashboard.pw.html│ │ └── queries/reports.pw.sql│ └── accounts/│ ├── index.go accounts mux│ ├── signup_handler.go│ ├── signup.pw.html│ └── queries/accounts.pw.sql└── queries/ └── users.pw.sql shared by more than one featureA handler and the template it renders stay together. A feature owns the queries only it uses; a query moves upward after more than one feature actually shares it. The path therefore says who owns a change before any architecture document has to.
Each feature owns a mux
Section titled “Each feature owns a mux”A feature package has the same shape as the small scaffold:
package admin
import "github.com/shibukawayoshiki/popcornweb/pw"
var mux = pw.NewServeMux()
func Handlers() *pw.ServeMux { return mux }package admin
func init() { mux.HandleFunc("GET /dashboard", dashboard) }The root imports and mounts its children:
package webroot
func init() { mux.Handle("/admin/", http.StripPrefix("/admin", admin.Handlers())) mux.Handle("/", accounts.Handlers())}Paths inside admin are relative to its mount. The parent imports its children,
so their init functions register routes before the parent mounts them; children
never import the parent, so no package cycle appears. Subtree patterns and
http.StripPrefix are standard net/http composition.
What generation reads
Section titled “What generation reads”Generation scope is explicit and purpose-specific:
[generate]handlers = ["webroot"]templates = ["webroot", "templates"]queries = ["webroot", "queries"]config = ["cmd/myapp"]webroot/admin/queries is already covered by the webroot entry. Only a new
top-level source directory requires an edit. No purpose has an implicit default:
a missing key is an error, while [] states that the project intentionally
generates nothing for that purpose. See pw generate
for the generated outputs and out-of-scope diagnostics.
Three things remain global however many feature packages exist:
- one
document.pw.htmlshell; - one ordered migration set under
migration.dir; - one namespace shared by registered configuration prefixes.
Principle: layer by feature
Section titled “Principle: layer by feature”A top level of controllers, services, repositories, and models scatters
one feature across generic packages. It often adds request types, persistence
types, domain types, and mappers whose only new information is how to copy one
shape into another. That costs review time, binary weight, human attention, and
AI context without necessarily creating a meaningful boundary.
Popcorn Web starts with the opposite default: keep a feature internally shallow, compose features with Go packages and muxes, and extract a shared package only after shared ownership exists. A layer must earn its place by holding different knowledge or reversing a real dependency.
That does not exile domain knowledge from SQL. Schema constraints, query shape, indexes, and transaction boundaries determine what the application permits and how it fails. Generated queries keep that behavior visible; a generic CRUD repository is not inserted merely to make the database appear farther away.
One handler stays net/http
Section titled “One handler stays net/http”The smallest scale is one request. Here too, the framework surrounds a familiar center instead of replacing it.
| Concern | How it works |
|---|---|
| Unit of work | one http.Handler |
| Links | full document requests by default |
| Forms | ordinary submissions and redirects |
| Mutation | handler or application service |
| Browser default after a mutation | Post/Redirect/Get |
| Transaction boundary | explicit, via pw.Transaction |
| Client-side enhancement | optional |
Zoom out one step and the same handler sits in a conventional server stack.
http.Server accepts the connection, framework middleware wraps the mux, and
http.ServeMux selects application code. The colours in the following figure
distinguish standard library code, framework runtime, application code, and code
generated from application sources.
The code has the same shape Go developers already know:
type createMemoInput struct { Body string `form:"body" check:"required,maxlen=1000"`}
func createMemo(w http.ResponseWriter, r *http.Request) { input, err := pw.Parse[createMemoInput](r) if err != nil { pw.WriteProblem(w, r, pw.BadRequest(err)) return } if _, err := queries.CreateMemo(r.Context(), input.Body); err != nil { pw.WriteProblem(w, r, err) return } http.Redirect(w, r, "/memos", http.StatusSeeOther)}The handler still receives http.ResponseWriter and *http.Request. It owns
control flow, the status or redirect, calls to external systems, and transaction
boundaries. r.Context() remains the carrier understood by Go libraries.
What disappears is handwritten representation plumbing. pw.Parse uses a
generated binder to move path, query, header, form, or JSON input into a typed
struct and validate it. Generated query functions move typed parameters and
rows across SQL. Generated template functions accept typed parameters and
produce HTML. pw.WriteProblem and HTML response helpers write the protocol
shape consistently.
The generation boundary is finite and visible:
| Source you own | Generated Go |
|---|---|
*.pw.html |
typed component functions and parameter structs |
*.pw.sql |
typed, context-taking query functions and row scanning |
pw.Parse[T] call sites |
request binding and validation for T |
pw.WriteAPI[T] / pw.WriteStream[T] call sites |
response encoding for T |
pw.RegisterConfig[T] call sites |
startup configuration binding for T |
| all of the above | an OpenAPI 3.1 fragment |
Generated files end in _pw_gen.go and live beside their sources. They are
build output: pw generate overwrites them and pw dev regenerates them after
a relevant edit. Moving this work ahead of the request catches mismatched query
rows, missing template parameters, invalid output contexts, and binding errors
at build time. It also removes request-time reflection, which keeps TinyGo a
practical target.
Principle: preserve common sense; generate the borders
Section titled “Principle: preserve common sense; generate the borders”Replacing net/http would discard knowledge already shared by Go developers,
libraries, debuggers, and tests. Popcorn Web keeps its mux patterns, handler
signature, request context, middleware model, redirects, and status codes.
But familiarity is not a reason to hand-copy data between representations. Request binding, SQL rows, configuration, and template parameters are mechanical boundaries where generation can add type checks and better errors. The framework spends its abstraction budget there. The center remains ordinary Go.
The browser runtime follows the same rule. Standard links, forms, complete responses, typed binding, templates, errors, configuration, and OpenAPI do not depend on it. Import the server-driven update layer when a screen needs it; a minimal application does not pay for a component graph, patch protocol, or hydration dependency.
The three scales now line up. pw packages the development environment without
entering the release. Feature packages organize ownership without hiding Go’s
composition. Generated borders remove data-moving code without hiding the
net/http handler that decides what the request means.
