Future-Proof Go Packages

2023-09-27

Hello!

๐Ÿ‘‹ I’m Abhinav

Gopher @ Rippling

Previously: Pulumi, Uber

Agenda

  • What are future-proof packages

  • What gets in the way

  • Future-proofing packages

    • Finding and fixing issues

    • Preventing issues

What makes a package future-proof?

The ability to evolve in functionality
without disrupting the code base

Disruption

Being forced to modify code
as a result of an unrelated change

What causes disruption?

Changes to packages with
a design that leaks complexity

On complexity

Diagram
  • Complexity is inevitable

  • Good design hides complexity

  • Complexity leaks cause fragility

  • Fragility causes disruption

Future-proofing packages

Planning for expansion

  • ๐Ÿงฎ Functions

  • ๐Ÿ’ก Abstractions

๐Ÿงฎ Expanding functions

  • Parameter objects

  • Result objects

  • Functional options

Parameter objects

Put the inputs into a struct

type ClientConfig struct {
  URL string
}

func New(cfg *ClientConfig) *Client {
  return &Client{
    /* ... */
  }
}

Parameter objects

Put the inputs into a struct

  • Use for >3 parameters
    not counting context.Context

  • New parameters must be optional

type ClientConfig struct {
  URL string
  Log *slog.Logger
}

func New(cfg *ClientConfig) *Client {
  log := cfg.Log
  if log == nil {
    log = DiscardLogger
  }
  return &Client{
    log: log,
    /* ... */
  }
}

Result objects

Put the outputs into a struct

type ListResult struct {
  Items      []*Item
  ContinueAt ItemID
}

func List(from *ItemID) (*ListResult, error) {
  /* ... */
}

Result objects

Put the outputs into a struct

  • Use for >2 returns
    not counting error

  • Obvious field names

type ListResult struct {
  Items      []*Item
  ContinueAt ItemID
  Remaining  int
}

func List(from *ItemID) (*ListResult, error) {
  /* ... */
}

Functional options

๐Ÿ™‚
  • Flexible

  • Customizable

๐Ÿ™
  • Complex

type Option func(*clientOptions)

func New(opts ...Option) *Client {
  var options clientOptions
  for _, opt := range opts {
    opt(&options)
  }
  /* ... */
}
type clientOptions struct {
  logger *slog.Logger
}

func WithLogger(l *slog.Logger) Option {
  return func(o *clientOptions) {
    o.logger = l
  }
}

Functional options

type Option func(*clientOptions)

func New(opts ...Option) *Client {
  var options clientOptions
  for _, opt := range opts {
    opt(&options)
  }
  /* ... */
}
Diagram
type clientOptions struct {
  logger *slog.Logger
  /* ... */
  httpClient *http.Client
}

func WithLogger(l *slog.Logger) Option {
  return func(o *clientOptions) {
    o.logger = l
  }
}

func WithHTTPClient(c *http.Client) Option {
  return func(o *clientOptions) {
    o.httpClient = c
  }
}

Functional options

Diagram

๐Ÿ™

  • High boilerplate

  • Harder to test

  • Corner cases

๐Ÿ™‚

  • Several options

  • Few required inputs
    not as options

  • Composability

Don’t use by default. Prefer parameter objects.

๐Ÿ’ก Expanding abstractions

  • Accept interfaces

  • Return structs

Accept interfaces

Dependencies should be interfaces
not concrete implementations

๐Ÿ™

func Parse(f *os.File) (Node, error)
func Login(c *Client) error

๐Ÿ™‚

func Parse(r io.Reader) (Node, error)
func Login(c AuthClient) error
type AuthClient interface {
  Authenticate(Credentials) error
}

Accept interfaces

Extend by upcasting interfaces

func Parse(r io.Reader) (Node, error) {
  /* ... */
}

Accept interfaces

Extend by upcasting interfaces

Breaks if wrapped

type countLines struct{ io.Reader }
Parse(&countLines{file}) // oops!
  • Make it obvious

  • Use small interfaces

func Parse(r io.Reader) (Node, error) {
  name := "<unknown>"
  if src, ok := r.(Source); ok {
    name = src.Name()
  }
  /* ... */
}
type Source interface {
  io.Reader
  Name() string
}

Return structs

Return concrete implementations
not interfaces

