Imagine you need to wrap multiple objects which implements io.Closer, e.g. three clients to fetch and combine data from different endpoints.

type Parent struct {
    child1 Child1
    child2 Child2
    child3 Child3
}

Parent closer

Let’s see how we can create and destroy a parent object.

func NewParent() (*Parent, error) {
    child1, err := NewChild1()
    if err != nil {
        return nil, err
    }
    child2, err := NewChild1()
    if err != nil {
        // oops, child1 needs to be closed here
        child1.Close()
        return nil, err
    }
    child3, err := NewChild1()
    if err != nil {
        // oops again, both child1, and child2 needs to be closed here
        child1.Close()
        child2.Close()
        return nil, err
    }
    return &Parent{
        child1: child1,
        child2: child2,
        child3: child3,
    }, nil
}

func (p *Parent) Close() error {
    var errs []error
    if err := p.child1.Close(); err != nil {
        errs = append(errs, err)
    }
    if err := p.child2.Close(); err != nil {
        errs = append(errs, err)
    }
    if err := p.child3.Close(); err != nil {
        errs = append(errs, err)
    }
    return multierr.Combine(errs...)
}

Note the boilerplate code of closing the children. Because the parent creates its children, it must be responsible for calling their Close method whenever needed. If there are any errors during the initialisation, the children already created have to be properly closed, and before the parent exits its scope, it has to close its children too.

Furthermore, the Closer interface is contagious. If we organise our code by wrapping objects layer by layer like above, and any one of the descendants is a Closer, then all the types in the hierarchy are infected and have to implement the Closer interface too.

Parent container

Unlike the parent closer, all of the complexity could have been avoided if the parent is a simple container, borrowing the references of the children rather than owning them, as long as the children outlive its parent.


func NewParent(child1 Child1, child2 Child2, child3 Child3) *Parent {
    return &Parent{child1: child1, child2: child2, child3: child3}
}

func run() error {
    child1, err := NewChild1()
    if err != nil {
        return nil, err
    }
    defer child1.Close()
    child2, err := NewChild1()
    if err != nil {
        return nil, err
    }
    defer child2.Close()
    child3, err := NewChild1()
    if err != nil {
        return nil, err
    }
    defer child3.Close()

    parent := NewParent(child1, child2, child3)

    // the parent can be used safely here before func run returns
}

It is usually straightforward to guarantee the children outlive its parent in real cases:

  • either the parent is created and held by a service during its whole lifetime, and func run could be a function that keeps running until the service terminates
  • or the parent is created when handling a request, and func run is the request handler

The key difference between a “parent closer” and a “parent container” is that the latter makes it possible to use the defer statements to close the children in either error or normal case, so the duplicated clean-up code can be avoided.

Conclusion

io.Closer interfaces are contagious. Usually, we do not want to wrap them to make another io.Closer, instead, we should only wrap them by reference borrowing, without managing their lifetime within the wrapper.