Pass common object to nested structs

type options {
    logger *log.Logger
}

type optFunc func(*options)

func defaultOptions() *options {
  return &options{
    logger: defaultGlobalLogger,
  }
}

func WithLogger(l *log.Logger) optFunc {
  return func(o *options) {
    o.logger = l
  }
}

type foo struct {
  // something
  logger *log.Logger
}

type Bar struct {
  foo *foo
  data int
  logger *log.Logger
}

func NewBar(data int, opts ... optFunc) *Bar {
  o := defaultOptions()
  for _, fn := range opts {
    fn(o)
  }

  f := &foo{logger: o.logger}
  return &Bar{foo: f, data: data, logger: o.logger}
}

In this case you can implement as many default options as you want with just adding an exported function to allow user configure the behavior. But at the same time, user can simply call NewBar without any optFunc-s to use default options e.g.,

b := NewBar(1) // Default global logger.

// or

b := NewBar(1, WithLogger(myCustomLogger)) // Set custom logger.

Or you can split loggers for foo and Bar e.g.,

type options struct {
  fooLogger *log.Logger
  barLogger *log.Logger
}

func defaultOptions() *options {
  return &options{
    fooLogger: defaultGlobalLogger,
    barLogger: defaultGlobalLogger,
  }
}

func WithFooLogger(l *log.Logger) optFunc {
  return func(o *options) {
    o.fooLogger = l
  }
}

func WithBarLogger(l *log.Logger) optFunc {
  return func(o *options) {
    o.barLogger = l
  }
}

// Now I can do something like this:

b := NewBar(1) // Both foo and Bar use default.
b := NewBar(1, WithFooLogger(myCustomFooLogger)) // foo uses custom and Bar uses global (same can be done with only WithBarLogger).
b := NewBar(1, WithFooLogger(myCustomFooLogger), WithBarLogger(myCustomBarLogger)) // Both foo and Bar use custom loggers.

In the end, you can add new options with default parameters, exposing only an exported function so anyone can use what they need. This hides implementation from the user, allowing more cleaner and flexible ways to customize what they want.

Your example with exposing setup struct for the user is also another way to allow customizable options. But in this case, every time you add an option you need to add checks into your code, that user actually provided something. It is less readable than simple Function options where you already know your defaults, but allow users to customize them.