๐Ÿ™

type Client interface {
  List() (ListResult, error)
}

func New() Client {
  return &impl{/* ... */}
}

type impl struct{/* ... */}

func (*impl) List() (ListResult, error)

๐Ÿ™‚

type Client struct{ /* ... */ }

func New() *Client {
  return &Client{/* ... */}
}

func (*Client) List() (ListResult, error)

Return structs

Extend by adding new methods

๐Ÿ™

 type Client interface {
   List() (ListResult, error)
+  Put(PutRequest) error /* BAD */
 }

Disruptive:
Breaks other implementations
(e.g. mocks, middleware)

๐Ÿ™‚

 func (*Client) List() (ListResult, error)
+func (*Client) Put(PutRequest) error

Planning for expansion

๐Ÿ“ฅ Input๐Ÿ“ค Output

๐Ÿงฎ Functions

Parameter Objects

Result Objects

Functional options

๐Ÿ’ก Abstractions

Accept interfaces

Return structs

Finding and fixing problems

Built-in obsession

Reluctance to declare types, instead preferring built-in types

  • ๐Ÿงต String overuse

  • ๐Ÿ—บ๏ธ Map overuse

  • ๐ŸŽ‚ Boolean overuse

adds complexity

๐Ÿงต String overuse

Over-reliance on strings

func isHTTP(addr string) bool {
  return strings.HasPrefix(addr, "http://") ||
    strings.HasPrefix(addr, "https://")
}
func download(addr string) {
  if isHTTP(addr) {
    http.Get(addr)
  } else {
    log.Panic("unsupported:", addr)
  }
  /* ... */
}

๐Ÿงต String overuse

Over-reliance on strings

func isHTTP(addr string) bool {
  return strings.HasPrefix(addr, "http://") ||
    strings.HasPrefix(addr, "https://")
}
func isSSH(addr string) bool {
  return strings.HasPrefix(addr, "ssh://")
}

func sshDownload(addr string) {
  addr = strings.TrimPrefix(addr, "ssh://")
  /* ... */
}
func download(addr string) {
  if isHTTP(addr) {
    http.Get(addr)
  } else if isSSH(addr) {
    sshDownload(addr)
  } else {
    log.Panic("unsupported:", addr)
  }
  /* ... */
}

๐Ÿงต String overuse

Parse, don’t validate

 — Alexis King
  • Turn string into struct

  • struct value is evidence of validity

  • Eliminate chaos early

Diagram

๐Ÿงต String overuse

Over-reliance on strings

func isHTTP(addr string) bool {
  return strings.HasPrefix(addr, "http://") ||
    strings.HasPrefix(addr, "https://")
}
func isSSH(addr string) bool {
  return strings.HasPrefix(addr, "ssh://")
}

func sshDownload(addr string) {
  addr = strings.TrimPrefix(addr, "ssh://")
  /* ... */
}
func download(addr string) {
  if isHTTP(addr) {
    http.Get(addr)
  } else if isSSH(addr) {
    sshDownload(addr)
  } else {
    log.Panic("unsupported:", addr)
  }
  /* ... */
}

๐Ÿงต String overuse

Chaos to order

func isHTTP(addr *url.URL) bool {
  return addr.Scheme == "http" ||
    addr.Scheme == "https"
}
func isSSH(addr *url.URL) bool {
  return addr.Scheme == "ssh"
}

func sshDownload(addr *url.URL) {
  /* ... */
}
func download(addrs string) {
  addr, err := url.Parse(addrs)
  if err != nil {
    log.Panic(err)
  }
  if isHTTP(addr) {
    http.Get(addrs)
  } else if isSSH(addr) {
    sshDownload(addr)
  } else {
    log.Panic("unsupported:", addr)
  }
  /* ... */
}

๐Ÿงต String overuse

๐Ÿ™๐Ÿ™‚
strings.HasPrefix(addr, "ssh://")
u, err := url.Parse(addr)
var uuid string
type UUID [16]byte
func ParseUUID(string) (UUID, error)
var ts int64
t := time.UnixMilli(ts)
strings.Replace(s, "%VAR%", val)
type Node struct{ Var, Str string }
type Template []Node
func Parse(string) Template
tmpl := Parse(s)
tmpl.Render(
  map[string]string{"VAR": val},
)

๐Ÿ—บ๏ธ Map overuse

Over-reliance on maps

Uninformative type

Business constraint in signature
Will uniqueness always be required?

func BulkRegister(users map[string]string) error {
  /* ... */
}
func Register(email, login string) error {
  /* ... */
}

๐Ÿ—บ๏ธ Map overuse

Over-reliance on maps

Uninformative type

Business constraint in signature
Will uniqueness always be required?

func BulkRegister(reqs []RegisterRequest) error {
  emails := make(map[string]struct{})
  for _, r := range reqs {
    if _, used := emails[r.Email]; used {
      return fmt.Errorf("email already used: %v", r.Email)
    }
    emails[r.Email] = struct{}{}
  }
  /* ... */
}
type RegisterRequest struct {
  Email, Login string
}

๐Ÿ—บ๏ธ Map overuse

๐Ÿ™๐Ÿ™‚
map[string][]string
[]PackageCoverage
type PackageCoverage struct {
  ImportPath string
  CoverFiles []string
}
map[string]map[string]int
[]RateLimit
type RateLimit struct {
  From, To string
  RPS      int
}
map[string]*HealthCheck
[]*HealthCheck

๐ŸŽ‚ Boolean overuse

Overly specific booleans

type SiteGen struct {
  /* ... */
}

func (g *SiteGen) RelativeURL(dst *Page) string {
  u := /* ... */
  return u + "/"
}

g.RelativeURL(p) // "path/to/dst/"

๐ŸŽ‚ Boolean overuse

Overly specific booleans

func (...) RelativeURL(
  dst *Page,
  slash bool,
) string

Disruptive
Poorly scoped

type SiteGen struct {
  /* ... */
  AddSlash bool
}

Disruptive

type SiteGen struct {
  /* ... */
  OmitSlash bool
}

Good
Not terrible

๐ŸŽ‚ Boolean overuse

Use an enum

type LinkStyle int

const (
  LinkStyleDir   LinkStyle = iota
  LinkStylePlain
)
type SiteGen struct {
  /* ... */
  LinkStyle LinkStyle
}

๐ŸŽ‚ Boolean overuse

Use an enum

type LinkStyle int

const (
  LinkStyleDir   LinkStyle = iota
  LinkStylePlain
  LinkStyleHTML
)
type SiteGen struct {
  /* ... */
  LinkStyle LinkStyle
}

๐Ÿ—ฃ๏ธ๐Ÿ”Š But wait, there’s more ๐Ÿ“ข

โžฟ Callback overuse

Function reference lock-in

  • Opaque

  • Inflexible

type SiteGen struct {
  /* ... */
  LinkStyle func(string) string
}
func LinkStyleDir(string)   string { ... }
func LinkStylePlain(string) string { ... }
func LinkStyleHTML(string)  string { ... }
func (g *SiteGen) RelativeURL(dst *Page) string {
  styleLink := g.LinkStyle
  if styleLink == nil {
    styleLink = LinkStyleDir /* default */
  }
  /* ... */
}

โžฟ Callback overuse

Use an interface

type LinkStyler interface {
  StyleLink(string) string
}
type DirLinkStyler   /* ... */
type PlainLinkStyler /* ... */
type HTMLLinkStyler  /* ... */
type SiteGen struct {
  /* ... */
  LinkStyler LinkStyler
}
func (g *SiteGen) RelativeURL(dst *Page) string {
  styler := g.LinkStyle
  if styler == nil {
    styler = new(LinkStyleDir) /* default */
  }
  /* ... */
}

โžฟ Callback overuse

Upcast the interface

type LinkStyler interface {
  StyleLink(string) string
}

type PageLinkStyler interface {
  StylePageLink(*Page, string) string
}
type SiteGen struct {
  /* ... */
  LinkStyler LinkStyler
}
func (g *SiteGen) RelativeURL(dst *Page) string {
  styler := g.LinkStyle
  if styler == nil {
    styler = new(LinkStyleDir) /* default */
  }
  /* ... */
  if pl, ok := styler.(PageLinkStyler); ok {
    link = pl.StylePageLink(page, link)
  } else {
    link = linkStyler.StyleLink(link)
  }
  return link
}

Global state

๐Ÿ Implicit process globals

Process globals include

os.Stdout
os.Stdin
os.Stderr
os.Getenv(k)
os.Setenv(k, v)
if os.Getenv("FEATURE_MAGIC") != "" {
  fmt.Println("You're a wizard")
  return doMagic()
}
return beBoring()
  • Don’t touch process globals outside main

  • Isolate business logic from process state

๐Ÿ Implicit process globals

Extract and inject process globals early in main

os.Getenv("FEATURE_MAGIC")
func main() {
  feats := Features{Magic: os.Getenv("FEATURE_MAGIC")}
  run(feats)
}
fmt.Println(
  "You're a wizard",
)
func main() {
  run(os.Stdout)
}
func run(stdout io.Writer)
os.Getenv
func main() {
  run(os.Getenv)
}
func run(
  getenv func(string) string,
)

๐Ÿ Implicit process globals

Inject process globals

type cliParams struct {
  Stdout io.Writer
  Stderr io.Writer
  Getenv func(string) string
}

func run(*cliParams)
type App struct {
  Stdout io.Writer
  Stderr io.Writer
  Getenv func(string) string
}

func (*App) Run(args []string) (exitCode int)

Interface misuse

  • ๐Ÿฅš Premature interfaces

  • ๐Ÿฆ– Big interfaces

๐Ÿฅš Premature interfaces

  • Unnecessary
    Interfaces match dynamically

  • Inflexible
    New methods are disruptive

  • Complex
    Constructor is required

type Client interface {
  List() ([]*Post, error)
}

func New() Client {
  return &impl{/* ... */}
}

type impl struct{/* ... */}

func (*impl) List() ([]*Post, error) {
  /* ... */
}

๐Ÿฅš Premature interfaces

Use a struct

  • Producers expose concrete types

  • Consumers define interfaces

type PostClient interface {
  List() ([]*Post, error)
}

func NewHandler(pc PostClient) *Handler {
  /* ... */
}
type Client struct{/* ... */}

func New() *Client {
  return &Client{/* ... */}
}

func (*Client) List() ([]*Post, error) {
  /* ... */
}

๐Ÿฅš Premature interfaces

Producers may define interfaces for…​

  • Single operation interfaces

  • Multiple implementations

  • Wrapped abstractions

  • Others

๐Ÿฆ– Big interfaces

Big interfaces cause
a tight coupling
between abstractions

The bigger the interface,
the weaker the abstraction.

Gopherfest 2015
— Rob Pike

๐Ÿฆ– Big interfaces

Making interfaces smaller

  • Follow the request path

  • Use functions

  • Build a strong core

Follow the request path

Add methods from
the request path

type Store interface {
  Get(string) (string, error)
  Set(k, v string) error
}

No Close()

s := NewRedisStore(addr)
defer s.Close()
run(s)
// Given,
//   func run(Store)
type RedisStore struct{ /* ... */ }

func NewRedisStore(addr string) *RedisStore

func (*RedisStore) Get(string) (string, error)

func (*RedisStore) Set(k, v string) error

func (*RedisStore) Close()

Use functions

Convenience methods stay out of the interface

func (s *RedisStore) SetMany(ks, vs []string) error {
  for i, k := range ks {
    err := s.Set(k, vs[i])
    if err != nil {
      return nil, err
    }
  }
  return nil
}

Use functions

Convenience methods stay out of the interface

func SetMany(s Store, ks, vs []string) error {
  for i, k := range ks {
    err := s.Set(k, vs[i])
    if err != nil {
      return nil, err
    }
  }
  return nil
}
func (s *RedisStore) SetMany(
  ks, vs []string,
) error {
  return s.redisc.superFastSetMany(ks, vs)
}

Use functions

Convenience methods stay out of the interface

func SetMany(s Store, ks, vs []string) error {
  if sm, ok := s.(SetManyStore); ok {
    return sm.SetMany(ks, vs)
  }
  for i, k := range ks {
    err := s.Set(k, vs[i])
    if err != nil {
      return nil, err
    }
  }
  return nil
}
type SetManyStore interface {
  Store
  SetMany(ks, vs []string) error
}
func (s *RedisStore) SetMany(
  ks, vs []string,
) error {
  return s.redisc.superFastSetMany(ks, vs)
}

Upcast to upgrade

Build a strong core

Wrap a small interface with powerful functionality

type DataStore struct{ s Store }

func (*DataStore) Get(string) (string, error)
func (*DataStore) Set(k, v string) error
type Store interface {
  Get(string) (string, error)
  Set(k, v string) error
}
func (d *DataStore) SetMany(ks, vs []string) error {
  if sm, ok := s.(SetManyStore); ok {
    return sm.SetMany(ks, vs)
  }
  /* ... */
}
type SetManyStore interface {
  Store
  SetMany(ks, vs []string) error
}

Zooming out

๐ŸŒŠ Flow of information

Diagram

๐ŸŒŠ Flow of information

Diagram
Diagram

Flow in one direction without backtracking

๐ŸŒŠ Flow of information

Flow in one direction without backtracking

Diagram
Diagram

๐ŸŒŠ Flow of information

Diagram

Zigzagging flow indicates a leak

๐Ÿ” Scope

Application scope

Does not change between requests

  • Command line arguments

  • Environment variables

Request scope

Changes for each request

  • HTTP request

  • ???

๐Ÿ” Scope

Scope is relative

Diagram

๐Ÿ” Scope

Structs

Large scope: fields
Small scope: parameters

Diagram

Interfaces

Request-scoped methods
on the interface

type Store interface {
  Get(string) (string, error)
  Set(k, v string) error
}

Parameters

Sort by scope (big to small)

func GetUser(
  org, username string,
) *User

Surface area

Diagram

๐Ÿ“ฆ Surface area and depth

Diagram

๐Ÿ“ฆ Surface area and depth

Diagram

๐Ÿ“ฆ Surface area and depth

Wide and shallow packages

  • Frequent entry and exit

  • May cause zigzagging

Narrow and deep packages

  • Very few entry points

  • Upfront design work

Business packages should be narrow and deep

๐Ÿ“ฆ Surface area and depth

Might be wide and shallow if
there are top-level functions that

  • Do RPC and IO

  • Access global state

  • Have shared arguments

  • Are interrelated

  • Are in 'util'

Diagram

Finding the abstraction

๐Ÿ”ญ Large scoped conditions

Remove special cases based on conditions
that don’t change in the current context

Diagram

๐Ÿ”ญ Large scoped conditions

type SiteGen struct {
  /* ... */
  LinkStyle LinkStyle
}
type LinkStyle int

const (
  LinkStyleDir   LinkStyle = iota
  LinkStylePlain
  LinkStyleHTML
)
func (g *SiteGen) RelativeURL(dst *Page) string {
  /* ... */
  switch g.LinkStyle {
  case LinkStyleDir:
    return u + "/"
  case LinkStylePlain:
    return strings.TrimSuffix(u, "/")
  case LinkStyleHTML:
    return u + ".html"
  }
}

๐Ÿ”ญ Large scoped conditions

type SiteGen struct {
  /* ... */
  LinkStyler LinkStyler
}
func (g *SiteGen) RelativeURL(dst *Page) string {
  /* ... */
  return g.LinkStyler.StyleLink(u)
}
type LinkStyler interface {
  StyleLink(string) string
}

type (
  DirLinkStyler   /* ... */
  PlainLinkStyler /* ... */
  HTMLLinkStyler  /* ... */
)
func (DirLinkStyler) StyleLink(u string) string {
    return u + "/"
}

func (PlainLinkStyler) StyleLink(u string) string {
    return strings.TrimSuffix(u, "/")
}

func (HTMLLinkStyler) StyleLink(u string) string {
    return u + ".html"
}

๐Ÿšง Partial function application

Find large scoped function parameters
and wrap them into objects

๐Ÿšง Partial function application

Diagram

๐Ÿšง Partial function application

Diagram
Diagram
Diagram

Writing the abstraction

Start on the outside

Design ⇒ Document ⇒ Implement

Name things clearly

Be consistent, re-use terms,
no kitchen sinks

Don’t leak internals

Don’t add features,
integrate

Conclusion

Future-breaking code
leaks complexity,
causes disruption

  • Plan for expansion

  • Chaos to order early

  • Find the abstraction

  • Write deep modules

  • Flow in one direction

See also

philosophy of software design

A Philosophy of Software Design by John Ousterhout

License