Software Design Principles Applied to Go

Raul Jordan
Prysmatic Labs
11 min readOct 13, 2021

--

Background

Prysmatic Labs builds the software behind the next generation Ethereum blockchain. Our main project, Prysm, is one of the popular Ethereum clients used for proof-of-stake today. We love programming in Go and believe in using tried-and-tested design principles to manage complexity at scale. When writing a distributed systems application with so much money at stake, we are always looking for ways to level up our software engineering processes. We are also currently hiring! We are on the verge of completing one of the biggest milestones in blockchain technology, migrating Ethereum fully into proof-of-stake. If this is the kind of stuff that catches your interest, come join us to help make this mission a reality.

Prysm has become a fairly large Go project with a diverse set of contributors and complex features. As our project has become more critical and running in production, it is integral we, as software engineers, improve how we design our code for each other and for other developers. We believe bringing popular software principles into our organization will have a positive, compounding effect on our day-to-day. This document outlines some Go practices we are adopting in our codebase and can also help improve other large Go, open source projects.

Design Patterns

SOLID

SOLID is a 5 letter acronym defining popular software engineering principles to write clean, maintainable, and ideally more secure code. It stands for:

  • Single responsibility principle
  • Open / closed principle
  • Liskov substitution principle
  • Interface substitution principle
  • Dependency inversion principle

In general, the teachings of SOLID allow developers to create extremely readable code that is easy to test, refactor as needed, and lead to major productivity boosts when working on complex codebases. When it comes to managing complexity, using the right abstractions and principles makes life easier for an entire team. We’ll go over every letter of SOLID in this document and outline how it can be applied to Prysm.

Shift-Left Thinking

Another useful pattern is Shift-Left Thinking The earlier in the software development process a failure can be detected, the easier the failure can be corrected. Following that assertion to its logical conclusion, we should prefer type system / compiler checks > unit tests > integration tests > end-to-end tests > production failures. As such, emphasis should be placed on leveraging Go to its full capacity and using the power of the compiler to prevent bugs making it into PRs at all.

Single responsibility principle

Let’s take a look at an important function in Prysm, namely, validateBeaconBlockPubSub in beacon-chain/sync/validate_beacon_block.go. The function spans 150 lines of code doing some of the following actions:

  • Acquisitions of mutexes
  • Sending over event feeds
  • Validation of data integrity
  • Retrieval of data from the database
  • Acquiring of another mutex to add data to a cache map
  • Slot time verification
  • More database access

It becomes really difficult to have full coverage of this function, and also becomes hard to modify in the future if needed. For a new reader of our code, reasoning about its complexity is painful. What if we need to refactor one part of it?

Refactoring safely while wrangling mutexes, caches, goroutine spawns, etc. becomes a major task with potentially dangerous consequences…

The single responsibility favors creating smaller functions to accomplish smaller tasks we care about. For example, in the function above, we could have a function that checks if we’re ready to validate the block, such as simply checking if we are receiving a message from ourselves and not currently syncing. Next, we could have another function that encompasses a few smaller tasks, etc. At the end, our function will be a lot more readable and easier to unit test in smaller chunks.

Risks of SRP done incorrectly

Purely aesthetic SRP

Note: it can be tempting to fulfill the single responsibility principle (SRP) by just taking chunks of a function and putting them into one-of helpers with poor names just to make things cleaner. Instead, splitting code up for SRP should be intentional and care should be taken to ensure functions are named after their purpose.

These smaller helpers are only used to split up and make the function look pretty, but these helpers are neither reusable nor easy to understand without the context of the bigger function surrounding them. We should avoid SRP for aesthetic purposes only.

Bugs with improper SRP

The main thing to consider with SRP is whether or not we are encapsulating code properly. Caching issues are a good example of where violating the SRP can introduce bugs. When every consumer of a cache is required to make remember the cache keys are appropriately marked dirty system, the risk for mistakes shoots up, compared to if that is an internal detail of a data access type that mediates talking to the cache vs. talking to the database. Reducing or eliminating things that callers need to remember is a good way to prevent bugs using the principle of SRP.

Recommendations

Reusable validation pipelines with functional options

There are common data structures in Prysm that are validated through our codebase in different ways. Namely, blocks, attestations, signed messages, etc. Many times, defining validation pipelines ends up being repetitive, verbose, and violates principles of DRY (don’t repeat yourself) code. An alternative for better validation of data structures is to have reusable, extensible validation pipelines that are easy to include as desired.

For example, in a web application, you might write form validators that are extensible and easy to chain, such as:

In Prysm, there are many functions that perform some validation on blocks or other data structures. Let’s take a look at how we could create reusable validation pipelines for blocks:

Other functions in our codebase might also want to validate n block from input arguments, but use fewer of these validators. For example, we may only want to check for structural integrity and for a valid signature.

Open / closed principle

This principle refers to writing code that is simple to maintain. It states that types such as Go interfaces or classes in object oriented programming should be closed for modification but open for extension. In Go, this means that if we want to modify or add new functionality to an structs, we shouldn’t have to worry about breaking or changing all other code that depends on it. Instead, we should favor interfaces as a way to extend functionality.

Recommendations

Leverage interface embedding in structs

Interface embedding in structs is a powerful way to extend an interface or “override a method”. For example, let’s say we want to implement sorting in reverse. We already have the awesome sort.Interface from the standard library, and we know that sorting in reverse simply requires a different comparison function to check list elements.

A really nice way to write clean code for this is to leverage the standard library as follows:

Embedding sort.Interface just means that reverse holds a value that is a type that satisfies sort.Interface. By implementing Less, reverse itself satisfies sort.Interface, it's not really overriding anything, it's just allowing us to wrap and delegate for another value that implements sort.Interface. TeeReader is a standard library example of this kind of pattern https://cs.opensource.google/go/go/+/refs/tags/go1.17:src/io/io.go;l=550

