๐ I’m Abhinav
Gopher @ Rippling
Previously: Pulumi, Uber
2023-09-27
Abhinav Gupta
๐ I’m Abhinav
Gopher @ Rippling
Previously: Pulumi, Uber
๐ abhinavg.net
๐ @abhinav
What are future-proof packages
What gets in the way
Future-proofing packages
Finding and fixing issues
Preventing issues
The ability to evolve in functionality
without disrupting the code base
Being forced to modify code
as a result of an unrelated change
Changes to packages with
a design that leaks complexity
Complexity is inevitable
Good design hides complexity
Complexity leaks cause fragility
Fragility causes disruption
๐งฎ Functions
๐ก Abstractions
Parameter objects
Result objects
Functional options
Put the inputs into a struct
type ClientConfig struct {
URL string
}
func New(cfg *ClientConfig) *Client {
return &Client{
/* ... */
}
}
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,
/* ... */
}
}
Put the outputs into a struct
type ListResult struct {
Items []*Item
ContinueAt ItemID
}
func List(from *ItemID) (*ListResult, error) {
/* ... */
}
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) {
/* ... */
}
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
}
}
type Option func(*clientOptions)
func New(opts ...Option) *Client {
var options clientOptions
for _, opt := range opts {
opt(&options)
}
/* ... */
}
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
}
}
๐
High boilerplate
Harder to test
Corner cases
๐
Several options
Few required inputs
not as options
Composability
Don’t use by default. Prefer parameter objects.
Accept interfaces
Return structs
interface
sDependencies should be interfaces
not concrete implementations
๐ |
|
|
๐ |
|
|
interface
sExtend by upcasting interfaces
func Parse(r io.Reader) (Node, error) {
/* ... */
}
interface
sExtend 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
}
struct
sReturn 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)
struct
sExtend 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
๐ฅ Input | ๐ค Output | |
---|---|---|
๐งฎ Functions | Parameter Objects | Result Objects |
Functional options | ||
๐ก Abstractions | Accept interfaces | Return structs |
Reluctance to declare types, instead preferring built-in types
๐งต String overuse
๐บ๏ธ Map overuse
๐ Boolean 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)
}
/* ... */
}
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)
}
/* ... */
}
Parse, don’t validate
Turn string
into struct
struct
value is evidence of validity
Eliminate chaos early
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)
}
/* ... */
}
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)
}
/* ... */
}
๐ | ๐ | |
---|---|---|
|
| |
|
| |
|
| |
|
|
|
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 {
/* ... */
}
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
}
๐ | ๐ | |
---|---|---|
|
|
|
|
|
|
|
|
Overly specific booleans
type SiteGen struct {
/* ... */
}
func (g *SiteGen) RelativeURL(dst *Page) string {
u := /* ... */
return u + "/"
}
g.RelativeURL(p) // "path/to/dst/"
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
Use an enum
type LinkStyle int
const (
LinkStyleDir LinkStyle = iota
LinkStylePlain
)
type SiteGen struct {
/* ... */
LinkStyle LinkStyle
}
Use an enum
type LinkStyle int
const (
LinkStyleDir LinkStyle = iota
LinkStylePlain
LinkStyleHTML
)
type SiteGen struct {
/* ... */
LinkStyle LinkStyle
}
๐ฃ๏ธ๐ But wait, there’s more ๐ข
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 */
}
/* ... */
}
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 */
}
/* ... */
}
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
}
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
Extract and inject process globals early in main
|
| |
|
|
|
|
|
|
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)
๐ฅ Premature interfaces
๐ฆ Big 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) {
/* ... */
}
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) {
/* ... */
}
Producers may define interfaces for…
Single operation interfaces
Multiple implementations
Wrapped abstractions
Others
Big interfaces cause
a tight coupling
between abstractions
The bigger the interface,
the weaker the abstraction.
Making interfaces smaller
Follow the request path
Use functions
Build a strong core
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()
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
}
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)
}
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
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
}
Flow in one direction without backtracking
Flow in one direction without backtracking
Zigzagging flow indicates a leak
Application scope
Does not change between requests
Command line arguments
Environment variables
Request scope
Changes for each request
HTTP request
???
Scope is relative
Structs | Large scope: fields | |
---|---|---|
Interfaces | Request-scoped methods |
|
Parameters | Sort by scope (big to small) |
|
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
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'
Remove special cases based on conditions
that don’t change in the current context
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"
}
}
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"
}
Find large scoped function parameters
and wrap them into objects
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
Future-breaking code
leaks complexity,
causes disruption
Plan for expansion
Chaos to order early
Find the abstraction
Write deep modules
Flow in one direction
A Philosophy of Software Design by John Ousterhout