19 KiB
Wiring Conventions
Forging a service is mostly wiring. Do it the same way every time.
This is not an Einherjar module — it is the canonical application shape that uses
Einherjar modules. Apps live in their own repository with an internal/wire/ package that
mirrors this template. The conventions here are distilled from production services built on
Einherjar v1 (iron-dough-api, pei-api) and describe the one opinionated minimum a
scaffolded app should have. You can hand-roll something else — but then it is yours to
maintain, and it will not match what the rest of the ecosystem reads at a glance.
The opinionated minimum
Every scaffolded Einherjar application has, at minimum:
- A clean
main.go— nothing but.envautoload and a call towire.Run(). - An
internal/wire/package — one file per feature pluswire.go, which assembles everything. - An
internal/config/config.go— one globalConfigthat composes the framework's component configs, loaded from the environment withcaarlos0/env. - A
.env.examplekept in lock-step with that config (see Config & .env.example).
Anything a developer freely chooses — how migrations run, how the first admin is seeded, an init-by-endpoint/webhook/email flow — is not part of this convention and is left to the app.
Project layout
cmd/<app>/main.go one-line entrypoint: godotenv autoload + wire.Run()
internal/wire/wire.go Run() — loads config, builds infra, registers feature hooks
internal/wire/<feature>.go one file per feature, hosts a with<Feature> hook
internal/wire/middleware.go authz, skipPublicPaths, skipMethodPath helpers
internal/config/config.go global Config composing framework component configs
.env.example every env var the config reads, documented, in sync
internal/<feature>/dto/ request/response DTOs
internal/<feature>/handler/ HTTP handlers
internal/<feature>/repository/ data access
internal/<feature>/service/ domain logic
main.go
cmd/<app>/main.go contains nothing but the .env autoload and the call to wire.Run().
The blank import _ "github.com/joho/godotenv/autoload" is the standard, documented way to load
a local .env — it never overrides variables already set in the real environment, and a missing
file is not an error, so deployments (vars injected by the platform, no .env) are unaffected.
package main
import (
"fmt"
"os"
_ "github.com/joho/godotenv/autoload"
"myapp/internal/wire"
)
func main() {
if err := wire.Run(); err != nil {
fmt.Fprintln(os.Stderr, "fatal:", err)
os.Exit(1)
}
}
No config parsing, no component construction, no logging setup — all of that lives in
internal/wire/. A main.go that builds anything itself is the single most common scaffolding
mistake.
Config
internal/config/config.go is one Config struct that composes the framework's component
configs as nested fields, alongside the app's own settings. caarlos0/env recurses into the
nested fields, so each Einherjar component's EINHERJAR_* env tags load automatically next to
the app-owned fields. App-owned fields use the APP_* prefix so they never collide with the
framework's EINHERJAR_* namespace. There is exactly one Load().
package config
import (
"time"
"github.com/caarlos0/env/v11"
"code.nochebuena.dev/einherjar/core/logz"
"code.nochebuena.dev/einherjar/db-postgres"
"code.nochebuena.dev/einherjar/web/server"
)
// JWTConfig is app-owned: the secret is handed to the signer in code, not consumed
// by a framework component, so it carries no EINHERJAR_ prefix.
type JWTConfig struct {
Secret string `env:"APP_JWT_SECRET,required,notEmpty"`
Issuer string `env:"APP_JWT_ISSUER" envDefault:"myapp"`
AccessTTL time.Duration `env:"APP_JWT_ACCESS_TTL" envDefault:"1h"`
RefreshTTL time.Duration `env:"APP_JWT_REFRESH_TTL" envDefault:"168h"`
}
// Config is the fully-resolved startup configuration. Einherjar component configs
// are nested fields; caarlos0/env recurses into them, populating their
// EINHERJAR_SERVER_* / EINHERJAR_PG_* tags from the environment.
type Config struct {
AppEnv string `env:"APP_ENV" envDefault:"local"`
JWT JWTConfig
// Framework component configs — composed verbatim. Their own EINHERJAR_* tags
// load through this one env.Parse call.
Log logz.Config // EINHERJAR_LOG_*
Server server.Config // EINHERJAR_SERVER_* (incl. EINHERJAR_SERVER_CORS_ORIGINS)
PG postgres.Config // EINHERJAR_PG_*
}
func Load() (Config, error) {
var cfg Config
if err := env.Parse(&cfg); err != nil {
return Config{}, err
}
return cfg, nil
}
Never read framework env vars (EINHERJAR_*) with os.Getenv — compose the component's Config
type and let caarlos0/env load it. A raw os.Getenv("EINHERJAR_PG_HOST") in application code is
the mistake this convention removes.
Config & .env.example
Every environment variable the config package reads must also appear in .env.example at
the repo root, documented. The two are kept in lock-step: when a feature introduces a new env
var, the same change adds its env:"..." tag to config and a documented line to
.env.example. This is not optional bookkeeping — it is what stops a long feature from shipping
and then failing at boot because nobody knew which variables to set.
Two kinds of var, two ways to keep them honest:
- Framework component vars (
EINHERJAR_*) — when you compose a new component later (e.g.cachevalkey.Config,minio.Config,smtp.Config), the MCP knows its real vars: callget_config_env("<module>")for the exact set (name, required, default) and add each to.env.example. Theconfig.unknown-env-varrule rejects anyEINHERJAR_*tag the framework doesn't declare, so a typo likeEINHERJAR_PG_DATABASEis caught atvalidate_snippettime. - App-owned vars (
APP_*) — likeAPP_JWT_SECRETabove: the framework can't know these, so keeping them in.env.exampleis your discipline, not something it can name-check.
After you compose a component, run check_env with what the app composes: it flags
EINHERJAR_* names that don't exist, required vars you forgot to document, and vars set for a
config you don't actually compose (dead vars). Prefer the composes input (exact struct
selectors like web/server/Config) over modules — it catches struct-level dead vars (e.g.
EINHERJAR_HEALTH_CHECK_TIMEOUT lives on web/health.Config, so it is dead if you compose
web/server/Config but not the health config). get_scaffold already emits a .env.example
derived from these same tags, so the starting point is correct by construction.
# .env.example — copy to .env for local dev. Every var the app reads lives here.
# ── App ───────────────────────────────────────────────────────────────────
APP_ENV=local
# CORS: local uses mw.CORSAllowAll(); non-local reads EINHERJAR_SERVER_CORS_ORIGINS (below).
APP_JWT_SECRET=change-me
APP_JWT_ISSUER=myapp
# ── Einherjar: logging (EINHERJAR_LOG_*) ──────────────────────────────────
# EINHERJAR_LOG_LEVEL=INFO
# EINHERJAR_LOG_JSON=false
# ── Einherjar: HTTP server (EINHERJAR_SERVER_*) ───────────────────────────
EINHERJAR_SERVER_HOST=0.0.0.0
EINHERJAR_SERVER_PORT=8080
# EINHERJAR_SERVER_CORS_ORIGINS — explicit origins for non-local envs (comma-separated).
# "*" is rejected by mw.CORS; local dev uses mw.CORSAllowAll() and ignores this.
# EINHERJAR_SERVER_CORS_ORIGINS=
# ── Einherjar: PostgreSQL (EINHERJAR_PG_*) ────────────────────────────────
EINHERJAR_PG_HOST=localhost
EINHERJAR_PG_PORT=5432
EINHERJAR_PG_USER=postgres
EINHERJAR_PG_PASSWORD=postgres
EINHERJAR_PG_NAME=myapp
To discover the full set for any component you compose, use get_config_env("<module>") rather
than reading source by hand; every var it returns belongs in .env.example, and check_env
confirms none are missing, misspelled, or dead.
wire.go — Run()
The application entry point. The order below is load-bearing: configuration first, observability
second, infrastructure third, cross-cutting helpers fourth, then the launcher with every component
appended, then feature hooks, then lc.Run().
web.New vs server.New — pick the right tier:
web.New(logger, web.Config{Server: cfg.Server})— batteries-included. It pre-wires the recommended middleware stack (Recover → RequestID → RequestLogger) and appliesmw.CORSfromEINHERJAR_SERVER_CORS_ORIGINSautomatically (explicit origins only; empty ⇒ CORS off + a log line). Use it for a plain service that just needs the defaults. It does not support allow-all CORS or a custom middleware order.server.New(logger, cfg.Server, server.WithMiddleware(...))— full control. You compose the middleware list yourself. Use it when you need a custom middleware order, extra middleware (auth, enrichment), a custom request-ID generator, or allow-all CORS in local dev (mw.CORSAllowAll, gated byAppEnv— see below). The starter below usesserver.Newprecisely because it inserts JWT auth + enrichment into the stack.
Whichever tier you pick, CORS origins always come from the framework var
EINHERJAR_SERVER_CORS_ORIGINS (cfg.Server.CORSOrigins) — never invent an app-owned CORS var.
CORS has one home:
server.Config.CORSOrigins. In frameworkv1.x,web.Configcarried anAllowedOriginsfield. It was env-backed throughv1.1.xand a code-only override inv1.2.0— reading it after the env tag moved silently served no CORS.v1.3.0removed the field entirely so the mistake fails at compile time instead of at runtime. If you are migrating code that readweb.Config.AllowedOriginsor set it in a struct literal, switch tocfg.Server.CORSOrigins:// v1.x (removed) — compiled but could serve no CORS after v1.2.0: // mw.CORS(cfg.Web.AllowedOrigins) // web.New(logger, web.Config{AllowedOrigins: origins}) // v1.3.0 — the single source of truth: mw.CORS(cfg.Server.CORSOrigins) // server.New tier web.New(logger, web.Config{Server: cfg.Server}) // web.New reads it automatically
validate_snippetflags any lingeringAllowedOriginsreference (web.allowedorigins-removed).
package wire
import (
"github.com/google/uuid"
authjwt "code.nochebuena.dev/einherjar/auth-jwt"
"code.nochebuena.dev/einherjar/auth/authmw"
"code.nochebuena.dev/einherjar/auth/rbac"
"code.nochebuena.dev/einherjar/core/launcher"
"code.nochebuena.dev/einherjar/core/logz"
"code.nochebuena.dev/einherjar/core/valid"
"code.nochebuena.dev/einherjar/db-postgres"
"code.nochebuena.dev/einherjar/web/mw"
"code.nochebuena.dev/einherjar/web/server"
"myapp/internal/config"
)
func Run() error {
cfg, err := config.Load()
if err != nil {
return err
}
// logz.Config is composed in config, so EINHERJAR_LOG_LEVEL / _JSON load from
// the environment; StaticArgs are set here (they carry no env tag).
logCfg := cfg.Log
logCfg.StaticArgs = []any{"service", "myapp", "env", cfg.AppEnv}
logger := logz.New(logCfg)
signer := authjwt.NewHMACSigner([]byte(cfg.JWT.Secret))
publicPaths := []string{
"/health",
"/api/v1/auth/login",
"/api/v1/auth/refresh",
}
db := postgres.New(logger, cfg.PG)
// CORS convention: allow-all in local dev, explicit origins everywhere else.
// Origins come from the framework's own EINHERJAR_SERVER_CORS_ORIGINS
// (cfg.Server.CORSOrigins) — never invent an APP_CORS_ORIGINS var. mw.CORS panics
// on "*" (it matches no real origin) — allow-all is mw.CORSAllowAll, never a "*".
corsMW := mw.CORSAllowAll()
if !strings.EqualFold(cfg.AppEnv, "local") {
corsMW = mw.CORS(cfg.Server.CORSOrigins)
}
srv := server.New(logger, cfg.Server,
server.WithMiddleware(
mw.RequestID(uuid.NewString),
mw.Recover(logger),
corsMW,
mw.RequestLogger(logger),
authjwt.AuthMiddleware(logger, signer, publicPaths),
authmw.EnrichmentMiddleware(logger, &claimsEnricher{}),
),
)
v := valid.New(valid.WithMessageProvider(valid.SpanishMessages))
provider := rbac.NewClaimsPermissionProvider("masks", claimsFromCtx)
lc := launcher.New(logger)
lc.Append(db, srv)
withHealth(lc, srv, logger, db)
withUsers(lc, srv, db, logger, provider, v)
// … one withFeature(...) call per feature in your domain.
return lc.Run()
}
Feature hook
One file per feature in internal/wire/. The function signature is fixed:
launcher.Launcher first, server.Server second when registering routes, deps last. The body is
one call to lc.BeforeStart. Everything else — repository, service, handler construction, route
registration — lives inside the closure.
package wire
import (
"code.nochebuena.dev/einherjar/contracts/logging"
"code.nochebuena.dev/einherjar/contracts/security"
"code.nochebuena.dev/einherjar/core/launcher"
"code.nochebuena.dev/einherjar/core/valid"
"code.nochebuena.dev/einherjar/db-postgres"
"code.nochebuena.dev/einherjar/web/server"
"myapp/internal/domains"
userhandler "myapp/internal/user/handler"
userrepo "myapp/internal/user/repository"
usersvc "myapp/internal/user/service"
)
func withUsers(
lc launcher.Launcher,
srv server.Server,
db postgres.Provider,
logger logging.Logger,
provider security.PermissionProvider,
v valid.Validator,
) {
lc.BeforeStart(func() error {
repo := userrepo.New(db)
uow := postgres.NewUnitOfWork(logger, db)
svc := usersvc.New(repo, uow)
h := userhandler.New(svc, v)
// Literal-segment routes register BEFORE parametrised siblings.
srv.Put("/api/v1/users/me/password", h.ChangeOwnPassword)
srv.With(authz(provider, domains.ResourceUsers, domains.GrantReadUser)).
Get("/api/v1/users", h.ListUsers)
srv.With(authz(provider, domains.ResourceUsers, domains.GrantCreateUser)).
Post("/api/v1/users", h.CreateUser)
srv.With(authz(provider, domains.ResourceUsers, domains.GrantUpdateUser)).
Put("/api/v1/users/{user_id}", h.UpdateUser)
return nil
})
}
Route ordering
chi matches paths in registration order. Always register literal-segment routes before parametrised-segment routes that share the same prefix.
✅ Correct:
srv.Put("/api/v1/users/me/password", h.ChangeOwnPassword)
srv.Put("/api/v1/users/{user_id}", h.UpdateUser)
❌ Wrong — chi binds me to {user_id} and the literal route is unreachable:
srv.Put("/api/v1/users/{user_id}", h.UpdateUser)
srv.Put("/api/v1/users/me/password", h.ChangeOwnPassword)
Authorization
Every protected route registers with .With(authz(provider, resource, grant)):
srv.With(authz(provider, domains.ResourceUsers, domains.GrantReadUser)).
Get("/api/v1/users", h.ListUsers)
Resource constants and grant bits live in internal/domains/. Routes that the caller owns
(/me/...) intentionally skip authz — they are reachable to any authenticated user.
Middleware helpers
These belong in internal/wire/middleware.go and are used across every feature hook.
// authz returns a per-route authorization middleware that checks one bit.
func authz(p security.PermissionProvider, resource string, bit int) func(http.Handler) http.Handler {
return authmw.AuthzMiddleware(nil, p, resource, security.Permission(bit))
}
// skipPublicPaths wraps mw so it is bypassed for any path that matches publicPaths.
// Use this for middleware that must not run on unauthenticated endpoints
// (e.g. EnrichmentMiddleware).
func skipPublicPaths(publicPaths []string, mw func(http.Handler) http.Handler) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
inner := mw(next)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
for _, p := range publicPaths {
if matched, _ := path.Match(p, r.URL.Path); matched {
next.ServeHTTP(w, r)
return
}
}
inner.ServeHTTP(w, r)
})
}
}
// skipMethodPath bypasses mw only when BOTH method and path match. Use this to
// expose ONE method on an otherwise-authenticated path (e.g. GET /api/v1/config
// public while PUT is not). Adding such a path to publicPaths would silently
// strip identity from context on the protected methods, breaking authz().
func skipMethodPath(method, pathPattern string, mw func(http.Handler) http.Handler) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
inner := mw(next)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == method {
if matched, _ := path.Match(pathPattern, r.URL.Path); matched {
next.ServeHTTP(w, r)
return
}
}
inner.ServeHTTP(w, r)
})
}
}
Adapters at the wire boundary
When a framework type does not match a service-layer port, write a small typed adapter in
internal/wire/. Always compile-time assert with var _ TargetIface = (*adapter)(nil).
The framework intentionally exposes only Signer.Sign(claims) (string, error) — the framework
gives you a signing primitive; the access/refresh strategy, claim layout, and response shape are
application concerns. A "helper" that returned a fixed {access, refresh, type, expiresIn} struct
would silently decide for every app whether refresh tokens exist, what fields to expose, and what
casing to use. Those are wire-format choices the app owns.
import (
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
authjwt "code.nochebuena.dev/einherjar/auth-jwt"
)
type tokenSignerAdapter struct {
signer authjwt.Signer
cfg authjwt.TokenConfig
}
var _ authsvc.TokenSigner = (*tokenSignerAdapter)(nil)
func (a *tokenSignerAdapter) IssueTokenPair(subject string, custom map[string]any) (authdto.TokenPairResponse, error) {
now := time.Now()
access := jwt.MapClaims{
"sub": subject,
"iss": a.cfg.Issuer,
"iat": now.Unix(),
"exp": now.Add(a.cfg.AccessTTL).Unix(),
}
for k, v := range custom {
access[k] = v
}
accessToken, err := a.signer.Sign(access)
if err != nil {
return authdto.TokenPairResponse{}, err
}
refreshToken, err := a.signer.Sign(jwt.MapClaims{
"sub": subject,
"jti": uuid.NewString(),
"iat": now.Unix(),
"exp": now.Add(a.cfg.RefreshTTL).Unix(),
})
if err != nil {
return authdto.TokenPairResponse{}, err
}
return authdto.TokenPairResponse{
AccessToken: accessToken,
RefreshToken: refreshToken,
TokenType: "Bearer",
ExpiresIn: int(a.cfg.AccessTTL.Seconds()),
}, nil
}