Skip to content

Feature Request: Support for Partial Struct Initialization Pattern #1281

Description

@scr-oath

Problem Statement

When building services with fx, there's a common pattern that's difficult to express cleanly:

  • Injected dependencies (config, logger, database connections)
  • Computed/derived fields (caches, connection pools)
  • Internal state (mutexes, channels, maps)

Currently, fx's design constraints force verbose boilerplate for this pattern. Specifically:

The fx.In Constraints

  1. fx.In must be directly in params - Can't use embedded base structs that fx would need to flatten
  2. Return types cannot embed fx.In - fx/dig explicitly rejects: "cannot provide parameter objects: embeds a dig.In"
  3. Anonymous embedding fails - Can't wrap params struct in return type

These constraints lead to tedious field-by-field copying:

type ServiceParams struct {
    fx.In
    Config *Config
    Logger *Logger
    DB     *sql.DB
}

type Service struct {
    config *Config
    logger *Logger
    db     *sql.DB
    cache  *Cache      // computed field
    mu     sync.Mutex  // internal state
}

func NewService(p ServiceParams) *Service {
    // Manual field-by-field copying
    return &Service{
        config: p.Config,
        logger: p.Logger,
        db:     p.DB,
        cache:  NewCache(p.Config.CacheSize),
    }
}

This becomes painful with 5+ dependencies and forces duplicate field definitions.


Two Key "Aha Moments"

While exploring solutions, two constraints stood out as potential improvement opportunities:

1. fx.In Cannot Appear in Return Types

This is a fundamental dig constraint that prevents the cleanest solution:

// ❌ This doesn't work - fx rejects: "cannot provide parameter objects: embeds a dig.In"
type ServiceParams struct {
    fx.In
    Config *Config
    Logger *Logger
}

func NewService(p ServiceParams) *ServiceParams {
    // Would love to just return the params with computed fields added
    return &p
}

Question: Could fx support this with an annotation or tag? E.g., fx.In with fx:"allow-in-output" or a new fx.Partial marker?

2. Anonymous Embedding of Base Structs Fails

The most Go-idiomatic pattern would be:

// ❌ This doesn't work - fx can't flatten the embedded base
type ServiceBase struct {
    fx.In  // OR could be separate params struct
    Config *Config
    Logger *Logger
}

type Service struct {
    ServiceBase  // anonymous embedding for clean field access
    cache *Cache
    mu    sync.Mutex
}

func NewService(base ServiceBase) *Service {
    return &Service{
        ServiceBase: base,
        cache:       NewCache(base.Config.Size),
    }
}

Question: Could fx support recognizing anonymous embedded structs (with or without explicit tags) as "inject these fields"?


Use Case: Universal Pattern

