Plugins
Plugins attach infrastructure to an Anvil app without changing handler code.
Use plugins for cross-cutting behavior: telemetry, S3 clients, audit sinks, company-owned providers, error observers, lifecycle hooks, and event subscribers.
Read Callbacks and Middleware for the difference between plugin lifecycle hooks, middleware methods, error observers, and event subscribers.
Contract
Section titled “Contract”type Plugin interface { Name() string Register(lifecycle AppLifecycle) error}Register runs immediately when anvil.Use(plugin) or app.Use(plugin) is
called. In normal applications that happens during app setup, before app.Wire
and before transports start accepting traffic. Return errors from Register
when configuration is invalid or a provider cannot be constructed. Panics are
reserved for bugs, not plugin configuration.
Plugin names must be non-empty and unique per app. If registration fails, Anvil releases the reserved name so a corrected plugin can be registered by caller code before startup.
anvil.Use(plugin) and app.Use(plugin) both use the same lifecycle surface.
The option form records registration errors as app initialization errors; the
method form returns the error directly.
Lifecycle Surface
Section titled “Lifecycle Surface”type AppLifecycle interface { OnBoot(hook func(ctx context.Context) error) OnShutdown(hook func(ctx context.Context) error) OnError(hook func(ctx context.Context, event ErrorEvent)) ErrorPipeline() ErrorPipeline RegisterProvider(provider Provider) error EventBus() EventBus}Lifecycle hooks are application hooks. They run during app setup, startup, or shutdown. Use middleware for request, GraphQL, gRPC, and queue control flow. Use plugins for startup checks, shutdown cleanup, error observers, providers, and event subscriptions.
A plugin package can expose middleware types. Install the plugin for its
providers, hooks, observers, or mappers, then attach middleware explicitly with
sdk.Use[T] on the groups or route policies that should use it.
Use this split:
| Need | API |
|---|---|
| Check or start infrastructure before traffic | OnBoot |
| Flush or close infrastructure during shutdown | OnShutdown |
| Send mapped errors to logging, Sentry, or telemetry | OnError |
| Change how errors are classified | ErrorPipeline().Use(...) |
| Add a dependency to generated DI | RegisterProvider |
| Publish application or integration events | EventBus() |
OnBoot
Section titled “OnBoot”Runs before transports start listening. Use it to verify external connectivity, warm caches, start background supervisors, or publish boot events.
app.OnBoot(func(ctx context.Context) error { return p.telemetry.Start(ctx)})If a boot hook returns an error, app startup fails.
Boot hooks run in registration order.
OnShutdown
Section titled “OnShutdown”Runs during graceful shutdown. Use it to flush telemetry, close clients, stop background workers, or drain queues.
app.OnShutdown(func(ctx context.Context) error { return p.telemetry.Flush(ctx)})Shutdown hooks run after transport shutdown, in reverse registration order.
When Run is stopped by context cancellation, Anvil calls shutdown with
context.WithoutCancel(ctx) so hooks can still read context values while they
clean up.
OnError
Section titled “OnError”Receives mapped errors after the error pipeline has attached route context.
app.OnError(func(ctx context.Context, event sdk.ErrorEvent) { if event.Expected { return } p.sentry.Capture(ctx, event.Error, event.Failure.Context)})Use event.Expected to avoid reporting routine validation and domain failures as
production incidents.
Observers run synchronously in registration order when a driver publishes a mapped failure. Observer functions have no error return and Anvil does not recover observer panics. A plugin that sends errors to an external system sets its own timeout and recovers inside the observer.
event.Error is the normalized failure cause. For domain errors mapped by a
custom mapper, that is normally the original domain error. For failures built
with ctx.Errors() and no explicit cause, Anvil fills the cause with the
failure value itself. For unexpected failures it is the wrapped technical cause
that the error mapper preserved.
ErrorPipeline
Section titled “ErrorPipeline”Registers or replaces error mappers:
func (p DomainErrorsPlugin) Register(app sdk.AppLifecycle) error { app.ErrorPipeline().Use(DomainMapper{}) return nil}Mappers run in registration order. The first mapper that returns true wins.
RegisterProvider
Section titled “RegisterProvider”Adds a dependency provider to the application wiring graph:
func (p S3Plugin) Register(app sdk.AppLifecycle) error { client, err := NewS3Client(p.Options) if err != nil { return err }
return app.RegisterProvider(anvil.As[*s3.Client](client))}Provider Build methods run when generated wiring resolves them. That still
happens before requests are served in a generated application, but only
providers reached by the generated dependency graph are built. Provider errors
fail app.Wire(...).
Plugins must register providers before generated wiring runs. After app.Wire
has completed, RegisterProvider returns an error because generated components
have already resolved their dependency graph.
Plugins normally use providers created by anvil.As, anvil.Named,
anvil.Factory, or anvil.NamedFactory. Those helpers create the type keys
that generated DI uses. A custom provider with an empty key is built
immediately and is not stored in the generated dependency graph.
EventBus
Section titled “EventBus”The event bus is for decoupled system events:
app.EventBus().Subscribe("project.created", func(payload any) { created, ok := payload.(ProjectCreated) if !ok { return } audit.Record(created)})The event bus is for cross-cutting fanout and system integration. Keep ordinary request-path service calls as direct Go calls.
Publishing is synchronous. Handlers run in subscription order on the publishing goroutine. That gives tests and boot hooks deterministic ordering and makes slow subscribers visible as backpressure.
The bus does not recover subscriber panics. Subscribers handle their own errors, timeouts, and panic recovery before calling external systems.
The bus does not clone payloads. Treat event payloads as immutable or pass value objects when several subscribers need to inspect the same event.
A subscription added while another goroutine is publishing the same topic is used by later publishes. It is not inserted into the already-running publish.
Example: Telemetry Plugin
Section titled “Example: Telemetry Plugin”type TelemetryPlugin struct { Reporter Reporter}
func (p TelemetryPlugin) Name() string { return "telemetry"}
func (p TelemetryPlugin) Register(app sdk.AppLifecycle) error { if p.Reporter == nil { return errors.New("telemetry reporter is required") }
app.OnBoot(func(ctx context.Context) error { return p.Reporter.Start(ctx) })
app.OnShutdown(func(ctx context.Context) error { return p.Reporter.Flush(ctx) })
app.OnError(func(ctx context.Context, event sdk.ErrorEvent) { if !event.Expected { p.Reporter.Capture(ctx, event.Error, event.Failure) } })
return nil}Failure Rules
Section titled “Failure Rules”Plugin failures are explicit:
- Invalid configuration returns an error from
Register - Failed provider construction returns an error from the provider
- Failed boot checks return an error from the boot hook
- Runtime observer and event-bus failures are handled inside the plugin because observer and event handlers have no error return
First-Party Telemetry Plugin
Section titled “First-Party Telemetry Plugin”Package:
import "github.com/TDB-Group/anvil-plugins/telemetry"The telemetry plugin wires slog logging into the Anvil lifecycle and error
pipeline:
app := anvil.New( anvil.Use(telemetry.New(telemetry.Options{ Logger: logger, LogBoot: true, LogShutdown: true, IncludeUnexpectedCause: true, IncludeContextAttributes: true, })),)Behavior from the plugin source:
Name()returnstelemetry.- Passing no options uses
slog.Default. - More than one options value is rejected during plugin registration.
LogBootregisters anOnBoothook that logsanvil boot.LogShutdownregisters anOnShutdownhook that logsanvil shutdown.- Every registration adds an
OnErrorobserver. - Expected failures log at warning level. Unexpected failures log at error level.
- Protocol-neutral fields are logged: status, expected, recovered, protocol, controller, endpoint, method, route, path, request ID, trace ID, and phase.
- Technical causes are opt-in through
IncludeExpectedCauseandIncludeUnexpectedCause. - Error context attributes are opt-in through
IncludeContextAttributes.
The plugin does not start a background worker. Logging happens inside the lifecycle hook or error observer that Anvil calls.
First-Party S3 Plugin
Section titled “First-Party S3 Plugin”Package:
import anvils3 "github.com/TDB-Group/anvil-plugins/s3"The S3 plugin registers a bucket-scoped *anvils3.Store provider:
app := anvil.New( anvil.Use(anvils3.New(anvils3.Options{ Client: awsS3Client, Bucket: "project-assets", Prefix: "prod", })),)Behavior from the plugin source:
Clientis required.Bucketis required after trimming whitespace.Prefixis trimmed of surrounding whitespace and slashes.- Object keys passed to
Put,Get, andDeleteare trimmed and leading slashes are removed before the optional prefix is added. ProviderNameis optional. When set, the plugin registersanvil.Named[*anvils3.Store](ProviderName, store).- Provider names cannot contain surrounding whitespace.
Name()returnss3by default ands3:<ProviderName>for named stores.
Store intentionally wraps a narrow part of S3:
type Store struct { // unexported fields}
func (s *Store) Bucket() stringfunc (s *Store) Put(ctx context.Context, key string, body io.Reader, opts ...anvils3.PutOption) errorfunc (s *Store) Get(ctx context.Context, key string) (io.ReadCloser, error)func (s *Store) Delete(ctx context.Context, key string) errorPut, Get, and Delete require a non-nil context, a configured client, a
non-empty bucket, and a non-empty key. Put also requires a non-nil body.
Per-call put options:
ContentType(value)sets the S3 content type.Metadata(values)clones and attaches user metadata.
The application configures AWS credentials, regions, endpoints, and retry policy. The plugin registers the Anvil provider and offers the small store wrapper.