Skip to content

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.

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.

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:

NeedAPI
Check or start infrastructure before trafficOnBoot
Flush or close infrastructure during shutdownOnShutdown
Send mapped errors to logging, Sentry, or telemetryOnError
Change how errors are classifiedErrorPipeline().Use(...)
Add a dependency to generated DIRegisterProvider
Publish application or integration eventsEventBus()

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.

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.

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.

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.

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.

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.

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
}

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

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() returns telemetry.
  • Passing no options uses slog.Default.
  • More than one options value is rejected during plugin registration.
  • LogBoot registers an OnBoot hook that logs anvil boot.
  • LogShutdown registers an OnShutdown hook that logs anvil shutdown.
  • Every registration adds an OnError observer.
  • 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 IncludeExpectedCause and IncludeUnexpectedCause.
  • 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.

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:

  • Client is required.
  • Bucket is required after trimming whitespace.
  • Prefix is trimmed of surrounding whitespace and slashes.
  • Object keys passed to Put, Get, and Delete are trimmed and leading slashes are removed before the optional prefix is added.
  • ProviderName is optional. When set, the plugin registers anvil.Named[*anvils3.Store](ProviderName, store).
  • Provider names cannot contain surrounding whitespace.
  • Name() returns s3 by default and s3:<ProviderName> for named stores.

Store intentionally wraps a narrow part of S3:

type Store struct {
// unexported fields
}
func (s *Store) Bucket() string
func (s *Store) Put(ctx context.Context, key string, body io.Reader, opts ...anvils3.PutOption) error
func (s *Store) Get(ctx context.Context, key string) (io.ReadCloser, error)
func (s *Store) Delete(ctx context.Context, key string) error

Put, 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.