While this came up during google/wireuber/fx migration (wire's wire.Struct handled this elegantly), this is a general Go DI pattern:

Wire Context (for reference)

In wire, you could mark fields for partial initialization:

type Service struct {
    Config *Config
    Logger *Logger
    Cache  *Cache  // manually initialized
}

// wire.Struct(new(Service), "Config", "Logger")
// Wire injects Config/Logger, user provides Cache

This pattern is common across many Go codebases:

  • Services with lifecycle-managed resources
  • Services with derived state from config
  • Large microservice codebases with 10+ dependencies per service
  • Library/module authors who want to provide fx.Module that consumers can include

What's Been Tried: Reflection-Based Solution

Through experimentation, I've developed a production-ready pattern using Go's reflection capabilities. This works beautifully but highlights opportunities for native fx support.

AutoConstructor Pattern (★ Recommended Approach)

The most idiomatic and composable solution:

// Define base with injected dependencies
type ServiceBase struct {
    Config *Config
    Logger *Logger
    DB     *sql.DB
}

// Define real struct with embedded base + internal state
type Service struct {
    ServiceBase
    cache map[string]any
    mu    sync.Mutex
}

// Auto-generate provider for ServiceBase
fx.Provide(AutoConstructor[ServiceBase]())

// Write normal constructor
func NewService(base ServiceBase) *Service {
    return &Service{
        ServiceBase: base,
        cache:       make(map[string]any),
    }
}

fx.Provide(NewService)

With lifecycle hooks:

func NewService(base ServiceBase, lc fx.Lifecycle) *Service {
    s := &Service{
        ServiceBase: base,
        cache:       make(map[string]any),
    }
    lc.Append(fx.Hook{
        OnStart: s.start,
        OnStop:  s.stop,
    })
    return s
}

In an fx.Module (library use case):

// Library code in github.com/myorg/mylib
var Module = fx.Module(
    "MyLibrary",
    fx.Provide(
        AutoConstructor[ServerBase](),
        NewServer,
    ),
)

// Consumer code
fx.New(
    mylib.Module,  // Just works!
    // ...
)

Why this is the best pattern:

No fx.In pollution - Base struct has no fx markers
No fx:"-" tags - Clear separation: dependencies in Base, state in Real
Normal constructors - Write regular Go functions, not reflection wrappers
Composable - Multiple constructors can share the same Base
Works in fx.Module - Can be packaged in libraries for reuse
Natural lifecycle - fx.Lifecycle, fx.Shutdowner work normally in constructors
Wire-like ergonomics - Mirrors wire.Struct conceptually


Alternative Explored: AutoProvidePartial + fx.Decorate

An alternative pattern uses fx:"-" tags and fx.Decorate:

type Service struct {
    Config *Config              // Injected by fx
    Logger *Logger              // Injected by fx
    Cache  *Cache   `fx:"-"`    // Manual initialization
    State  *State   `fx:"-"`    // Manual initialization
}

fx.Provide(AutoProvidePartial[Service]())

fx.Decorate(
    func(s *Service, lc fx.Lifecycle) *Service {
        s.Cache = NewCache(s.Config.CacheSize)
        s.State = &State{Ready: false}
        
        lc.Append(fx.Hook{
            OnStart: func(ctx context.Context) error {
                s.State.Ready = true
                return nil
            },
        })
        
        return s
    },
)

Why this is less ideal:

Cannot be packaged in fx.Module - fx.Decorate is scoped to the module it's in
Library authors can't use it - You can provide AutoProvidePartial[Service]() in a module, but consumers would need to call fx.Decorate separately
Not composable - Can't ship a complete library module that "just works"

The fundamental issue: fx.Decorate doesn't work across module boundaries. A library can't do:

// ❌ This doesn't work - Decorate is scoped to this module only
var Module = fx.Module(
    "MyLibrary",
    fx.Provide(AutoProvidePartial[Service]()),
    fx.Decorate(func(s *Service) *Service {
        s.Cache = NewCache(s.Config.Size)
        return s
    }),
)

The decorator only sees types provided within the same module, making this pattern unsuitable for library/module authors who want to ship complete, self-contained functionality.

This is why AutoConstructor is the superior pattern - it produces a complete, initialized object without requiring decorators.


Implementation Details: How Reflection Makes This Work

The core technique uses three reflection primitives to dynamically generate fx-compatible constructors:

func AutoConstructor[T any]() interface{} {
    targetType := reflect.TypeOf((*T)(nil)).Elem()
    
    // 1. reflect.StructOf - Build params struct with fx.In + exported fields
    fields := []reflect.StructField{
        {
            Name:      "In",
            Type:      reflect.TypeOf((*fx.In)(nil)).Elem(),
            Anonymous: true,
        },
    }
    
    numFields := targetType.NumField()
    for i := 0; i < numFields; i++ {
        field := targetType.Field(i)
        if !field.IsExported() {
            continue  // Skip unexported fields
        }
        fields = append(fields, field)
    }
    
    paramsType := reflect.StructOf(fields)
    
    // 2. reflect.FuncOf - Create function signature fx expects
    returnType := targetType
    if targetType.Kind() != reflect.Ptr {
        returnType = reflect.TypeOf((*T)(nil))
    }
    
    funcType := reflect.FuncOf(
        []reflect.Type{paramsType},
        []reflect.Type{returnType},
        false,
    )
    
    // 3. reflect.MakeFunc - Implement the function
    fn := reflect.MakeFunc(funcType, func(args []reflect.Value) []reflect.Value {
        paramsVal := args[0]
        
        // Create target instance
        var targetVal reflect.Value
        if targetType.Kind() == reflect.Ptr {
            targetVal = reflect.New(targetType.Elem())
        } else {
            targetVal = reflect.New(targetType).Elem()
        }
        
        // Copy fields from params to target
        for i := 0; i < numFields; i++ {
            field := targetType.Field(i)
            if !field.IsExported() {
                continue
            }
            
            // Find matching field in params (skip fx.In)
            paramsField := paramsVal.FieldByName(field.Name)
            targetField := targetVal
            if targetType.Kind() == reflect.Ptr {
                targetField = targetField.Elem()
            }
            targetField = targetField.FieldByName(field.Name)
            
            targetField.Set(paramsField)
        }
        
        return []reflect.Value{targetVal}
    })
    
    return fn.Interface()
}

Key insight: fx's reflection can inspect dynamically-created types (from reflect.StructOf) as if they were hand-written structs. This lets us generate the params struct at runtime while keeping the target struct clean.

What this generates (conceptually):

// AutoConstructor[ServiceBase]() generates equivalent of:
type generatedParams struct {
    fx.In
    Config *Config
    Logger *Logger
    DB     *sql.DB
}

func generatedConstructor(p generatedParams) ServiceBase {
    return ServiceBase{
        Config: p.Config,
        Logger: p.Logger,
        DB:     p.DB,
    }
}

Features that work:

  • ✅ Standard fx struct tags: `name:"..."`, `optional:"true"`, `group:"..."`
  • ✅ Error returns in constructors
  • ✅ fx.Lifecycle, fx.Shutdowner injection
  • ✅ Works in fx.Module
  • ✅ Minimal runtime overhead (reflection only at startup)

Comparison: Current Workarounds

Pattern Field Access Boilerplate Reflection Module-Safe Lifecycle Limitations
Manual copying Direct High None Duplicate definitions, verbose
Named field (.P.) service.P.Config Low None Awkward accessor
AutoConstructor Direct Minimal Startup only Needs external package, debug traces
AutoProvidePartial + Decorate Direct Minimal Startup only Cannot work in fx.Module

All patterns work in production, but they require:

  1. External utility package
  2. Understanding of reflection internals
  3. Accepting `reflect.makeFuncStub` in stack traces

Potential Solutions (Open to fx Team's Design)

Several approaches could make this cleaner natively in fx:

Option A: Allow fx.In in Return Types (with annotation)

type Service struct {
    fx.In `fx:"partial"` // or fx.Partial
    Config *Config
    Logger *Logger
    cache  *Cache `fx:"-"`  // not injected
}

func NewService(s Service) *Service {
    s.cache = NewCache(s.Config.Size)
    return &s
}

Option B: Support Anonymous Base Struct Embedding

type ServiceBase struct {
    fx.In  // or could be implicit
    Config *Config
    Logger *Logger
}

type Service struct {
    ServiceBase  // fx recognizes and injects this
    cache *Cache
}

func NewService(base ServiceBase) *Service {
    return &Service{
        ServiceBase: base,
        cache:       NewCache(base.Config.Size),
    }
}

Option C: Native fx:"-" Tag Support

type ServiceParams struct {
    fx.In
    Config *Config
    Logger *Logger
    Cache  *Cache  \`fx:"-"\`  // user provides this
}

type Service struct {
    ServiceParams
}

func NewService(p ServiceParams) *Service {
    p.Cache = NewCache(p.Config.Size)
    return &Service{ServiceParams: p}
}

Option D: Official Reflection-Based Utility

Adopt the `AutoConstructor` pattern as an official fx utility:

import "go.uber.org/fx/fxutil"

fx.Provide(
    fxutil.AutoConstructor[ServiceBase](),
    NewService,
)

Or even simpler, a new marker type:

type ServiceBase struct {
    fx.Struct  // or fx.Base, fx.Injectable
    Config *Config
    Logger *Logger
}

// fx recognizes fx.Struct and auto-generates the constructor

Benefits of Native Support

  1. Better debugging - No `reflect.makeFuncStub` in stack traces
  2. Discoverability - Official documentation and examples
  3. Compile-time validation - Could provide better error messages
  4. No external dependencies - Pure fx solution
  5. Consistency - One blessed pattern instead of multiple workarounds
  6. Library/module authors - Can ship complete, self-contained modules

Evidence This Pattern is Needed

From my exploration:

  • 3,000+ lines of production-ready code developed to solve this
  • Multiple patterns explored (7+ variations)
  • Comprehensive tests with lifecycle integration, error handling, edge cases
  • Performance benchmarks showing minimal overhead
  • Real-world usage in migration from wire
  • Complete example module demonstrating library packaging

The AutoConstructor pattern has emerged as clearly superior because:

  • Works in fx.Module (critical for library authors)
  • Most idiomatic Go (normal constructors)
  • No special tags or markers needed
  • Composable across multiple services

Additional Context

  • All struct tag features work on dynamically-created types (`name:"..."`, `optional:"true"`, `group:"..."`)
  • Patterns are composable (multiple constructors can share same base)
  • Works seamlessly with fx.Lifecycle, fx.Shutdowner, fx.Module
  • Reflection overhead is startup-only (during fx.New), zero runtime cost

Happy to provide more details or examples if helpful!


What would you like to see? I'd love feedback on:

  1. Whether native support for this pattern makes sense for fx
  2. Which approach (A/B/C/D above) best fits fx's design philosophy
  3. Whether the reflection-based implementation could be adopted officially

Thank you for considering this enhancement!

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions