Skip to content

Anvil API

The Anvil package is github.com/TDB-Group/anvil.

Application main packages use it to build the app, provide dependencies, install drivers and plugins, run generated wiring, and start the process.

Read Callbacks and Middleware for the exact difference between startup hooks, request middleware, error observers, and event subscribers.

app := anvil.New(
httpstd.Driver(),
anvilgrpc.Driver(),
anvil.WithProviders(
anvil.As[project.Store](project.NewMemoryStore()),
),
anvil.Use(telemetry.New()),
anvil.OnError(captureError),
)

New returns an *anvil.App. Options never start listeners. Startup happens when the app is wired and then run.

New records option errors and returns them later from Wire or Run. Nil options are recorded as initialization errors. That keeps construction panic-free while still failing before the app starts.

Anvil exposes two sentinel errors for callers that need errors.Is:

var ErrAlreadyWired error
var ErrTransportNotRegistered error

ErrAlreadyWired is returned when app.Wire(...) is called more than once.

ErrTransportNotRegistered wraps missing transport errors from WireContext. Generated wiring uses that path when it needs a protocol driver that was not registered in anvil.New(...).

Driver packages return anvil.Option values. A driver option calls anvil.WithTransport internally.

app := anvil.New(
httpstd.Driver(),
anvilgraphql.Driver(),
anvilgrpc.Driver(),
)

Duplicate protocol names are rejected. A generated HTTP module requires a registered sdk.HTTPTransport; generated gRPC, GraphQL, and queue modules require their matching SDK transport interfaces.

Generated wiring also looks up transports by the standard protocol string:

Generated wiringRequired Protocol()Required SDK contract
HTTP routeshttpsdk.HTTPTransport
WebSocket routesFirst registered sdk.WebSocketTransportsdk.WebSocketTransport
GraphQL endpointsgraphqlsdk.GraphQLTransport
gRPC servicesgrpcsdk.GRPCTransport
Queue jobsqueuesdk.QueueTransport

Custom drivers for generated Anvil apps use those protocol strings. A transport with another protocol name can still be registered for custom runtime work, but generated Wire code will not find it through WireContext.HTTP, WireContext.GraphQL, WireContext.GRPC, or WireContext.Queue.

Transports must be registered before the app starts. Once Run, Listen, RunTLS, or ListenTLS finalizes transports, later registration returns an error.

Low-level integrations can register a transport directly:

app := anvil.New(
anvil.WithTransport(customTransport),
)

Transports that also implement http.Handler become edge targets when they implement sdk.HTTPTransport, sdk.GraphQLTransport, or sdk.GRPCTransport. Anvil then serves them behind the shared HTTP-family listener. Other transports run as background transports and receive an empty address when Start is called.

Anvil checks edge target contracts in this order when a custom transport implements more than one: GraphQL, then gRPC, then HTTP. Official drivers keep one edge protocol per driver.

RegisterTransport(transport) is the method form for custom composition code. Transport protocols must be non-empty and unique. After Run, Listen, RunTLS, or ListenTLS begins finalizing transports, later transport registration returns an error.

The normal multi-protocol runtime is HTTP-family edge based. Anvil does not ask HTTP, GraphQL, or gRPC drivers to bind their own public port when those drivers also implement http.Handler; it registers them behind the shared edge listener and calls the selected driver as a handler.

Providers register application-owned dependencies before generated wiring runs.

app := anvil.New(
anvil.WithProviders(
anvil.As[project.Store](project.NewMemoryStore()),
anvil.Named[project.Store]("read", project.NewReadStore()),
anvil.Named[project.Store]("write", project.NewWriteStore()),
),
)

As[T](value) registers a singleton value for the named Go type T. Use it when the injection point is an interface:

type Projects struct {
Store project.Store `inject:""`
}

Named[T](name, value) registers the same Go type with an explicit name. Use it when two fields intentionally use the same type:

type Projects struct {
ReadStore project.Store `inject:"read"`
WriteStore project.Store `inject:"write"`
}

Factory[T] and NamedFactory[T] build dependencies while Anvil wires the app:

anvil.Factory(func(r sdk.DependencyResolver) (*Service, error) {
store, err := anvil.Resolve[project.Store](r)
if err != nil {
return nil, err
}
return NewService(store), nil
})

Provider type keys require named Go types. Built-in types, anonymous structs, and unnamed slices or maps are rejected as provider keys. A named type whose underlying type is a slice, map, or struct is accepted because it has a stable package path and type name.

Pointer types and value types share the same provider key because the key is based on the named element type. The produced value still has to match the field type Anvil resolves. A field of type *Config needs a provider that returns *Config; a field of type Config needs a provider that returns Config.

RegisterProvider(provider) is the method form used by plugins and custom composition code. It follows the same timing rule as WithProviders: call it before Wire. Registering providers after wiring returns an error.

