Procyon|Docs
Components

Container

Understand the runtime container without making application code depend on it everywhere.

The container is the runtime registry behind Procyon's component model. It stores component definitions, creates instances, tracks singleton state, exposes manually registered dependencies, and resolves values by name or type.

Most application code should not call the container directly. Prefer constructor injection for normal services. Use the container when you are writing runtime boundary code: a command-line runner, context initializer, framework extension, test harness, or integration that needs to inspect or resolve application components dynamically.

What the container manages

The standard container combines several responsibilities:

  • component definitions registered from constructors
  • singleton instances created during context refresh
  • prototype and custom scoped components
  • non-component dependencies such as runtime.Context and io.ResourceResolver
  • lifecycle processors that run before or after initialization
  • parent container lookup for bootstrap dependencies

The public container interface is composed from smaller registries:

container.go
type Container interface {
	// DefinitionRegistry stores constructor metadata before instances are created.
	DefinitionRegistry

	// SingletonRegistry stores already-created singleton instances.
	SingletonRegistry

	// Resolver resolves components by name, type, or collection.
	Resolver

	// DependencyRegistry stores injectable values that are not component definitions.
	DependencyRegistry

	// ScopeRegistry manages built-in and custom component scopes.
	ScopeRegistry

	// ProcessorRegistry registers hooks around component initialization.
	ProcessorRegistry
}

That split matters when reading the rest of the component docs. Registration adds definitions, resolution creates or retrieves instances, dependency registry stores framework-provided values, scope registry controls non-singleton lifetimes, and processor registry hooks into initialization.

DefinitionRegistry is about component metadata. It stores constructor-based definitions before instances exist, including component names, scopes, produced types, and metadata.

SingletonRegistry is about already-created instances. Singleton components are stored here after construction and are destroyed from here during shutdown.

Resolver is the read side of the container. It resolves by name, by type, by name plus expected type, or as a collection of all matching components.

DependencyRegistry is for values that are injectable but not normal component definitions, such as runtime context, environment, resource resolver, or test infrastructure.

ScopeRegistry holds named scope implementations. Singleton and prototype are the built-in model; custom scopes can control their own instance reuse rules.

ProcessorRegistry lets framework packages register hooks that run before or after component initialization.

Registry contracts

The embedded interfaces are the operations the container exposes internally and to framework extensions.

definition_registry.go
type DefinitionRegistry interface {
	// RegisterDefinition adds constructor metadata to the container.
	RegisterDefinition(def *Definition) error

	// Definition reads a definition by component name without creating it.
	Definition(name string) (*Definition, bool)

	// DefinitionsOf returns definitions assignable to a requested type.
	DefinitionsOf(typ reflect.Type) []*Definition
}

Use definition APIs when code needs metadata about components before instances exist. Normal application code should usually use component.Register instead of calling RegisterDefinition directly.

singleton_registry.go
type SingletonRegistry interface {
	// RegisterSingleton stores an already-created instance by name.
	RegisterSingleton(name string, instance any) error

	// Singleton reads an initialized singleton without creating a new instance.
	Singleton(name string) (any, bool)

	// DestroySingletons disposes initialized singletons during shutdown.
	DestroySingletons()
}

Singleton APIs deal with instances, not definitions. The container uses them after creating singleton-scoped components. Framework extensions may also use them for infrastructure objects that already exist before component creation.

resolver.go
type Resolver interface {
	// Resolve retrieves a component by name.
	Resolve(ctx context.Context, name string) (any, error)

	// ResolveType retrieves one component assignable to the requested type.
	ResolveType(ctx context.Context, typ reflect.Type) (any, error)

	// ResolveAs retrieves a named component and verifies the expected type.
	ResolveAs(ctx context.Context, name string, typ reflect.Type) (any, error)

	// ResolveAll retrieves every component assignable to the requested type.
	ResolveAll(ctx context.Context, typ reflect.Type) ([]any, error)
}

Use resolver APIs at runtime boundaries where the component set is intentionally dynamic. Constructor injection should stay the default for regular services.

dependency_registry.go
type DependencyRegistry interface {
	// RegisterDependency makes a non-component value injectable by type.
	RegisterDependency(typ reflect.Type, val any) error
}

Dependencies are useful for runtime-provided values such as runtime.Context, runtime.Environment, resource resolvers, clocks, or test infrastructure. They are not component definitions and do not have component lifecycle behavior.

scope_registry.go
type ScopeRegistry interface {
	// RegisterScope adds a named component scope implementation.
	RegisterScope(name string, scope Scope) error

	// Scope returns a registered scope by name.
	Scope(name string) (Scope, bool)
}

Scopes control how instances are reused after a definition is resolved. Singleton and prototype cover most application needs; custom scopes are for framework-level behavior.

processor_registry.go
type ProcessorRegistry interface {
	// UseBeforeInitProcessor registers a hook before Init is called.
	UseBeforeInitProcessor(processor BeforeInitProcessor) error

	// UseAfterInitProcessor registers a hook after Init is called.
	UseAfterInitProcessor(processor AfterInitProcessor) error
}

Processors are cross-cutting hooks around component initialization. They are the right place for framework packages that need to validate, decorate, or observe many components consistently.

During context refresh, Procyon creates a new application container and attaches the startup container as its parent. Bootstrap components stay available to the runtime while application components load into the main container.

Resolve at runtime boundaries

Use typed resolution when the expected type is clear:

runner.go
func (r *AuditRunner) Run(ctx runtime.Context, args *runtime.Args) error {
    service, err := component.ResolveType[*AuditService](ctx, ctx.Container())
    if err != nil {
        return err
    }

    return service.Run(ctx)
}

Use named resolution when a qualifier or explicit component name matters:

runner.go
func (r *AuditRunner) Run(ctx runtime.Context, args *runtime.Args) error {
    writer, err := component.Resolve[ReportWriter](ctx, ctx.Container(), "jsonReportWriter")
    if err != nil {
        return err
    }

    return writer.Write(ctx)
}

Resolve collections

Use ResolveAll when your extension should discover every implementation of an interface.

runner.go
handlers, err := component.ResolveAll[Migration](ctx, ctx.Container())
if err != nil {
    return err
}

for _, handler := range handlers {
    if err := handler.Run(ctx); err != nil {
        return err
    }
}

This is useful for plugin-like features: migrations, startup checks, command handlers, exporters, and other extension lists.

Register runtime dependencies

The context registers a few runtime values as dependencies before loading components. Constructors can ask for these directly:

service.go
func NewAssetService(resolver io.ResourceResolver) *AssetService {
    return &AssetService{resolver: resolver}
}

The same mechanism is available to runtime extensions:

initializer.go
func (i *TestInitializer) InitializeContext(ctx runtime.Context) error {
    return ctx.Container().RegisterDependency(
        reflect.TypeFor[*Clock](),
        NewFixedClock(),
    )
}

Prefer this for infrastructure values that should be injectable but are not application components.

Customize the container

Implement component.ContainerCustomizer when a package needs to add container state before singleton initialization.

customizer.go
type MetricsCustomizer struct {
    registry *MetricsRegistry
}

func NewMetricsCustomizer(registry *MetricsRegistry) *MetricsCustomizer {
    return &MetricsCustomizer{registry: registry}
}

func (c *MetricsCustomizer) CustomizeContainer(container component.Container) error {
    return container.RegisterSingleton("metricsRegistry", c.registry)
}

Container customizers run before singleton components are initialized, so the values they register can be injected into later components.

Keep container usage narrow

If a service can receive dependencies in its constructor, do that. Container access is most useful at the edges of the framework where the concrete component graph is intentionally dynamic.