Coupling Implicit Interfaces with Generics in Golang

I came across an interesting blog from Riot Games titled Leveraging Golang for Game Development and Operations.

Although this article came out roughly 4 years ago, the practical lessons and use cases were still insightful and applicable to today. One of the sections detailed implicit interfaces which Riot Games used extensively.

They defined an interface like so:

type Datastore interface {
	Fetch(ctx context.Context, key string) (interface{}, error)
	Update(ctx context.Context, key string, f func(current interface{}) (interface{}, error)) (interface{}, error)
}

They then elaborated on its benefits:

  1. "To easily test code and use as a tool to create modular code by defining an interface that every one of their services use in order to interact with a data source"
  2. The ability to implement many different backends in order to accomplish different tasks:
    • "An in-memory implementation for most of our tests and the small interface makes it very lightweight to implement inline in a test file for unique cases like access counts or to test our error handling"
    • "A mixture of SQL and Redis for our services and have an implementation for both using this interface"

I believe this is a very clever and versatile way of handling robust, safe, and flexible data manipulation operations.

I now aim to modify this interface to support generics, provide an example implementation, and give additional insight to the code pattern.


An Implicit Generic Interface

First off, what is an implicit interface?

In go, a type simply implements the interface's methods. There isn't any explicit declaration of intent and no "implements" keyword which decouples the definition of an interface from its implementation.

So in other words, from the Datastore interface above, any type that implements the Fetch and Update functions will implement this interface. We can have a simple in memory implementation used for testing, an implementation that read / writes to a SQL compliant datastore, a Redis instance, etc.

The one main drawback with this versatiliy is with the use of the empty interface{}. Lets dive into that a bit more:

  1. Type Safety:
    • Using the empty interface{} which all types implement can lead to potential runtime errors if type assertions are incorrect
  2. Readability:
    • There isn't much that we as programmers can gather as it does not convey what type of data it is expected to handle

But generics can help with this!

Code Walkthrough

Lets create a simple program that demonstrates the usage of the custom Datastore interface with generics and a UserService that interacts with it to manage user information.

Datastore Interface Implementation

We can now modify the interface like so:

// Datastore is an interface for fetching and updating data of a generic type T.
type Datastore[T any] interface {
	Fetch(ctx context.Context, key string) (T, error)
	Update(ctx context.Context, key string, f func(curr T) (T, error)) (T, error)
}

In doing so, we can pass in the type and provide compile-time type safety!

Now lets define an in memory implementation that implements this generic interface:

// StringStore is a simple in-memory implementation of the Datastore interface for string values.
type StringStore struct {
	data map[string]string
	mu   sync.RWMutex
}

// NewStringStore initializes and returns a new instance of StringStore.
func NewStringStore() *StringStore {
	return &StringStore{
		data: make(map[string]string),
	}
}

// Fetch retrieves a string value by key.
func (s *StringStore) Fetch(ctx context.Context, key string) (string, error) {
	s.mu.RLock()
	defer s.mu.RUnlock()

	if value, ok := s.data[key]; ok {
		return value, nil
	}
	return "", errors.New("key not found")
}

// Update modifies a string value by key, using the provided function.
func (s *StringStore) Update(ctx context.Context, key string, f func(curr string) (string, error)) (string, error) {
	s.mu.Lock()
	defer s.mu.Unlock()

	currentValue, ok := s.data[key]
	// If key does not exist, initialize currentValue to an empty string
	if !ok {
		currentValue = ""
	}

	newValue, err := f(currentValue)
	if err != nil {
		return "", err
	}

	s.data[key] = newValue
	return newValue, nil
}

In the example above, we implemented an in memory key value store that holds a key and value of type string. We add a mutex lock for thread safety and include the implementations of Fetch and Update.

UserService

We can now implement a UserService that passes its own logic on what to Update as a function:

// UserService utilizes a Datastore to manage user information.
type UserService struct {
	store Datastore[string]
}

// NewUserService creates a new instance of UserService with the given Datastore.
func NewUserService(store Datastore[string]) *UserService {
	return &UserService{
		store: store,
	}
}

// AddUser adds a new user with the given ID and name to the store.
func (u *UserService) AddUser(ctx context.Context, userID, name string) error {
	_, err := u.store.Update(ctx, userID, func(curr string) (string, error) {
		if curr != "" {
			return "", fmt.Errorf("user %s already exists", userID)
		}
		return name, nil
	})
	return err
}

// GetUserName retrieves a user's name by their ID.
func (u *UserService) GetUserName(ctx context.Context, userID string) (string, error) {
	return u.store.Fetch(ctx, userID)
}

// UpdateUserName updates a user's name by their ID.
func (u *UserService) UpdateUserName(ctx context.Context, userID, newName string) error {
	_, err := u.store.Update(ctx, userID, func(curr string) (string, error) {
		if curr == "" {
			return "", fmt.Errorf("user %s does not exist", userID)
		}
		return newName, nil
	})
	return err
}

In the example above, UserService holds a store of type DataStore with 3 methods defined: AddUser, GetUserName, and UpdateUserName.

We can now call Fetch and Update from the store and have custom logic for Update based on the current state of the store.

To run:

func main() {
	// Initialize the StringStore and UserService
	store := NewStringStore()
	userService := NewUserService(store)

	ctx := context.Background()

	// Add a new user
	err := userService.AddUser(ctx, "user1", "John Doe")
	if err != nil {
		fmt.Println("Error adding user:", err)
	}

	// Retrieve the user's name
	name, err := userService.GetUserName(ctx, "user1")
	if err != nil {
		fmt.Println("Error fetching user name:", err)
	} else {
		fmt.Println("User name:", name)
	}

	// Update the user's name
	err = userService.UpdateUserName(ctx, "user1", "Jane Doe")
	if err != nil {
		fmt.Println("Error updating user name:", err)
	}

	// Verify the update
	name, err = userService.GetUserName(ctx, "user1")
	if err != nil {
		fmt.Println("Error fetching user name:", err)
	} else {
		fmt.Println("Updated user name:", name)
	}
}
❯ go run main.go
User name: John Doe
Updated user name: Jane Doe

Passing a function as a parameter to Update also offers some benefits. Here's how:

  1. Conditional Updates:
    • As seen from the UserService above, we can update conditionally based on the current value of the data. If the user was empty, we can't update for example, and if the user was not empty, we can't add a new user.
  2. Flexibility:
    • The passed in function executed during an update can include a wide variety of operations including logging, validation, or even triggering an entire chain of other operations based on the current state of the data.
  3. Error Handling:
    • Did you see the unique errors defined within the passed in functions to Update in the UserService? We can now offer more sophisticated error handling that can be based on the current state of the data!
  4. Atomicity and Consistency:
    • By providing a function, we can ensure that the read-modify-write cycle happens as a single operation which is crucial for maintaining data consistency. For simplicty from the example above, we lock and unlock after the outer scope function completes but this can easily be modified to lock and unlock only when accessing data to not block for long operations (ie the function call)

I hope this provides a complete rundown on coupling generics with implicit interfaces!

It can be a very effective coding pattern for offering flexibility and type safety. :)