Provider keys need to be non-empty when generated code will resolve them. Providers created by As, Named, Factory, and NamedFactory always have keys. If a custom provider returns an empty key, Anvil calls Build immediately and does not register it in the generated DI container. Use a real key for dependencies that generated code resolves.

Provider registration returns an error immediately when the provider is nil, the app is nil, the provider type is not a named Go type, or the generated DI container already has the same key. Factory errors are returned when generated wiring resolves that provider. If a provider factory is nil, returns an error, or returns a value with the wrong Go type, Wire fails when the generated graph reaches that dependency.

MustResolve panics by design. Use it only inside startup-only provider factories when the app needs a hard startup failure.

Generated packages register a wiring function from init.

import _ "example.com/portal/anvilgen"
if err := app.Wire(); err != nil {
log.Fatal(err)
}

With no arguments, Wire uses every registered generated wiring function. If no generated package was imported and no explicit wiring function is passed, Wire has nothing to register. It still marks the app as wired, so a later call returns ErrAlreadyWired. An app wired this way starts with no generated routes, services, or jobs.

Tests can call explicit wiring instead:

if err := app.Wire(anvilgen.Wiring()); err != nil {
t.Fatal(err)
}

Wire can run once. A second call returns ErrAlreadyWired.

Generated packages call:

anvil.RegisterWiring(anvilgen.Wiring())

RegisterWiring(fn) adds a generated wiring function to the package-level registry. It returns a cleanup function for tests that need to unregister a temporary wiring function. Passing nil registers nothing and returns a no-op cleanup function.

Generated wiring uses WireContext to access the DI injector and registered transports. Application code normally does not use WireContext directly.

Run does not call Wire. Generated applications call Wire first so routes, providers, middleware, queue handlers, and protocol metadata are registered before the listener starts.

Run starts with an explicit context:

ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
defer cancel()
if err := app.Run(ctx, ":8080"); err != nil {
log.Fatal(err)
}

Listen creates that interrupt context for you. It handles os.Interrupt. Use Run with your own context when the process needs custom cancellation, such as SIGTERM handling in containers.

if err := app.Listen(":8080"); err != nil {
log.Fatal(err)
}

TLS variants set cert and key before running:

app.ListenTLS(":443", "cert.pem", "key.pem")
app.RunTLS(ctx, ":443", "cert.pem", "key.pem")

Cert and key must both be non-empty.

Treat an *anvil.App as one process lifecycle. Build it, register drivers and providers, wire generated components, run it, then let it shut down. Create a new app value for another run after Run or Listen returns. Wire is one-shot, transport finalization is one-shot, and several drivers keep shutdown state that is not designed to restart.

Run follows this order:

  1. Return any initialization error recorded by options.
  2. Finalize transports into edge targets and background transports.
  3. Register the edge transport with the core runtime when at least one HTTP-family target exists.
  4. Run boot hooks in registration order.
  5. Start registered core transports concurrently.
  6. Wait for context cancellation or the first transport return.
  7. Shut down transports.
  8. Run shutdown hooks in reverse registration order.

Run requires a non-nil context and a non-empty address. If finalization leaves the core runtime with no transports, startup fails with core: no transports registered. That usually means no driver was installed, or the app registered only custom transports that were rejected earlier as initialization errors.

Canceling the run context is a clean stop signal. When the context is canceled, Anvil shuts down transports and returns the shutdown error, if any. A clean context cancellation with clean transport shutdown returns nil rather than context.Canceled.

If the context is canceled, shutdown runs with context.WithoutCancel(ctx) so cleanup hooks can still use the original context values. If a transport returns first, Anvil still shuts down the rest and returns the joined transport and shutdown errors.

If a boot hook returns an error, transports have not started yet. Startup fails with that hook error and Run does not run shutdown hooks for that failed boot.

Transport Start(addr) is a blocking call in the Anvil runtime model. A driver that returns from Start tells the core runtime that the transport has stopped or failed. Returning nil still causes the app to begin shutdown, because one registered transport has ended.

For HTTP-family drivers behind the shared edge, the driver Start(addr) method is not called in normal app runtime. The core runtime calls Start(addr) on the edge transport. The edge serves the public socket and calls HTTP, GraphQL, or gRPC drivers as http.Handler targets after request classification. The target drivers still receive Shutdown(ctx) when the edge stops.

WithListener configures the edge net/http.Server:

app := anvil.New(
anvil.WithListener(&http.Server{
ReadHeaderTimeout: 2 * time.Second,
MaxHeaderBytes: 32 << 10,
}),
httpstd.Driver(),
)

Handler must be nil because Anvil installs the handler that classifies HTTP, GraphQL, and gRPC requests before drivers run.

Passing nil, or passing a server with Handler already set, records an initialization error. New still returns an app, and Wire or Run reports the error before the process starts accepting traffic.

Run and Listen set the server address from the addr argument. The http.Server.Addr value on the supplied listener config is ignored.

WithListener is ignored unless at least one HTTP-family driver is registered. Queue-only apps run their queue transports directly through the core runtime.

An HTTP-family driver counts as an edge driver only when it implements both http.Handler and its SDK transport contract. A transport that lacks http.Handler still stays registered for generated wiring, but at runtime it is started as a background transport with an empty address.

Anvil copies the supplied http.Server struct when it builds the edge listener. Pointer fields such as TLSConfig and Protocols are still shared values, so configure them before app startup and treat them as immutable after passing the server to WithListener.

WithProxy configures client-IP resolution for applications behind reverse proxies:

app := anvil.New(
anvil.WithProxy(anvil.ProxyConfig{
ProxyHeader: "CF-Connecting-IP",
TrustedProxies: []string{"203.0.113.0/24"},
}),
httpstd.Driver(),
)
type ProxyConfig struct {
ProxyHeader string
TrustedProxies []string
}

ProxyHeader is the header written by the trusted proxy, such as CF-Connecting-IP, X-Forwarded-For, or Forwarded.

TrustedProxies contains CIDR ranges or literal IP addresses for direct peers that are allowed to set that header. If the direct peer is not trusted, Anvil ignores the header and uses the socket peer IP.

Handlers and middleware read the result with ctx.Request().IP().

anvil.Use registers plugins during construction:

app := anvil.New(
anvil.Use(telemetry.New()),
)

app.Use(plugin) is the method form. Plugin names must be non-empty and unique. If Register returns an error, the plugin name reservation is released and app startup fails with that error.

Plugins can register boot hooks, shutdown hooks, providers, error observers, error mappers, and event subscribers through sdk.AppLifecycle.

anvil.Use(...) registers plugins while New applies options. app.Use(...) registers the plugin at the call site. In both forms, boot hooks run later, after wiring and before transports start.

The method forms are useful when setup happens after New:

app := anvil.New(httpstd.Driver())
app.OnBoot(checkDependencies)
app.OnShutdown(flushTelemetry)
app.OnError(captureError)

OnBoot hooks run before transports start. If a boot hook returns an error, startup fails.

OnShutdown hooks run during graceful shutdown.

OnError observes mapped failures from drivers, generated code, panics, and the error pipeline. Observers run synchronously in registration order, so slow observers need their own buffering or timeouts.

EventBus() returns the shared in-process event bus:

app.EventBus().Subscribe("project.created", func(payload any) {
// Handle cross-cutting fanout.
})

The event bus publishes synchronously on the publishing goroutine. It is useful for deterministic plugin fanout and tests; it is not a background job runner.

Event handlers run in subscription order for a topic. Anvil does not recover panics from event handlers and does not add timeouts around them. Handlers that call external systems or start background work need their own buffering, timeouts, and panic recovery.

anvil.OnError registers an error observer during construction:

app := anvil.New(
anvil.OnError(func(ctx context.Context, event sdk.ErrorEvent) {
logger.Error("request failed", "status", event.Failure.Status)
}),
)

app.ErrorPipeline() exposes the mapper surface:

app.ErrorPipeline().Use(DomainMapper{})

It returns the SDK ErrorPipeline interface, not a concrete core type.

Env returns an environment value or fallback:

addr := anvil.Env("ADDR", ":8080")

The fallback is returned when the key is empty, the variable is missing, or the variable is set to an empty string.

LoadConfig[T] fills a struct from env, default, and required:"true" tags at boot:

type Config struct {
Addr string `env:"ADDR" default:":8080"`
Mode anvil.Mode `env:"APP_ENV" default:"development"`
TTL time.Duration `env:"TOKEN_TTL" default:"15m"`
}
cfg, err := anvil.LoadConfig[Config]()

Defaults are used only when the environment variable is missing. If the variable is set to an empty string, the default is not applied. A field tagged required:"true" fails when the final value is empty.

LoadConfig[T] requires T to be a struct. It walks settable fields only. Unexported fields are ignored because Go reflection cannot assign to them. Fields without an env tag are ignored unless they are nested structs.

Supported field types:

  • string
  • bool, parsed with strconv.ParseBool
  • Signed and unsigned integers, parsed as base-10 numbers
  • Floats
  • time.Duration, parsed with time.ParseDuration
  • []string from comma-separated values, with each item trimmed
  • Nested structs, except time.Duration

For []string, Anvil trims whitespace around each comma-separated item but does not remove empty items. FEATURES=a,,b becomes []string{"a", "", "b"}.

MustConfig[T] panics by design and is only for startup code.

ParseMode accepts:

InputMode
"", dev, development, localanvil.Development
prod, productionanvil.Production
test, testinganvil.Test

ModeFromEnv() checks ANVIL_ENV, APP_ENV, and GO_ENV by default and returns development when none are set.

ModeFromEnv("APP_MODE", "GO_ENV") checks only the supplied keys, in order. Passing an empty key returns an error.

MustMode panics by design and is only for startup code.

Mode also has predicate methods for readable branch points:

mode.IsDevelopment()
mode.IsProduction()
mode.IsTest()