Monorepo containing the Go service and the Nuxt frontend for Hangrix.
The Go binary embeds the prebuilt frontend so a single hangrix.exe serves
the whole app.
.
├── apps/
│ ├── hangrix/ Go HTTP service (embeds the Nuxt static bundle)
│ │ ├── cmd/hangrix/ entry point — only container init + Get[*App]
│ │ ├── conf/ YAML config (server.addr, ...)
│ │ ├── internal/
│ │ │ ├── app/ App: flag parsing, config loading, server start
│ │ │ ├── config/ viper-based config loader
│ │ │ ├── modules/ feature modules (modular monolith)
│ │ │ │ ├── hello/ handler / domain / repo / infra (as needed)
│ │ │ │ └── healthz/
│ │ │ ├── server/ chi router + RouteProvider interface
│ │ │ └── web/ //go:embed of apps/web's static output
│ │ └── scripts/copy-web-dist.mjs stages apps/web/.output/public into internal/web/dist
│ └── web/ Nuxt 4 frontend (shadcn-vue + Tailwind v4)
├── pkg/
│ ├── common/ Shared Go package
│ └── ioc/ IoC container used by apps/hangrix
├── go.work Go multi-module workspace
├── turbo.json Turborepo task graph (JS + Go)
└── pnpm-workspace.yaml
- Go >= 1.26
- Node.js >= 20
- pnpm >= 9
# install JS dependencies (all workspaces)
pnpm install
# sync go workspace
go work sync
# all tasks via Turborepo (JS + Go)
pnpm dev # turbo run dev
pnpm build # turbo run build (web#generate → embed → go build)
pnpm test # turbo run test
pnpm typecheck # turbo run typecheck
# run only the Nuxt dev server (proxies /api/** and /healthz to :8080 — see nuxt.config.ts routeRules)
pnpm --filter web dev
# run only the Go service with hot reload (air, configured at apps/hangrix/.air.toml)
pnpm --filter hangrix dev
# or one-shot without hot reload
cd apps/hangrix && go run ./cmd/hangrix
# produce the embedded single-binary build
pnpm --filter hangrix build
# → apps/hangrix/bin/hangrix.exe (serves SPA + /api/* + /healthz)apps/webis built statically withnuxi generate, producingapps/web/.output/public/.apps/hangrix/scripts/copy-web-dist.mjsstages that intoapps/hangrix/internal/web/dist/.internal/web/web.gohas//go:embed all:dist, sogo buildbakes the files into the binary.- At runtime
internal/webis registered as aserver.RouteProvidermounted at/*. chi's radix-tree matching gives specific routes (/api/hello,/healthz) precedence; everything else falls through to the SPA handler, which servesindex.htmlfor paths that don't map to a file.
Turborepo wires the dependency: hangrix#build declares dependsOn: ["web#generate"], so the static bundle is always fresh before the Go build runs.
internal/web/dist/ contents (apart from .gitkeep) are gitignored — the directory is regenerated on every build.
The Go service uses pkg/ioc for dependency injection. Each domain exposes a
Module() *ioc.Module that registers its constructors and bindings. main.go
only creates a container, self-registers the container, loads all modules,
and fetches *app.App:
c := ioc.NewContainer()
c.Provide(func() *ioc.Container { return c }).ToSelf()
c.Load(app.Module(), server.Module(), healthz.Module(), hello.Module(), web.Module())
ioc.Get[*app.App](c).Run(os.Args[1:])App depends on *ioc.Container. Inside App.Run it parses flags, loads
config, registers *config.Config into the container via Provide(...).ToSelf(),
then resolves *server.Server from the container — Server takes
*config.Config and []RouteProvider via standard DI.
Constructor rules enforced by ioc:
- Constructor must return a single value: a pointer to a struct.
- Constructor takes 0 or 1 parameters; the parameter, if present, is a pointer to a struct whose fields are interfaces, pointers-to-struct, or slices of interfaces.
- Bind with
ToSelf()for concrete-type resolution, orToInterface(new(I))to register an interface implementation. Slice fields ([]I) collect every binding forI.
Each domain lives under internal/modules/<name>/ and is composed of layered subpackages (handler / domain / repo / infra). The module root exposes a single Module() *ioc.Module that wires every layer the feature uses.
Minimum (handler only) skeleton:
internal/modules/<name>/
├── handler/handler.go # package handler — Handler struct + RegisterRoutes
└── module.go # package <name> — Module() wires handler (and later domain/repo/infra)
handler/handler.go:type Handler struct{},NewHandler() *Handler,(h *Handler) RegisterRoutes(r chi.Router). Routes the handler claims (e.g./api/<name>) are static, so chi's radix tree gives them precedence over the SPA catch-all.module.go:package <name>, exportingModule() *ioc.Modulethat doesm.Provide(handler.NewHandler).ToInterface(new(server.RouteProvider)). Add further layers here as the feature grows.- Register the module in
cmd/hangrix/main.go'sc.Load(...)list.
Server discovers the handler automatically through the []RouteProvider slice dependency.
Suggested layout once a module needs more than HTTP:
internal/modules/<name>/
├── domain/ interfaces + plain types (no framework imports)
├── infra/ concrete implementations (DB clients, HTTP clients, ...)
├── repo/ repository interfaces + impls (or fold into domain/infra)
├── handler/ chi-level HTTP entry points
└── module.go the only place that knows about every layer
Cross-module dependencies should go through the ioc container by depending on interfaces from the other module's domain/, not by importing handlers/infra directly.
internal/config/config.go uses viper:
- File: YAML at the path given by
-config(defaultconf/config.yaml). - Env override:
API_<UPPER_SNAKE>overrides any key. Example:API_SERVER_ADDR=:9090overridesserver.addr. - Defaults:
server.addrfalls back to:8080when unset everywhere.
Adding a new config field: add it to the Config struct (with mapstructure tags), put a value in conf/config.yaml, and depend on *config.Config from any constructor — it's registered into the ioc container by App.Run after flag parsing.
The web app is fully wired for shadcn-vue with Tailwind v4 (no further init needed):
apps/web/components.json— style:new-york, base color:neutral, CSS variables enabledapps/web/app/lib/utils.ts— thecnhelperapps/web/app/assets/css/tailwind.css—@import "tailwindcss",@import "tw-animate-css", full light/dark CSS variables,@theme inlinemapping,@layer base- Runtime deps in
apps/web/package.json:clsx,tailwind-merge,class-variance-authority,lucide-vue-next,reka-ui, devtw-animate-css shadcn-nuxtNuxt module is registered innuxt.config.tsso components auto-import
Add components with:
pnpm --filter web dlx shadcn-vue@latest add button --yes
pnpm --filter web dlx shadcn-vue@latest add card --yesGenerated files land in apps/web/app/components/ui/<name>/ and are auto-imported by Nuxt.
- Root
turbo.jsondefinesbuild,dev,lint,typecheck,test,generate. globalPassThroughEnvexposes Go-related env (LOCALAPPDATA,GOCACHE, ...) so Go can find its cache on Windows.apps/hangrix/turbo.jsonextends the root and addsbuild.dependsOn: ["web#generate"].pkg/common/turbo.jsonandpkg/ioc/turbo.jsondeclareoutputs: []for the library Go packages.- Approved build scripts (
esbuild,@parcel/watcher) are pinned in the rootpackage.jsonunderpnpm.onlyBuiltDependencies.