Search code examples
gocomposition

Is my approach to composability idiomatic for Go?


I created this in the playground: https://play.golang.org/p/Jj4UhA8Yn7

I'll paste the code below as well.

The question revolves around whether my approach on composability is something I should consider as viable, good Go code, or if I'm thinking about it incorrectly and should consider something more in line with idiomatic Go.

My goal is to use this pattern to create "logic" tiers that decorate the underlying layer with additional logic that the wrapped layer should not need to know about.

As a cursory example I might have these "layers"

  1. interface layer - a set of interfaces that define the "model"
  2. simple struct layer - just holds data from the database meets the interfaces above
  3. validation layer - wraps around an interface from the interface layer and validates incoming data against some validation rules before it then forwards the method calls to the wrapped layer below. It also meets the same interface as the layer it is wrapping.
  4. translation layer - same as number 3, translates any text being accessed by first calling the layer it is wrapping, then translates the text, and returns the translated text. it will meet the same interface of the object it wraps. Any methods unrelated to getting info will be forwarded to the underlying layer transparently.

I hope I've made myself somewhat clear and that the example code below will help illustrate it better than my words above.

The example code from the playgroud

package main

import (
    "errors"
    "fmt"
)

//An interface
type Weird interface {
    Name() string
    SetName(name string) error

    Age() int
    SetAge(age int) error
}

//Simple struct to hold data
type SimpleWeird struct {
    name string
    age  int
}

func (s *SimpleWeird) Name() string {
    return s.name
}

func (s *SimpleWeird) SetName(name string) error {
    s.name = name
    return nil
}

func (s *SimpleWeird) Age() int {
    return s.age
}

func (s *SimpleWeird) SetAge(age int) error {
    s.age = age
    return nil
}

//RegularWeird encapsulates some "business" logic within it's methods
//and would be considered normal logic flow
type RegularWeird struct {
    Weird
}

func (r *RegularWeird) SetName(name string) error {
    if len(name) > 5 {
        return errors.New("Regulars can't set a name longer than 5 characters long")
    }
    return r.Weird.SetName(name)
}

func (r *RegularWeird) SetAge(age int) error {
    if age > 80 {
        return errors.New("Regulars can't set an age above 80")
    }

    return r.Weird.SetAge(age)
}

//AdminWeird encapsulates some admin "business" logic within it's methods
//It would be considered admin logic flow/rules
type AdminWeird struct {
    Weird
}

//AdminWeirds don't have their own SetName. If they
//Wrap a SimpleWeird then any name size is allowed (desired behavior)
//If the wrap a regular weird then the regular weird's logic is enforced

func (a *AdminWeird) SetAge(age int) error {
    if age > 100 {
        return errors.New("Admins can't set an age above 100")
    }
    return nil
}

func NewAdminWeird() Weird {
    return &AdminWeird{Weird: &SimpleWeird{}}
}

func NewRegularWeird() Weird {
    return &RegularWeird{Weird: &SimpleWeird{}}
}

//This one doesn't make sense for this example but I wanted to show
//the composability aspect of this. I would be creating chainable
//interfaces that each handle different unrelated logic
func NewAdminRegularWeird() Weird {
    return &AdminWeird{Weird: NewRegularWeird()}
}

func checkErr(err error) {
    if err != nil {
        fmt.Println(err)
    }
}

func main() {
    var err error

    r := NewRegularWeird()
    a := NewAdminWeird()
    ar := NewAdminRegularWeird()

    fmt.Println("Regular output:")
    err = r.SetName("test")
    checkErr(err)

    err = r.SetAge(5)
    checkErr(err)

    err = r.SetName("something-longer")
    checkErr(err)

    err = r.SetAge(90)
    checkErr(err)

    fmt.Println("Admin output:")
    err = a.SetName("test")
    checkErr(err)

    err = a.SetAge(5)
    checkErr(err)

    err = a.SetName("something-longer")
    checkErr(err)

    err = a.SetAge(101)
    checkErr(err)

    fmt.Println("AdminRegular output:")
    err = ar.SetName("test")
    checkErr(err)

    err = ar.SetAge(5)
    checkErr(err)

    err = ar.SetName("something-longer")
    checkErr(err)

    err = ar.SetAge(90)
    checkErr(err)

}

Solution

  • I'm not sure about idiomatic, but I think you've effectively used interfaces, structures and constructor methods to achieve your goal. Here are a few things you might change if it suits you.

    1) Let the implementations deal with the setters.

    //An interface
    type Weird interface {
        Name() string
    
        Age() int
    }
    

    Implementers could choose to implement a SetName or SetAge, but they could just as well initialize with values or assign directly to the structure. For good or bad, the business rules about min/max age, etc. are not enforceable by the interface, so the interface methods seems like extra code to me.

    2) Create an Admin interface, though you haven't really defined any unique behavior of Admins in your example:

    type Admin interface {
        Weird
        SomeAdminMethod() string
    }
    

    3) Your use of the interface as the return value for all of the New* functions is a bit confusing to me. It seems like that would be more suited to a single NewWeird function like:

    func NewWeird(flavor string) (Weird, error) {
        switch flavor {
        case "regular":
            return &RegularWeird{Weird: &SimpleWeird{}}, nil
        case "admin":
            return &AdminWeird{Weird: &SimpleWeird{}}, nil
        case "regularadmin":
            return &RegularWeird{Weird: &NewWeird(regular)}, nil
        case default:
            return nil, errors.Error("unknown weird type")
        }
    }
    

    The use of the interface as the return is handy if you want to initialize a bunch of heterogeneous Weirds and stuff them into a []Weird or something like that.

    Hope it helps...