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:
- "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"
- 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:
- Type Safety:
- Using the empty
interface{}
which all types implement can lead to potential runtime errors if type assertions are incorrect
- Using the empty
- 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:
- 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.
- As seen from the
- 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.
- Error Handling:
- Did you see the unique errors defined within the passed in functions to
Update
in theUserService
? We can now offer more sophisticated error handling that can be based on the current state of the data!
- Did you see the unique errors defined within the passed in functions to
- 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. :)