Modelling your Data Layer in Go

Modelling your Data Layer in Go

The "Unopinionated" Trap of Golang

Featured on Hashnode

There are a few statements any Go dev would have heard like:

  1. There is no perfect programming language.

  2. Go is modular by design.

  3. In Golang, pass-by-value is faster than the pass-by-reference.

  4. Go is the perfect programming language

And so on…

In this article, we pick up the modelling aspect of our Go application. While we are trying to follow the MVC architecture, there are some Gopher quirks that we have observed previously. This article will show how those translate to coding when dealing with data models and DB business logic wrappers.

The data package

In our case, we will keep all the model-related data and data transfer objects (DTOs) inside the data package. This package will have 4 files as we will discuss below. This package is built on top of the config and utils packages which we discussed in the previous article.

User Profile Representation in Go

First, let’s have a look at the user_model.go file. This file contains the user model which we will store in our MongoDB collection. Between lines 12 and 18, we define the fields of our user profile. Our user will have an Id field which will be auto-generated by MongoDB. Unlike JS, JSON objects are not natively compatible with Go. Neither is MongoDB’s BSON. To facilitate interoperability, we need to tag the fields of our struct. The bson tag denotes what the struct field will be called in the MongoDB notation while the json tag says what the field will be called when the struct is marshalled into JSON.

package data

import (
    "github.com/abhik-99/passwordless-login/pkg/config"

    "go.mongodb.org/mongo-driver/bson"
    "go.mongodb.org/mongo-driver/bson/primitive"
    "go.mongodb.org/mongo-driver/mongo"
    "go.mongodb.org/mongo-driver/mongo/options"
)

type User struct {
    Id    primitive.ObjectID `bson:"_id" json:"id"`
    Pic   string             `bson:"profilePic" json:"profilePic"`
    Name  string             `bson:"name" json:"name"`
    Email string             `bson:"email" json:"email"`
    Phone string             `bson:"phone" json:"phone"`
}

var (
    userCollection = config.Db.Collection("user-collection")
    ctx            = config.MongoCtx
)

func CreateNewUser(user CreateUserDTO) (*mongo.InsertOneResult, error) {
    return userCollection.InsertOne(ctx, user)
}

func GetAllPublicUserProfiles() ([]PublicUserProfileDTO, error) {
    var users []PublicUserProfileDTO
    cursor, err := userCollection.Find(ctx, bson.M{})
    if err != nil {
        return users, err
    }

    if err = cursor.All(ctx, &users); err != nil {
        return users, nil
    } else {
        return users, err
    }
}

func GetUserProfileById(id string) (PublicFullUserProfileDTO, error) {
    var user PublicFullUserProfileDTO
    obId, err := primitive.ObjectIDFromHex(id)
    if err != nil {
        return user, err
    }

    err = userCollection.FindOne(ctx, bson.M{"_id": obId}).Decode(&user)
    return user, err
}

func UserLookupViaEmail(email string) (bool, string, error) {
    var result User
    filter := bson.D{{Key: "email", Value: email}}
    projection := bson.D{{Key: "_id", Value: 1}}

    if err := userCollection.FindOne(ctx, filter, options.FindOne().SetProjection(projection)).Decode(&result); err != nil {
        return false, "", err
    }
    return true, result.Id.Hex(), nil

}

func UserLookupViaPhone(phone string) (bool, string, error) {
    var result User
    filter := bson.D{{Key: "phone", Value: phone}}
    projection := bson.D{{Key: "secret", Value: 1}, {Key: "counter", Value: 1}, {Key: "_id", Value: 1}}

    if err := userCollection.FindOne(ctx, filter, options.FindOne().SetProjection(projection)).Decode(&result); err != nil {
        return false, "", err
    }
    return true, result.Id.Hex(), nil
}

func UpdateUserProfile(id string, u EditUserDTO) (*mongo.UpdateResult, error) {
    if obId, err := primitive.ObjectIDFromHex(id); err != nil {
        update := bson.D{{Key: "$set", Value: u}}
        return userCollection.UpdateByID(ctx, obId, update)
    } else {
        return nil, err
    }
}

func DeleteUserProfile(id string) (*mongo.DeleteResult, error) {
    if obId, err := primitive.ObjectIDFromHex(id); err != nil {
        return userCollection.DeleteOne(ctx, bson.M{"_id": obId})
    } else {
        return nil, err
    }
}

Next, between lines 20 and 23, we import Db from the config package. We then create a reference to the user-collection MongoDB collection which we had created during the initialization phase and store that in the userCollection variable. The Mongo context is also imported and stored in the ctx variable.

The first method we have is the CreateNewUser() function which takes in a user, inserts the data, and then returns the insertion result. It is a light wrapper around the InsertOne() function. After that, we have the GetAllPublicUserProfiles() function. This function queries all the users in the user-collection. However, it does not return all the fields of every collection. It returns only those fields that are present in the PublicUserProfileDTO struct (struct shown below). This ensures that only a subset of fields is returned.

