Modelling your Data Layer in Go
The "Unopinionated" Trap of Golang
There are a few statements any Go dev would have heard like:
There is no perfect programming language.
Go is modular by design.
In Golang, pass-by-value is faster than the pass-by-reference.
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 theHex()
function on the returned MongoDB ID will turn it into astring
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.
Authentication-related modeling in Go
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!