In this example we are going to work with "tags" and "posts" in "blog" domain. You can normally store tags in the post document as an array field. However, this would quickly lead to repetition of the tag data. In order to avoid this, you can store tag references inside the post document as an array field. Given the number of tags per post is small with limited growth, storing the tag reference inside the post document is feasible. It is a classic 1-n relationship. See example below.


Database content


Preparation


use blog

db.createCollection("tags")
db.tags.createIndex({"uuid":1},{unique:true,name:"UQ_uuid"})
db.tags.createIndex({"name":1},{unique:true,name:"UQ_name"})

db.createCollection("posts")
db.posts.createIndex({"uuid":1},{unique:true,name:"UQ_uuid"})

Tags data


[
{
"_id": {
"$oid": "604f463ed42b77e01e7f03db"
},
"uuid": "07131782-0090-40cf-b530-68e774669826",
"name": "tech",
"description": "Technology related stuff"
},
{
"_id": {
"$oid": "604f463ed42b77e01e7f03dc"
},
"uuid": "a1e57bc6-de13-432d-ad22-7a3f5ba07b80",
"name": "sport",
"description": "Any kind of sport"
},
{
"_id": {
"$oid": "604f463ed42b77e01e7f03dd"
},
"uuid": "4f489d03-e4c9-444a-ba09-076d0584c96e",
"name": "travel",
"description": "Travel or holiday related stuff"
}
]

Posts data


[
{
"_id": {
"$oid": "604f4831601401a8de9dfe1a"
},
"uuid": "acda18b2-f5c9-4736-8201-d1dc68c59354",
"subject": "Tech in football",
"text": "VAR ruined football so far!",
"created_at": {
"$date": "2021-03-15T11:42:41.171Z"
},
"tags": [
"07131782-0090-40cf-b530-68e774669826",
"a1e57bc6-de13-432d-ad22-7a3f5ba07b80"
]
},
{
"_id": {
"$oid": "604f4891601401a8de9dfe1b"
},
"uuid": "368f4fba-b1d1-4c58-9682-74ceedd276e9",
"subject": "Covid on holiday",
"text": "Do not take your Covid on holiday with you :)",
"created_at": {
"$date": "2021-03-15T11:44:17.613Z"
},
"tags": [
"4f489d03-e4c9-444a-ba09-076d0584c96e"
]
},
{
"_id": {
"$oid": "604f494e601401a8de9dfe1c"
},
"uuid": "ba87a063-7379-4b13-b7a5-d986f2bed43c",
"subject": "Pasta or not to pasta?",
"text": "Let's talk about what pasta is easiest to make.",
"created_at": {
"$date": "2021-03-15T11:47:26.607Z"
},
"tags": null
}
]

Storage


Models


package storage

import (
"context"
"time"
)

type PostStorer interface {
Insert(ctx context.Context, post PostWrite) error
Find(ctx context.Context, uuid string) (PostRead, error)
}

type PostWrite struct {
UUID string `bson:"uuid"`
Subject string `bson:"subject"`
Text string `bson:"text"`
CreatedAt time.Time `bson:"created_at"`
TagUUIDs []string `bson:"tags"`
}

type PostRead struct {
ID string `bson:"_id"`
UUID string `bson:"uuid"`
Subject string `bson:"subject"`
Text string `bson:"text"`
CreatedAt time.Time `bson:"created_at"`
Tags []Tag `bson:"tags"`
}

type Tag struct {
ID string `bson:"_id"`
UUID string `bson:"uuid"`
Name string `bson:"name"`
Description string `bson:"description"`
}

Storer


package mongodb

import (
"context"
"log"
"time"

"github.com/you/mongo/internal/pkg/domain"
"github.com/you/mongo/internal/pkg/storage"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo"
)

var _ storage.PostStorer = PostStorage{}

type PostStorage struct {
Database *mongo.Database
Timeout time.Duration
}

func (p PostStorage) Insert(ctx context.Context, post storage.PostWrite) error {
ctx, cancel := context.WithTimeout(ctx, p.Timeout)
defer cancel()

if _, err := p.Database.Collection("posts").InsertOne(ctx, post); err != nil {
log.Println(err)

if er, ok := err.(mongo.WriteException); ok && er.WriteErrors[0].Code == 11000 {
return domain.ErrConflict
}

return domain.ErrInternal
}

return nil
}