// Inside of user_dto.go
type PublicUserProfileDTO struct {
    Id   primitive.ObjectID `bson:"_id" json:"id"`
    Pic  string             `bson:"profilePic" json:"profilePic"`
    Name string             `bson:"name" json:"name"`
}

Keep in mind that we are going to keep all the concerned DTOs in one file. This means that all DTOs concerned with the Mongo User collection are going inside user_dto.go file including the struct above. This limits the fields returned in the response.

But that is not the only way. You can just tell which fields to filter by and then which ones to return in the query itself. The GetUserProfileById() is not that special – it just takes in an ID and then returns the full user profile which matches the ID. The DTO for the Full user profile is shown below.

// Inside user_dto.go
type PublicFullUserProfileDTO struct {
    Id    primitive.ObjectID `bson:"_id" json:"id"`
    Pic   string             `bson:"profilePic" json:"profilePic"`
    Name  string             `bson:"name" json:"name"`
    Email string             `bson:"email" json:"email"`
    Phone string             `bson:"phone" json:"phone"`
}

The UserLookupViaEmail() is a bit special though. You can probably guess where it can be used. It takes in an email ID and then returns the ID of the user to whom that Email ID belongs. Pay close attention to line number 57. That is where we define which fields the query should return. This is another way to limit the number of fields returned in the response. We specify this projection in the options we pass to the FindOne() query.

Keep in mind that the ID which will be returned will not be a string. Invoking the Hex() function on the returned MongoDB ID will turn it into a string which we can return.

The UserLookupViaPhone() does something similar albeit with the phone number of a user. Since it is similar, we won’t be discussing it.

As for the other functions in the user_model.go file, they are just present for completion’s sake and provide a full range of CRUD functionality. We will forego discussing them since they are fairly easy to understand if you have understood the function we discussed thus far.

Next, we have the auth_model.go. In this section, we will focus on modelling the data layer so that we can interact with the Redis instance and save & check OTPs. Unlike MongoDB, we won’t have a struct decorator here since Redis is a key-value DB. That being said, there needs to be some structure to the model so that we can use it in our project with ease. The code given below is what we will have in our auth_model.go file.

package data

import (
    "time"

    "github.com/abhik-99/passwordless-login/pkg/config"
)

type Auth struct {
    UserId string
    Otp    string
}

var (
    redisDb = config.Rdb
    rCtx    = config.RedisCtx
)

func (a *Auth) SetOTPForUser() error {
    return redisDb.Set(rCtx, a.UserId, a.Otp, 30*time.Minute).Err()
}

func (a *Auth) CheckOTP() (bool, error) {
    if storedOtp, err := redisDb.Get(rCtx, a.UserId).Result(); err != nil {
        return false, err
    } else {
        if storedOtp == a.Otp {
            return true, nil
        }
    }
    return false, nil
}

First, we define the Auth struct between lines 9 and 12. UserId field will be the key while the Otp will represent the corresponding value. As you might have guessed, we will map Mongo DB Object IDs to OTPs and store that in Redis. In lines 14 to 17 we import the Redis connection and context from config package which we defined in the previous article.

We follow a different pattern in this kind of modelling. Unlike the previous user model, we define the methods with the struct as a receiver. So, when we initialize an Auth struct in our project, we can call the SetOTPForUser() function to set an auto-expiring OTP in Redis using the .Set() function or we may invoke the CheckOTP() function to find if OTP exists and matches the value in Otp field of the Auth struct we initialized. In the latter case, if OTP does not exist, then we can also assume that 30 minutes have passed and so the OTP has expired.

Next, we have the DTOs as shown in the code below. We will place them in the auth_dto.go file. As can be seen below, we have 3 DTO structs defined. LoginWithEmailDTO is used to decode the POST request body when email and OTP are sent. LoginWithPhoneDTO has similar usage but with a phone number. While the AccessTokenDTO is used for encoding the JWT access token in the response on successful authentication.

package data

type LoginWithEmailDTO struct {
    Email string `json:"email" validate:"required,email"`
    Otp   string `json:"otp" validate:"required"`
}

type LoginWithPhoneDTO struct {
    Phone string `json:"phone" validate:"required,e164"`
    Otp   string `json:"otp" validate:"required"`
}

type AccessTokenDTO struct {
    AccessToken string `json:"access_token"`
}

This completes the modelling phase of the project. We now have config, utils, and data packages ready. So, we can proceed to the routes and controller packages where the crux of our business logic will be implemented. We will explore this in the next articles.

Conclusion

As is apparent from this article, Golang is pretty unopinionated when it comes to how one should arrange their files and which conventions to choose from.

This freedom comes with a downside that can lead to poorly constructed applications. Sometimes, it can even lead to overengineering which can just needlessly increase codebase complexity. The quality in such a case, largely depends on the developer’s coding practices and the conventions being followed by a project. These help reduce the chaos in the codebase.

In this article, we went over the data modelling aspect of our mini-project. Hope to see you in the next one. Until then, keep building awesome things and WAGMI!

Did you find this article valuable?

Support Bored on the Edge by becoming a sponsor. Any amount is appreciated!