Storing data

We've seen how Go kit and our own code take an HTTP request with a JSON payload, translate it into a Go struct, invoke the service implementation, and encode the response as a JSON to return to the caller. Now, let's take a deeper look at the persistent storage of the data. The social graph manager is responsible for maintaining the followed/follower relationships between users. There are many options for storing such data, including relational databases, key-value stores, and, of course, graph databases, which may be the most natural. I chose to use a relational database at this stage because it is familiar, reliable, and can support the following necessary operations well:

  • Follow
  • Unfollow
  • Get followers
  • Get following

However, if we later discover that we prefer a different data store or extend the relational DB with some caching mechanism, it will be very easy to do so because the data store of the social graph manager is hidden behind an interface. It is actually using the very same interface, that is, SocialGraphManager. As you may remember, the social graph manager package accepts a store argument of the SocialGraphManager type in its factory function:

func NewSocialGraphManager(store om.SocialGraphManager) (om.SocialGraphManager, error) {
if store == nil {
return nil, errors.New("store can't be nil")
}
return &SocialGraphManager{store: store}, nil
}

Since the social graph manager interacts with its data store through this interface, changing implementations can be done without any code changes to the social graph manager itself.
I will take advantage of this for unit testing, where I use an in-memory data store that is easy to set up, can be quickly populated with test data, and allows me to run tests locally.

Let's look at the in-memory social graph data store, which can be found at  https://github.com/the-gigi/delinkcious/blob/master/pkg/social_graph_manager/in_memory_social_graph_store.go.

It has very few dependencies – just the SocialGraphManager interface and the standard errors package. It defines a SocialUser struct, which contains a username and the names of the users it is following, as well as the names of the users that they are followed by:

package social_graph_manager

import (
"errors"
om "github.com/the-gigi/delinkcious/pkg/object_model"
)

type Followers map[string]bool
type Following map[string]bool

type SocialUser struct {
Username string
Followers Followers
Following Following
}

func NewSocialUser(username string) (user *SocialUser, err error) {
if username == "" {
err = errors.New("user name can't be empty")
return
}

user = &SocialUser{Username: username, Followers: Followers{}, Following: Following{}}
return
}

The data store itself is a struct called InMemorySocialGraphStore that contains a map between usernames and the corresponding SocialUser struct:

type SocialGraph map[string]*SocialUser

type InMemorySocialGraphStore struct {
socialGraph SocialGraph
}

func NewInMemorySocialGraphStore() om.SocialGraphManager {
return &InMemorySocialGraphStore{
socialGraph: SocialGraph{},
}
}

This is all pretty pedestrian. The InMemorySocialGraphStore struct implements the SocialGraphManager interface methods. For example, here is the Follow() method:

func (m *InMemorySocialGraphStore) Follow(followed string, follower string) (err error) {
followedUser := m.socialGraph[followed]
if followedUser == nil {
followedUser, _ = NewSocialUser(followed)
m.socialGraph[followed] = followedUser
}

if followedUser.Followers[follower] {
return errors.New("already following")
}

followedUser.Followers[follower] = true

followerUser := m.socialGraph[follower]
if followerUser == nil {
followerUser, _ = NewSocialUser(follower)
m.socialGraph[follower] = followerUser
}

followerUser.Following[followed] = true

return

At this point, there is no need to focus on how it works too much. The main point I want to get across is that by using interfaces as abstractions, you can get a lot of flexibility and clean separation of concerns that helps a lot when you want to develop specific parts of the system or a service during testing. If you want to make significant changes, such as changing your underlying data stores or using multiple data stores interchangeably, then having an interface in place is a life saver.