func (p PostStorage) Find(ctx context.Context, uuid string) (storage.PostRead, error) {
ctx, cancel := context.WithTimeout(ctx, p.Timeout)
defer cancel()

// Tags are not sorted here!
// qry := []bson.M{
// {
// "$match": bson.M{
// "uuid": uuid,
// },
// },
// {
// "$lookup": bson.M{
// "from": "tags", // Child collection to join
// "localField": "tags", // Parent collection reference holding field
// "foreignField": "uuid", // Child collection reference field
// "as": "tags", // Arbitrary field name to store result set
// },
// },
// }

// Tags are asc sorted here!
qry := []bson.M{
{
"$match": bson.M{
"uuid": uuid,
},
},
{
"$lookup": bson.M{
// Define the tags collection for the join.
"from": "tags",
// Specify the variable to use in the pipeline stage.
"let": bson.M{
"tags": "$tags",
},
"pipeline": []bson.M{
// Select only the relevant tags from the tags collection.
// Otherwise all the tags are selected.
{
"$match": bson.M{
"$expr": bson.M{
"$in": []interface{}{
"$uuid",
"$$tags",
},
},
},
},
// Sort tags by their name field in asc. -1 = desc
{
"$sort": bson.M{
"name": 1,
},
},
},
// Use tags as the field name to match struct field.
"as": "tags",
},
},
}

cur, err := p.Database.Collection("posts").Aggregate(ctx, qry)
if err != nil {
log.Println(err)

return storage.PostRead{}, domain.ErrInternal
}

var pos []storage.PostRead

if err := cur.All(context.Background(), &pos); err != nil {
log.Println(err)

return storage.PostRead{}, domain.ErrInternal
}
defer cur.Close(context.Background())

if err := cur.Err(); err != nil {
log.Println(err)

return storage.PostRead{}, domain.ErrInternal
}

if len(pos) == 0 {
return storage.PostRead{}, domain.ErrNotFound
}

return pos[0], nil
}

HTTP Router


Models


package post

import "time"

// Request

type Create struct {
Subject string `json:"subject"`
Text string `json:"text"`
Tags []string `json:"tags"`
}

// Response

type Response struct {
ID string `json:"id"`
UUID string `json:"uuid"`
Subject string `json:"subject"`
Text string `json:"text"`
CreatedAt time.Time `json:"created_at"`
Tags []Tag `json:"tags"`
}

type Tag struct {
ID string `json:"id"`
UUID string `json:"uuid"`
Name string `json:"name"`
Description string `json:"description"`
}

Controller


As you can see this file is doing a lot and there isn't really a proper request validation. You should refactor it as per your needs.


package post

import (
"encoding/json"
"fmt"
"net/http"
"time"

"github.com/you/mongo/internal/pkg/domain"
"github.com/you/mongo/internal/pkg/storage"
"github.com/google/uuid"
"github.com/julienschmidt/httprouter"
)

type Controller struct {
Storage storage.PostStorer
}

// POST /api/v1/posts
func (c Controller) Create(w http.ResponseWriter, r *http.Request) {
var req Create
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}

id := uuid.New().String()

err := c.Storage.Insert(r.Context(), storage.PostWrite{
UUID: id,
Subject: fmt.Sprintf("%s - %d", req.Subject, time.Now().UTC().Nanosecond()),
Text: fmt.Sprintf("%s - %d", req.Text, time.Now().UTC().Nanosecond()),
CreatedAt: time.Now(),
TagUUIDs: req.Tags,
})
if err != nil {
switch err {
case domain.ErrConflict:
w.WriteHeader(http.StatusConflict)
default:
w.WriteHeader(http.StatusInternalServerError)
}
return
}

w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(id))
}

// GET /api/v1/posts/:uuid
func (c Controller) Find(w http.ResponseWriter, r *http.Request) {
id := httprouter.ParamsFromContext(r.Context()).ByName("uuid")

com, err := c.Storage.Find(r.Context(), id)
if err != nil {
switch err {
case domain.ErrNotFound:
w.WriteHeader(http.StatusNotFound)
default:
w.WriteHeader(http.StatusInternalServerError)
}
return
}

res := Response{
ID: com.ID,
UUID: com.UUID,
Subject: com.Subject,
Text: com.Text,
CreatedAt: com.CreatedAt,
Tags: make([]Tag, len(com.Tags)),
}
for i, tag := range com.Tags {
res.Tags[i].ID = tag.ID
res.Tags[i].UUID = tag.UUID
res.Tags[i].Name = tag.Name
res.Tags[i].Description = tag.Description
}

body, err := json.Marshal(res)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}

w.Header().Set("Content-Type", "application/json; charset=utf-8")
_, _ = w.Write(body)
}

Test


Find


curl --request GET 'http://localhost:3000/api/v1/posts/acda18b2-f5c9-4736-8201-d1dc68c59354'

{
"id": "604f4831601401a8de9dfe1a",
"uuid": "acda18b2-f5c9-4736-8201-d1dc68c59354",
"subject": "Tech in football",
"text": "VAR ruined football so far!",
"created_at": "2021-03-15T11:42:41.171Z",
"tags": [
{
"ID": "604f463ed42b77e01e7f03dc",
"uuid": "a1e57bc6-de13-432d-ad22-7a3f5ba07b80",
"name": "sport",
"description": "Any kind of sport"
},
{
"ID": "604f463ed42b77e01e7f03db",
"uuid": "07131782-0090-40cf-b530-68e774669826",
"name": "tech",
"description": "Technology related stuff"
}
]
}

Create


curl --request POST 'http://localhost:3000/api/v1/posts' \
--header 'Content-Type: application/json' \
--data-raw '{
"subject": "Subject 1",
"text": "Text 1",
"tags": [
"07131782-0090-40cf-b530-68e774669826"
]
}'

49329796-9f04-44d1-99fb-49128bba7a9c