Liskov substitution principle

This principle states that as long as two types are interchangeable if a caller is unable to tell the difference. In Go, we use interfaces to accomplish this. If we can abstract common code into interface that defines some behavior, we can use it extensively. Although we do a good job at using interfaces in general, there is room to improve to use them at their full extent.

Recommendations

Smaller interfaces are preferred for composition

Rather than having a major interface

type Database interface {
... // 10-20 fields.
}

We could split up this interface into smaller chunks and compose them into the bigger interface. We use this pattern in Prysm effectively, but still have a few large interfaces in the codebase that could be refactored.

Interface segregation principle

In general, callers should not be forced to depend on arguments they do not use. This helps keep code clean and easy to test. In Prysm, we try to follow this pattern reasonably, but there is still room for improvement.

Depend only on what you need

Bad:

Preferred:

Dependency inversion

High-level modules should not depend on low-level modules. Both should depend on abstractions. Abstractions should not depend on details. Details should depend on abstractions.

  • Uncle Bob

High-level code should deal with specifics, low-level with abstractions.

CLI flags need not be accessed beyond the main package

In Prysm, our main.go file simply serves to define execution commands and list the flags used. Then, control flows into beacon-chain/node/node.go or validator/node/node.go, which then perform a wide array of cli flag parsing and checking. Moreover, we end up propagating cli flag contexts down to low-level packages such as the database. It is common to see code in different parts of Prysm that accesses cli.Context to fetch flag values such as dataDir := cli.StringFlag(cmd.DataDirFlag.Name).

Example deep inside the validator’s account package which should not need to deal with CLI flags at such a low level of abstraction:

Instead, we could leverage our main packages a lot more for initialization of configuration values from flags, reading user input, and performing otherwise "specific", implementation-dependent operations. The recommendation is to avoid leaking cli.Context outside of any main package in Prysm. Ideally, low-level packages should deal in abstractions, interfaces, rather than specifics of our application context.

Avoid tight coupling

Golang does not allow circular package imports, and for good reason. More often than not, having a circular dependency is a sign that code should perhaps live side by side. These circular graphs typically arise when we try to segregate packages based on what feels nice rather than what is functional.

For example, let’s say we have a client/ and server/ package in a web application, and the client requires some types and imports the server package. However, the server imports the client package as well because it needs to know some information about the kind of client options beind used for initialization. This is a code smell, and instead we might find these should be consolidated under a single package. net/http has both client.go and server.go files for this same reason.

Preferred:

http/
client.go
server.go

Current:

server/
server.go
client
client.go
types/
types.go

We often create a third package, such as types simply to break import cycles. However, this does not solve the reason we have a cycle in the first place.

A specific example in Prysm is the tight coupling that exists between packages on initialization. For example, initial sync, sync, and blockchain depend on each other for various things, so we fix this dependency by having global feeds in the node/ package, which makes things significantly more complex.

The initialization process of Prysm often relies on things to be done across different packages in a specific order, which is communicated via the shared event feed. This creates tight coupling (whereas SRP pushes us towards loosely coupled components). Likewise the initial-sync, sync and blockchain packages all share responsibility for key stages of the blockchain processing algorithm.

Avoid multiple other packages modifying properties of a struct in a package

One form of tight coupling we have is where multiple packages may attempt to mutate the same struct value. This often leads to fairly complex locking logic. another reference point / rule of thumb for limiting what packages know about each other is https://en.wikipedia.org/wiki/Law_of_Demeter. Specifically “Only talk to your immediate friends”.

Miscellaneous Design Patterns

Encapsulation

Abstract caches away

Users of caches should not need to worry about acquiring locks nor transforming the cache keys for the objects involved.

Current:

Preferred:

Encapsulate nil checks

Too many nil checks exposed that could be done in one place can lead to bugs if someone forgets to perform the nil checks. By encapsulating nil checks, we get the ability to make safe calls and simplify our code a lot.

Imagine if HTTP 404 did not exist and everyone always received 200s and they had to then do nil checks on the body to check if the data is not found — Kasey Kirkham from our team

Current:

Preferred:

Minimize flag conditionals in the wrong places

We should place feature flag conditionals deep in the implementation they toggle and not require the rest of the codebase to deal with setting conditionals.

Current:

Preferred:

Leverage package-level errors

Current

Preferred

Concurrency

Use channels more for communication instead of mutexes

Current

Preferred: in-progress cache using semaphore

We Are Hiring!

We are always looking for new developers interested in working with us. If you know Go and want to contribute to the forefront of research on Ethereum, please drop us a line: hiring@prysmaticlabs.com.

If you want to contribute to our open source project, check out our open issues on Github. You can also join our active Discord community to chat about all things Ethereum proof-of-stake and running our software.

Prysmatic Labs Ether Donation Address

0x9B984D5a03980D8dc0a24506c968465424c81DbE

Prysmatic Labs ENS Name

prysmatic.eth

References

Acknowledgements

First of all, thanks to everyone on the team that contributed towards the software used by the majority of Ethereum beacon chain consensus. You guys are rockstars that have done amazing work under the time contraints of shipping the Ethereum beacon chain, and we constantly learn from your output. We want to give credit and huge thanks to Terence Tsao and Nishant Das from our team. Under extremely complex constraints, the code they have written now powers Prysm’s p2p and blockchain processing logic in production and we wouldn’t be here without their work. Thanks to Kasey and Preston for their insights and recommendations. Kasey for comments that started this discussion and Preston for teaching the ways of Uncle Bob

--

--