Refactoring Code: Choosing Functions Over Object-Oriented Structures in Go
Recently I needed to write a new business module that closely resembled one I had written two years ago in Go. I initially planned to adopt the old code, but it turned out to be too tightly coupled to its original context. Small modificasions wouldn’t cut it, so I decided to refactor the core logic. The main change was moving from object-oriented abstractions toward simple, stateless functions.
Prefer functions that don’t carry unnecessary state
The original code forced many operations into objects, maintaining extra fields that weren’t truly needed. Consider a Go function that starts a Docker container using the SDK. A typical call looks like this:
err = cli.ContainerStart(ctx, resp.ID, types.ContainerStartOptions{})
Creating the cli can fail:
cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
Getting the container ID also returns an error:
resp, err := cli.ContainerInspect(context.Background(), name)
So a single start operation involves three error sources. An object-oriented wrapper might look like this:
type ContainerManager struct {
client *client.Client
ctx context.Context
containerName string
containerID string
}
func NewContainerManager(name string) (*ContainerManager, error) {
cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
if err != nil {
return nil, err
}
ctx := context.Background()
resp, err := cli.ContainerInspect(ctx, name)
if err != nil {
return nil, err
}
return &ContainerManager{
client: cli,
ctx: ctx,
containerName: name,
containerID: resp.ID,
}, nil
}
func (m *ContainerManager) Start() error {
return m.client.ContainerStart(m.ctx, m.containerID, types.ContainerStartOptions{})
}
On the surface this seems convenient: the Start method reuses the cli and containerID stored in the struct. However, the constructor can fail, and any code that creates a ContainerManager must handle that error. Worse, if a ContainerManager is passed around and later used without checking for a nil pointer, calling Start() causes a panic. The error propagates outward, infecting every caller.
A cleaner approach is to avoid holding the mutable state altogether. Store only the container name, and let each method rebuild the required clients:
type ContainerManager struct {
containerName string
}
func NewContainerManager(name string) *ContainerManager {
return &ContainerManager{containerName: name}
}
func (m *ContainerManager) Start() error {
cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
if err != nil {
return err
}
ctx := context.Background()
resp, err := cli.ContainerInspect(ctx, m.containerName)
if err != nil {
return err
}
return cli.ContainerStart(ctx, resp.ID, types.ContainerStartOptions{})
}
Now the consrtuctor never fails, and external code doesn’t need to worry about invalid objects. But this version is essentially a thin wrapper around a function – the object adds little value. Removing the object entirely makes the code even simpler:
func StartContainer(name string) error {
cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
if err != nil {
return err
}
ctx := context.Background()
resp, err := cli.ContainerInspect(ctx, name)
if err != nil {
return err
}
return cli.ContainerStart(ctx, resp.ID, types.ContainerStartOptions{})
}
This function receives the container name as a parameter, creates and tears down its own dependencies, and returns a single unified error. There is no state to manage, no constructor to fail, and no risk of a nil-struct panic. For many scenarios – especially when the operation is self‑contained – this pattern is preferable to an object‑oriented wrapper.