How to Write Better Go Functions
Grace Collins
Solutions Engineer · Leapcell

Let’s first look at Ward Cunningham’s definition of “first-class citizens”:
If a programming language places no restrictions on the creation and use of a certain language element, and we can treat this syntactic element the same way we treat values, then we can call this syntactic element a “first-class citizen” in that programming language.
Simply put, in Go, functions can be assigned to variables.
A function can be passed as a parameter, used as a variable type, or as a return value.
As a Parameter
In kube-proxy, the function type makeEndpointFunc
is defined, with corresponding implementations in ipvs, nftables, and iptables.
type makeEndpointFunc func(info *BaseEndpointInfo, svcPortName *ServicePortName) Endpoint
Although the implementations are different, by unifying the function type, we can instantiate the same Endpoint information under different hardware supports. The application does not need to care about the specific implementation of Endpoint; as long as it can correctly obtain the necessary information from the node to complete upper-layer logic, it does not need to care about the specific implementation details of Endpoint.
func NewEndpointsChangeTracker(hostname string, makeEndpointInfo makeEndpointFunc, ipFamily v1.IPFamily, recorder events.EventRecorder, processEndpointsMapChange processEndpointsMapChangeFunc) *EndpointsChangeTracker { return &EndpointsChangeTracker{ endpointSliceCache: NewEndpointSliceCache(hostname, ipFamily, recorder, makeEndpointInfo), } }
You can see that NewEndpointsChangeTracker
directly uses the passed-in makeEndpointInfo
to initialize the cache.
Closures for Stateful Functions
Normally, after a function call is completed, the corresponding stack memory is released. But for functions with closures, the stack memory is only released after the closure is executed.
To put it more professionally: after a function call returns, there is a stack area whose resources are not yet released.
Although there is some performance overhead, the convenience closures bring to coding allows us to use them appropriately to improve code flexibility.
Closures can be used to pass parameters and construct new functions, thereby enabling stateful functions.
func NewDeploymentController(...) (*DeploymentController, error) { logger := klog.FromContext(ctx) dInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{ AddFunc: func(obj interface{}) { dc.addDeployment(logger, obj) }, UpdateFunc: func(oldObj, newObj interface{}) { dc.updateDeployment(logger, oldObj, newObj) }, DeleteFunc: func(obj interface{}) { dc.deleteDeployment(logger, obj) }, }) }
When calling ResourceEventHandlerFuncs
, we don’t need to pass in the logger parameter each time. We can directly construct the function’s logger, avoiding the need to define the logger parameter repeatedly.
Flexible Variadic Parameters
Let’s look at an example of the SharedInformerFactory
construction method in k8s.
type SharedInformerOption func(*sharedInformerFactory) *sharedInformerFactory
SharedInformerOption
is a function type that takes a sharedInformerFactory
, sets its value, and then returns the same sharedInformerFactory
.
func WithNamespace(namespace string) SharedInformerOption { return func(factory *sharedInformerFactory) *sharedInformerFactory { factory.namespace = namespace return factory } } type sharedInformerFactory struct { //... namespace string //... }
We see that WithNamespace
uses a closure to set the factory parameter, and returns a function type.
func NewSharedInformerFactoryWithOptions( client kubernetes.Interface, defaultResync time.Duration, options ...SharedInformerOption, ) SharedInformerFactory { factory := &sharedInformerFactory{} // Apply all variadic parameters here for _, opt := range options { factory = opt(factory) } return factory }
options
in this example is a variadic parameter. By using closures, Go allows for flexible handling of variadic parameters, even when the actual business logic requires setting parameters of different types.
The Application of Functors
Functors are great for performing batch homogeneous operations on container elements, and the code is often more elegant and concise than manually looping over each element. A functor is an interface that defines a function. The function takes a transformation function for the container elements as a parameter, and returns the functor interface.
The functor implementation is a struct with a container property, and implements the interface function. The logic is to loop through the container elements, call the transformation function on each, append the results to a new container, and finally return a new struct constructed from this new container.
This is suitable for scenarios where each piece of data needs to be processed, and the processing logic can be swapped like a plugin.
Let’s look at an example:
type User struct { ID int Name string } type Functor[T any] []T func (f Functor[T]) Map(fn func(T) T) Functor[T] { var result Functor[T] for _, v := range f { result = append(result, fn(v)) } return result }
The generic functor is defined by Functor
, and Map
processes each element.
func main() { users := []User{ {ID: 1, Name: "Alice"}, {ID: 2, Name: "Bob"}, {ID: 3, Name: "Charlie"}, } upperUserName := func(u User) User { u.Name = strings.ToUpper(u.Name) return u } us := Functor[User](users) us = us.Map(upperUserName) // Print transformed user data for _, u := range us { fmt.Printf("ID: %d, Name: %s\n", u.ID, u.Name) } }
Since Go 1.18 supports generics, the application of functors has become more extensive. In the past, you had to implement functors for each data type, and the code wasn’t very generic.
Functors seem more like syntactic sugar; in actual logic writing, the abstraction benefits functors bring to code are not huge. So this is more about technique—actual use depends on your real-world needs.
How to Write Good Functions
All these techniques ultimately serve our system design and code implementation, making the code easier to understand and maintain.
So, how can we actually write good functions in practice?
Reduce the Number of Function Parameters
If a function has too many parameters, consider whether you can encapsulate them in a struct to aggregate the information.
Let’s look at an example of creating a user:
func createUser(name string, age int, email string, address string, phoneNumber string) error{ // ... }
Here, all parameters are user information, so we can aggregate them with a User
struct:
// Encapsulate parameters in a struct type User struct { Name string Age int Email string Address string PhoneNumber string } func createUser(user User) error { //... }
This way, the parameters gain more semantic meaning, and aggregation provides more structural context when reading the code.
You can also use variadic parameters, which allows modification while providing default values and reduces the mental burden on the caller. If configuration values are linked, you don’t need to write two; one value can be derived from another.
Reduce Internal Variables
Too many variables inside a method often mean the method is shouldering too many responsibilities and needs to be split up.
A function acting like a Swiss army knife is not good—it burdens callers with too much cognitive load, and when modifying it, you need to check all usage for potential side effects.
How do we assess this in practice? By writing unit tests to see if your function is too complex.
If it’s hard to write a unit test for a function, you should consider whether the function has too many responsibilities or unclear semantics.
func CreateOrder(order *Order) error { // Validate order if len(order.Items) == 0 { return errors.New("order must have at least one item") } // Calculate total price and update inventory totalPrice := 0.0 for _, item := range order.Items { price, err := GetProductPrice(item.ProductID) if err != nil { return err } totalPrice += price * float64(item.Quantity) // Update inventory if err := UpdateInventory(item.ProductID, item.Quantity); err != nil { return err } } return nil }
In this method, writing unit tests means verifying a lot: order validity, total price calculation, whether inventory was updated—if any part changes, the tests become more complex.
We can split validation, inventory, and price calculation:
func ProcessOrder(order *Order) error { // Validate if err := ValidateOrder(order); err != nil { return err } // Calculate total price totalPrice, err := CalculateTotalPrice(order) if err != nil { return err } // Update inventory if err := ProcessInventory(order); err != nil { return err } return nil }
Now we don’t need to test ProcessOrder
directly—just validate it in integration testing, since it mainly acts as glue code. Testing order validation, total price calculation, and inventory update separately becomes much easier.
Pay Attention to Function Length
How long should a method be? There is no strict rule, but overemphasizing brevity can also lead to negative side effects.
The key is to control the cyclomatic complexity of the function. Avoid excessive complexity, as it increases the amount of context the reader must remember. Try to return early to reduce the burden of reading the function.
Let’s look at an example of a user registration function:
func RegisterUser(username, password, email string) error { if username == "" { return errors.New("username cannot be empty") } else { if len(username) < 3 { return errors.New("username must be at least 3 characters long") } else { if password == "" { return errors.New("password cannot be empty") } else { if len(password) < 6 { return errors.New("password must be at least 6 characters long") } else { if email == "" { return errors.New("email cannot be empty") } else { if !isValidEmail(email) { return errors.New("invalid email format") } else { // Perform user registration logic return nil } } } } } } }
By returning on error as soon as possible, we can simplify it like this:
func RegisterUser(username, password, email string) error { if username == "" { return errors.New("username cannot be empty") } if len(username) < 3 { return errors.New("username must be at least 3 characters long") } if password == "" { return errors.New("password cannot be empty") } if len(password) < 6 { return errors.New("password must be at least 6 characters long") } if email == "" { return errors.New("email cannot be empty") } if !isValidEmail(email) { return errors.New("invalid email format") } // Perform user registration logic return nil }
Be Mindful of Function Naming
How should you name a function? This is something that needs long-term review and attention.
For example, for a method that calculates the total price of a user’s shopping cart:
func CalculateTotalPriceOfCartForUser(userID int, cartID int) float64 {}
This is not as clear as the following name:
func GetCartTotal(userID, cartID int) float64 {}
If you split your logic into many small methods, inconsistent naming can lead to redundant implementations. This can be avoided by adhering to naming conventions.
Imagine if we could quickly find the function we need by intuition—would we still write a new one? Clearly not.
Function Abstraction Levels
Whether all the code within a function is at the same level of abstraction is also a standard for judging function quality.
If code is at the same abstraction level, it’s easy to quickly understand what the function does.
For example, in MVC:
- Controller handles parameter validation,
- Service handles logic,
- Dao handles database operations.
If you access the database in a Controller, you’re breaking abstraction levels, making the code harder to understand.
Functions Are Not Just for Eliminating Duplication
Although functions can eliminate duplicate code, their true value lies in creating abstractions—not just removing duplication.
If you abstract functions just to eliminate duplication, your code may become a tangled mess. When there is no need for reuse, it may be unclear what a function is really for.
At first, I used to abstract functions by looking for duplicate code. But as requirements increased, originally shared code would be given more and more personalized logic.
Eventually, as things diverged, many functions created “to eliminate duplication” became hard to understand.
Therefore, to write good functions, it’s more important to create good abstractions. Even if there’s no reuse right now, as the business grows, your abstracted logic will gradually be reused.
As abstraction levels rise, there are fewer details, and it gets closer to the real-world problems you want to solve.
When we communicate, we more often solve business problems through abstracted objects. How to translate these problems into highly maintainable code is something we should always strive for.
Summary
If you have extreme requirements for performance, sometimes you may have to sacrifice some code readability. However, this is rare in most business development scenarios, so we won’t discuss it in detail here.
Let’s summarize the article:
-
Go functions as first-class citizens: Functions in Go can be assigned to variables, passed as parameters, or used as return values. This is reflected in the
makeEndpointFunc
definition in kube-proxy, where a unified function type enables the same interface across different hardware implementations. -
Closures allow functions to maintain state: Closures make it possible for functions to capture external variables, increasing code flexibility. In
NewDeploymentController
, closures are used to directly reference the logger, avoiding the need to pass the logger as an extra parameter. With closure functions likeSharedInformerOption
, more flexible function variadic parameters are realized.
At the end, we also discussed how to truly write good functions:
- Reduce function parameters and internal variables by using structs for encapsulation or by splitting functions.
- Control function length and cyclomatic complexity; return early to reduce cognitive load when reading code.
- Pay attention to accurate and concise function naming, and avoid reinventing the wheel due to inconsistent naming.
- Keep code within a function at the same level of abstraction, making it easier to quickly understand a function’s purpose.
- The main value of functions is creating abstractions, not just eliminating duplicate code.
We are Leapcell, your top choice for hosting Go projects.
Leapcell is the Next-Gen Serverless Platform for Web Hosting, Async Tasks, and Redis:
Multi-Language Support
- Develop with Node.js, Python, Go, or Rust.
Deploy unlimited projects for free
- pay only for usage — no requests, no charges.
Unbeatable Cost Efficiency
- Pay-as-you-go with no idle charges.
- Example: $25 supports 6.94M requests at a 60ms average response time.
Streamlined Developer Experience
- Intuitive UI for effortless setup.
- Fully automated CI/CD pipelines and GitOps integration.
- Real-time metrics and logging for actionable insights.
Effortless Scalability and High Performance
- Auto-scaling to handle high concurrency with ease.
- Zero operational overhead — just focus on building.
Explore more in the Documentation!
Follow us on X: @LeapcellHQ