short vs. long lifetime structs

Short vs. Long lifetime structs

Much of the go code that we write the structure of; do some operation that might fail, return value and error, handle error if present, continue with value. Here’s an example:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
func (s Service) Login(userID int64) error {
    user, err := s.repo.User(userID)
    if err != nil {
        return User{}, fmt.Errorf("fetch user: %w", err)
    }
    
    // trimmed

    return nil
}

This is a relatively simple example, but I will show how with multiple method calls which return errors, the intent of the original function can easily be confused or lost in the noise of error handling.

Dave Cheney’s article “Eliminate error handling by eliminating errors” demonstrates how using errors as values we can reduce the number of places error handling needs to be performed. We can extend on this idea by thinking in-terms of two different classifications of objects; long lived and short lived structs.

In the example above the Service struct is an instance of a long lived struct. It’s stateless and thread-safe and this pattern is commonly used for; controllers, service, clients etc. These long lived structs have fields that carry dependencies and interfaces, and data is passed to the struct instance via method arguments.

If we look at a more complicated example, this code reticulates some splines based on the diameter passed.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
func (s Service) ReticulateSplines(id int, diameter float64) (Splines, error) {
    spl, err := s.splinesRepo.Splines(id)
    if err != nil {
        return Splines{}, fmt.Errorf("fetch splines: %w", err)
    }

    profile, err := s.reticulationRepo.Profile(spl.profileID)
    if err != nil {
        return Splines{}, fmt.Errorf("fetch reticulation profile: %w", err)
    }

    d, err := s.computeDialation(profile, diameter)
    if err != nil {
        return Splines{}, fmt.Errorf("dialate splines: %w", err)
    }


    if spl.dialation == d {
        return spl, nil
    }
    
    spl.dialation = d

    if err := s.splinesRepo.Save(spl); err != nil {
        return Splines{}, fmt.Errorf("save splines: %w", err)
    }

    return spl, nil
}

In this example, the multiple instances of error handling hide the intent of the method. Let’s consider a slightly rewritten version:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
func (s Service) ReticulateSplines(id int, diameter float64) (Splines, error) {
    r := reticulater{
        id:       id
        diameter: diameter
    }

    dialatedSpl, err := r.
        splines(s.splinesRepo).
        profile(s.reticulationRepo).
        dialate()

    if err != nil {
        return Splines{}, fmt.Errorf("dialating: %w", err)
    }

    if err := dialatedSpl.persist(s.splinesRepo); err != nil {
        return Splines{}, fmt.Errorf("update dialated splines: %w", err)
    }

    return dialatedSpl.splines, nil
}

Why is the second version better?

  • readability; the reticulater provides methods that abstract away the noise of error handling, making the code’s intent much clearer.
  • testing; the reticulater struct carries data and not interfaces which allows the struct to be setup with specific data once, and the various code paths to be exercised using different setup and expectations on mocks that are passed as interfaces (the “thing that changes”).

What is it about go that makes the use of ‘short lived structs’ easy?

  1. Defining a new struct type has a low overhead. When compared to languages like java, the overhead of defining a new struct type is much lower. There’s less boilerplate required and the short lived struct can easily live in the same file as the long lived one.
  2. Short lived struct types can define their own interfaces on the dependencies that they accept to reduce the testing surface area.

Rules:

If we follow some rules, we can make the best use of short lived structs to simplify the intent of our code.

  1. Pass the dependency, store the data.

    Passing the dependency has the benefit of making it clear that this object is a short lived behaviour / data object and not just another stateless service struct (like the ones that have caused us the repetitive if err checks). Passing the dependency also allows the data struct to act as an abstraction, in the data.persist(repo) example the data struct can choose if it wants to operate against the repo or not, this is hidden from the consumer.

  2. Carry the error and check it first.

    Making the error a value like all the other data in the struct means that we can carry the error on the struct, and our final method can be responsible for checking and returning the error. This groups all the error handling from all the setup methods into a single if err check.

TL;DR

Long lived service structs are stateless and thread-safe implementations of controller, service or client logic. Their fields comprise the dependencies and interfaces interacted with (often external, e.g. a repository or database).

Sort lived data structs define behaviours and carry data. The dependencies and interfaces they need are passed in with method calls, this makes them easier to test. Errors are treated as data values of the struct to simplify the error handling. The behaviour methods provide readable abstractions.



Appendix:

I’ve excluded from the example above the method implementations for the reticulater, here they are:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
type reticulater struct {
    id       int
    diamater float64
    
    splines  Splines
    profile  ReticulationProfile
    err      error
}

// pass the dependency 'repo' and store the data as fields of the reticulater
func (r reticulater) splines(repo splinesRepo) reticulater {
    // carry the error as a value in the struct, check it first.
    if r.err != nil {
        return r
    }

    spl, err := repo.Splines(r.id)
    if err != nil {
        // set the error value in the struct, to handle later
        r.err = fmt.Errorf("fetching splines: %w", err)
    }

    r.splines = spl
    return r
}

func (r reticluater) profile(repo profileRepo) reticulater {
    if r.err != nil {
        return r
    }

    if r.splines.profileID == 0 {
        r.err = fmt.Errof("missing profile id")
        return
    }

    profile, err := repo.Profile(r.splines.profileID)
    if err != nil {
        r.err = fmt.Errorf("fetch reticluation profile: %w", err)
    }

    r.profile = profile
    
    return r
}

func (r reticluator) dialate() (dialatedSplines, error) {
    if r.err != nil {
        return dialatedSplines{}, r.err
    }

    // trimmed
    
    return ds, nil
}