Easy Decoding: Unraveling the Balking Pattern in Go for Effortless Mastery

Photo by Jill Burrow: https://www.pexels.com/photo/metal-chain-link-door-of-house-in-sunlight-6387820/

Introduction

The Balking Pattern might not be widely known, but it plays a crucial role in preventing certain actions on objects based on their state. For instance, attempting to read the contents of an unopened file should not be allowed.

Advantages and disadvantages of the Balking Pattern

The Balking Pattern has several advantages:

  1. Preventing Unnecessary Operations: It stops unnecessary operations, particularly for time- or resource-intensive tasks, leading to improved performance.
  2. Enhanced Readability: Implementing explicit error checking, such as returning a Result struct in Rust, enhances code readability and robustness.
  3. Error Avoidance: Through explicit error checking, potential errors can be avoided.

The disadvantages are:

  1. Increased Complexity: The pattern may introduce extra complexity due to additional state-checking in objects.
  2. Developer Discipline: Proper state checking requires discipline on the part of developers.

Implementation in Go

In this example we will implement a simple MemoryFile, which is basically a string in our implementation.

This type can have three states: OpenState, ClosedState and UndefinedState. The UndefinedState is used to indicate that either the file has just been created or is in some form of error state . In our Go implementation they are implemented as empty structs:

package main

import "errors"

type OpenState struct{}
type ClosedState struct{}
type UndefinedState struct{}

Next we implement the MemoryFile struct:

type MemoryFile struct {
	name  string
	state interface{}
}

func NewMemoryFile(name string) *MemoryFile {
	return &MemoryFile{name: name, state: UndefinedState{}}
}

func (f *MemoryFile) Open() (*MemoryFile, error) {
	switch f.state.(type) {
	case ClosedState:
		return &MemoryFile{name: f.name, state: OpenState{}}, nil
	case UndefinedState:
		return &MemoryFile{name: f.name, state: OpenState{}}, nil
	default:
		return &MemoryFile{name: f.name, state: UndefinedState{}}, errors.New("file already open")
	}
}

func (f *MemoryFile) Close() (*MemoryFile, error) {
	switch f.state.(type) {
	case OpenState:
		return &MemoryFile{name: f.name, state: ClosedState{}}, nil
	default:
		return &MemoryFile{name: f.name, state: UndefinedState{}}, errors.New("file already closed")
	}
}

func (f *MemoryFile) Read() (*string, error) {
	switch f.state.(type) {
	case OpenState:
		return &f.name, nil
	default:
		return nil, errors.New("file not ready to read from since it's either closed or undefined")
	}
}

Note that we check the current state of the file in both the Open() and Close() methods, to make sure the operation is possible. Also note that we return the original object in case of an error, but in an undefined state. Since we do not panic, the original object can not be dropped. It depends on the developer using the API to handle the errors.

Time to test

Now we can test our setup:

func main() {
	file := NewMemoryFile("test.txt")

	file, err := file.Open()
	if err != nil {
		fmt.Printf("Something went wrong opening the file: %v\n", err)
	} else {
		println("file opened")
	}

	content, err := file.Read()
	if err != nil {
		fmt.Printf("Something went wrong reading the file: %v\n", err)
	} else {
		println("file content: ", *content)
	}
	file, err = file.Close()
	if err != nil {
		fmt.Printf("Something went wrong closing the file: %v\n", err)
	} else {
		println("file closed")
	}

	content, err = file.Read()
	if err != nil {
		fmt.Printf("Something went wrong reading the file after closing: %v\n", err)
	} else {
		println("file content: ", *content)
	}

	file, err = file.Close()
	if err != nil {
		fmt.Printf("Something went wrong closing the file: %v\n", err)
	} else {
		println("file closed")
	}
}

Note that we have to check the error return value after each method call to make sure that everything went well. Also note that if something went wrong, i.e. you try to open and already opened file you will get back the object in its UndefinedState, that is why reading the second time goes wrong.

Conclusion

As you can see, preventing or allowing actions based on the state of an object is something that requires some planning, and some idea of state-transitions in our domain. In our example we’re only dealing with three states, but you can imagine more complex systems. However, the implementation of this pattern usually is done on small objects, embedded in larger systems, thereby hiding some complexity.

One problem is, is that errors need to be handled explicitly. That is why I introduced the UndefinedState to prevent certain operations on the object when it has just been created, or when it is in an errorstate. One enhancement could be the addition of an ErrorState struct, but that is the subject of another post.

Leave a Reply

Your email address will not be published. Required fields are marked *