
Posted on • Originally published atabhik.hashnode.dev
Modelling your Data Layer in Go
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.
Thedata
package
In our case, we will keep all the model-related data and data transfer objects (DTOs) inside thedata
package. This package will have 4 files as we will discuss below. This package is built on top of theconfig
andutils
packages which we discussed in the previous article.
User Profile Representation in Go
First, let’s have a look at theuser_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 anId
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. Thebson
tag denotes what the struct field will be called in the MongoDB notation while thejson
tag says what the field will be called when the struct is marshaled into JSON.
packagedataimport("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")typeUserstruct{Idprimitive.ObjectID`bson:"_id" json:"id"`Picstring`bson:"profilePic" json:"profilePic"`Namestring`bson:"name" json:"name"`Emailstring`bson:"email" json:"email"`Phonestring`bson:"phone" json:"phone"`}var(userCollection=config.Db.Collection("user-collection")ctx=config.MongoCtx)funcCreateNewUser(userCreateUserDTO)(*mongo.InsertOneResult,error){returnuserCollection.InsertOne(ctx,user)}funcGetAllPublicUserProfiles()([]PublicUserProfileDTO,error){varusers[]PublicUserProfileDTOcursor,err:=userCollection.Find(ctx,bson.M{})iferr!=nil{returnusers,err}iferr=cursor.All(ctx,&users);err!=nil{returnusers,nil}else{returnusers,err}}funcGetUserProfileById(idstring)(PublicFullUserProfileDTO,error){varuserPublicFullUserProfileDTOobId,err:=primitive.ObjectIDFromHex(id)iferr!=nil{returnuser,err}err=userCollection.FindOne(ctx,bson.M{"_id":obId}).Decode(&user)returnuser,err}funcUserLookupViaEmail(emailstring)(bool,string,error){varresultUserfilter:=bson.D{{Key:"email",Value:email}}projection:=bson.D{{Key:"_id",Value:1}}iferr:=userCollection.FindOne(ctx,filter,options.FindOne().SetProjection(projection)).Decode(&result);err!=nil{returnfalse,"",err}returntrue,result.Id.Hex(),nil}funcUserLookupViaPhone(phonestring)(bool,string,error){varresultUserfilter:=bson.D{{Key:"phone",Value:phone}}projection:=bson.D{{Key:"secret",Value:1},{Key:"counter",Value:1},{Key:"_id",Value:1}}iferr:=userCollection.FindOne(ctx,filter,options.FindOne().SetProjection(projection)).Decode(&result);err!=nil{returnfalse,"",err}returntrue,result.Id.Hex(),nil}funcUpdateUserProfile(idstring,uEditUserDTO)(*mongo.UpdateResult,error){ifobId,err:=primitive.ObjectIDFromHex(id);err!=nil{update:=bson.D{{Key:"$set",Value:u}}returnuserCollection.UpdateByID(ctx,obId,update)}else{returnnil,err}}funcDeleteUserProfile(idstring)(*mongo.DeleteResult,error){ifobId,err:=primitive.ObjectIDFromHex(id);err!=nil{returnuserCollection.DeleteOne(ctx,bson.M{"_id":obId})}else{returnnil,err}}
Next, between lines 20 and 23, we importDb
from theconfig
package. We then create a reference to theuser-collection
MongoDB collection which we had created during the initialization phase and store that in theuserCollection
variable. The mongo context is also imported and stored in thectx
variable.
The first method we have is theCreateNewUser()
function which takes in a user, inserts the data, and then returns the insertion result. It is a light wrapper around theInsertOne()
function. After that, we have theGetAllPublicUserProfiles()
function. This function queries all the users in theuser-collection
. However, it does not return all the fields of every collection. It returns only those fields that are present in thePublicUserProfileDTO
struct (struct shown below). This ensures that only a subset of fields is returned.
// Inside of user_dto.gotypePublicUserProfileDTOstruct{Idprimitive.ObjectID`bson:"_id" json:"id"`Picstring`bson:"profilePic" json:"profilePic"`Namestring`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 insideuser_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. TheGetUserProfileById()
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.gotypePublicFullUserProfileDTOstruct{Idprimitive.ObjectID`bson:"_id" json:"id"`Picstring`bson:"profilePic" json:"profilePic"`Namestring`bson:"name" json:"name"`Emailstring`bson:"email" json:"email"`Phonestring`bson:"phone" json:"phone"`}
TheUserLookupViaEmail()
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 theoptions
we pass to theFindOne()
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.
TheUserLookupViaPhone()
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 theuser_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 theauth_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 ourauth_model.go
file.
packagedataimport("time""github.com/abhik-99/passwordless-login/pkg/config")typeAuthstruct{UserIdstringOtpstring}var(redisDb=config.RdbrCtx=config.RedisCtx)func(a*Auth)SetOTPForUser()error{returnredisDb.Set(rCtx,a.UserId,a.Otp,30*time.Minute).Err()}func(a*Auth)CheckOTP()(bool,error){ifstoredOtp,err:=redisDb.Get(rCtx,a.UserId).Result();err!=nil{returnfalse,err}else{ifstoredOtp==a.Otp{returntrue,nil}}returnfalse,nil}
First, we define theAuth
struct between lines 9 and 12.UserId
field will be the key while theOtp
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 fromconfig
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 anAuth
struct in our project, we can call theSetOTPForUser()
function to set an auto-expiring OTP in Redis using the.Set()
function or we may invoke theCheckOTP()
function to find if OTP exists and matches the value inOtp
field of theAuth
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 theauth_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 theAccessTokenDTO
is used for encoding the JWT access token in the response on successful authentication.
packagedatatypeLoginWithEmailDTOstruct{Emailstring`json:"email" validate:"required,email"`Otpstring`json:"otp" validate:"required"`}typeLoginWithPhoneDTOstruct{Phonestring`json:"phone" validate:"required,e164"`Otpstring`json:"otp" validate:"required"`}typeAccessTokenDTOstruct{AccessTokenstring`json:"access_token"`}
This completes the modeling phase of the project. We now haveconfig
,utils
, anddata
packages ready. So, we can proceed to theroutes
andcontroller
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 shouldarrange their files and whichconventions to choose from.
This freedom comes with a downside that can lead to poorly constructed applications. Sometimes, it can even lead tooverengineering 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 thechaos 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!
Top comments(0)
For further actions, you may consider blocking this person and/orreporting abuse