Modelling your Data Layer in Go
Tags: webdev, go, redis, mongodb
Published: 2024-02-27 18:30:01

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 marshaled into JSON.

```go
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.

```go
// 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.

```go
// 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.

### Authentication-related modeling in Go
Next, we have the `auth_model.go`. In this section, we will focus on modeling 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.

```go
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. 

```go
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 modeling 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!