Backend Development 28 min read

Building a Food Recommendation API with Domain‑Driven Design in Go

This tutorial walks through a complete Golang implementation of a food‑recommendation API using Domain‑Driven Design (DDD), covering the four DDD layers, entity and repository definitions, persistence setup with Gorm, application services, HTTP interfaces, middleware, and how to run the service.

360 Tech Engineering
360 Tech Engineering
360 Tech Engineering
Building a Food Recommendation API with Domain‑Driven Design in Go

Today we share a Golang implementation based on Domain‑Driven Design (DDD), a popular software development approach that connects implementation with evolving domain models to simplify complexity.

The article does not dive deep into DDD theory but presents the author’s practical interpretation applied to Go.

What is DDD?

DDD is a method that provides principles and patterns for solving complex problems, structures design around a domain model, and encourages creative collaboration between technical and domain experts to iteratively refine the conceptual model.

Reasons to consider DDD:

Provides principles and patterns for solving difficult problems.

Structures complex design around a domain model.

Fosters creative collaboration between technical and domain experts to iteratively refine the model.

DDD consists of four layers:

Domain : defines the business logic and core entities.

Infrastructure : contains external concerns such as libraries and databases.

Application : acts as a conduit between the domain and interface layers, handling use‑case orchestration.

Interface : deals with external interactions like web services, RPC, or batch jobs.

We will build a food‑recommendation API.

First, initialize dependency management with go.mod :

go mod init food-app

The .env file holds database connection details (Postgres and Redis) and should reside in the project root.

#Postgres
APP_ENV=local
API_PORT=8888
DB_HOST=127.0.0.1
DB_DRIVER=postgres
ACCESS_SECRET=98hbun98h
REFRESH_SECRET=786dfdbjhsb
DB_USER=steven
DB_PASSWORD=password
DB_NAME=food-app
DB_PORT=5432

#Redis
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
REDIS_PASSWORD=

Domain Layer – Entities

package entity

import (
    "food-app/infrastructure/security"
    "github.com/badoux/checkmail"
    "html"
    "strings"
    "time"
)

type User struct {
    ID        uint64     `gorm:"primary_key;auto_increment" json:"id"`
    FirstName string     `gorm:"size:100;not null;" json:"first_name"`
    LastName  string     `gorm:"size:100;not null;" json:"last_name"`
    Email     string     `gorm:"size:100;not null;unique" json:"email"`
    Password  string     `gorm:"size:100;not null;" json:"password"`
    CreatedAt time.Time  `gorm:"default:CURRENT_TIMESTAMP" json:"created_at"`
    UpdatedAt time.Time  `gorm:"default:CURRENT_TIMESTAMP" json:"updated_at"`
    DeletedAt *time.Time `json:"deleted_at,omitempty"`
}

type PublicUser struct {
    ID        uint64 `gorm:"primary_key;auto_increment" json:"id"`
    FirstName string `gorm:"size:100;not null;" json:"first_name"`
    LastName  string `gorm:"size:100;not null;" json:"last_name"`
}

// BeforeSave is a gorm hook
func (u *User) BeforeSave() error {
    hashPassword, err := security.Hash(u.Password)
    if err != nil {
        return err
    }
    u.Password = string(hashPassword)
    return nil
}

type Users []User

// Convert to public representation
func (users Users) PublicUsers() []interface{} {
    result := make([]interface{}, len(users))
    for index, user := range users {
        result[index] = user.PublicUser()
    }
    return result
}

func (u *User) PublicUser() interface{} {
    return &PublicUser{ID: u.ID, FirstName: u.FirstName, LastName: u.LastName}
}

func (u *User) Prepare() {
    u.FirstName = html.EscapeString(strings.TrimSpace(u.FirstName))
    u.LastName = html.EscapeString(strings.TrimSpace(u.LastName))
    u.Email = html.EscapeString(strings.TrimSpace(u.Email))
    u.CreatedAt = time.Now()
    u.UpdatedAt = time.Now()
}

func (u *User) Validate(action string) map[string]string {
    var errorMessages = make(map[string]string)
    var err error
    switch strings.ToLower(action) {
    case "update":
        if u.Email == "" {
            errorMessages["email_required"] = "email required"
        }
        if u.Email != "" {
            if err = checkmail.ValidateFormat(u.Email); err != nil {
                errorMessages["invalid_email"] = "email email"
            }
        }
    case "login":
        if u.Password == "" {
            errorMessages["password_required"] = "password is required"
        }
        if u.Email == "" {
            errorMessages["email_required"] = "email is required"
        }
        if u.Email != "" {
            if err = checkmail.ValidateFormat(u.Email); err != nil {
                errorMessages["invalid_email"] = "please provide a valid email"
            }
        }
    case "forgotpassword":
        if u.Email == "" {
            errorMessages["email_required"] = "email required"
        }
        if u.Email != "" {
            if err = checkmail.ValidateFormat(u.Email); err != nil {
                errorMessages["invalid_email"] = "please provide a valid email"
            }
        }
    default:
        if u.FirstName == "" {
            errorMessages["firstname_required"] = "first name is required"
        }
        if u.LastName == "" {
            errorMessages["lastname_required"] = "last name is required"
        }
        if u.Password == "" {
            errorMessages["password_required"] = "password is required"
        }
        if u.Password != "" && len(u.Password) < 6 {
            errorMessages["invalid_password"] = "password should be at least 6 characters"
        }
        if u.Email == "" {
            errorMessages["email_required"] = "email is required"
        }
        if u.Email != "" {
            if err = checkmail.ValidateFormat(u.Email); err != nil {
                errorMessages["invalid_email"] = "please provide a valid email"
            }
        }
    }
    return errorMessages
}

Domain Layer – Repository Interface

package repository

import (
    "food-app/domain/entity"
)

type UserRepository interface {
    SaveUser(*entity.User) (*entity.User, map[string]string)
    GetUser(uint64) (*entity.User, error)
    GetUsers() ([]entity.User, error)
    GetUserByEmailAndPassword(*entity.User) (*entity.User, map[string]string)
}

Infrastructure Layer – Persistence Implementation

package persistence

import (
    "errors"
    "food-app/domain/entity"
    "food-app/domain/repository"
    "food-app/infrastructure/security"
    "github.com/jinzhu/gorm"
    "golang.org/x/crypto/bcrypt"
    "strings"
)

type UserRepo struct {
    db *gorm.DB
}

func NewUserRepository(db *gorm.DB) *UserRepo {
    return &UserRepo{db}
}

var _ repository.UserRepository = &UserRepo{}

func (r *UserRepo) SaveUser(user *entity.User) (*entity.User, map[string]string) {
    dbErr := map[string]string{}
    err := r.db.Debug().Create(&user).Error
    if err != nil {
        if strings.Contains(err.Error(), "duplicate") || strings.Contains(err.Error(), "Duplicate") {
            dbErr["email_taken"] = "email already taken"
            return nil, dbErr
        }
        dbErr["db_error"] = "database error"
        return nil, dbErr
    }
    return user, nil
}

func (r *UserRepo) GetUser(id uint64) (*entity.User, error) {
    var user entity.User
    err := r.db.Debug().Where("id = ?", id).Take(&user).Error
    if err != nil {
        return nil, err
    }
    if gorm.IsRecordNotFoundError(err) {
        return nil, errors.New("user not found")
    }
    return &user, nil
}

func (r *UserRepo) GetUsers() ([]entity.User, error) {
    var users []entity.User
    err := r.db.Debug().Find(&users).Error
    if err != nil {
        return nil, err
    }
    if gorm.IsRecordNotFoundError(err) {
        return nil, errors.New("user not found")
    }
    return users, nil
}

func (r *UserRepo) GetUserByEmailAndPassword(u *entity.User) (*entity.User, map[string]string) {
    var user entity.User
    dbErr := map[string]string{}
    err := r.db.Debug().Where("email = ?", u.Email).Take(&user).Error
    if gorm.IsRecordNotFoundError(err) {
        dbErr["no_user"] = "user not found"
        return nil, dbErr
    }
    if err != nil {
        dbErr["db_error"] = "database error"
        return nil, dbErr
    }
    // Verify password
    err = security.VerifyPassword(user.Password, u.Password)
    if err != nil && err == bcrypt.ErrMismatchedHashAndPassword {
        dbErr["incorrect_password"] = "incorrect password"
        return nil, dbErr
    }
    return &user, nil
}

Infrastructure Layer – Database Configuration

package persistence

import (
    "fmt"
    "food-app/domain/entity"
    "food-app/domain/repository"
    "github.com/jinzhu/gorm"
    _ "github.com/jinzhu/gorm/dialects/postgres"
)

type Repositories struct {
    User repository.UserRepository
    Food repository.FoodRepository
    db   *gorm.DB
}

func NewRepositories(Dbdriver, DbUser, DbPassword, DbPort, DbHost, DbName string) (*Repositories, error) {
    DBURL := fmt.Sprintf("host=%s port=%s user=%s dbname=%s sslmode=disable password=%s", DbHost, DbPort, DbUser, DbName, DbPassword)
    db, err := gorm.Open(Dbdriver, DBURL)
    if err != nil {
        return nil, err
    }
    db.LogMode(true)
    return &Repositories{User: NewUserRepository(db), Food: NewFoodRepository(db), db: db}, nil
}

func (s *Repositories) Close() error { return s.db.Close() }

func (s *Repositories) Automigrate() error { return s.db.AutoMigrate(&entity.User{}, &entity.Food{}).Error }

Application Layer – Service Use Cases

package application

import (
    "food-app/domain/entity"
    "food-app/domain/repository"
)

type userApp struct { us repository.UserRepository }

var _ UserAppInterface = &userApp{}

type UserAppInterface interface {
    SaveUser(*entity.User) (*entity.User, map[string]string)
    GetUsers() ([]entity.User, error)
    GetUser(uint64) (*entity.User, error)
    GetUserByEmailAndPassword(*entity.User) (*entity.User, map[string]string)
}

func (u *userApp) SaveUser(user *entity.User) (*entity.User, map[string]string) { return u.us.SaveUser(user) }
func (u *userApp) GetUser(id uint64) (*entity.User, error) { return u.us.GetUser(id) }
func (u *userApp) GetUsers() ([]entity.User, error) { return u.us.GetUsers() }
func (u *userApp) GetUserByEmailAndPassword(user *entity.User) (*entity.User, map[string]string) { return u.us.GetUserByEmailAndPassword(user) }

Interfaces Layer – HTTP Handlers

package interfaces

import (
    "food-app/application"
    "food-app/domain/entity"
    "food-app/infrastructure/auth"
    "github.com/gin-gonic/gin"
    "net/http"
    "strconv"
)

type Users struct { us application.UserAppInterface; rd auth.AuthInterface; tk auth.TokenInterface }

func NewUsers(us application.UserAppInterface, rd auth.AuthInterface, tk auth.TokenInterface) *Users { return &Users{us: us, rd: rd, tk: tk} }

func (s *Users) SaveUser(c *gin.Context) {
    var user entity.User
    if err := c.ShouldBindJSON(&user); err != nil {
        c.JSON(http.StatusUnprocessableEntity, gin.H{"invalid_json": "invalid json"})
        return
    }
    validateErr := user.Validate("")
    if len(validateErr) > 0 {
        c.JSON(http.StatusUnprocessableEntity, validateErr)
        return
    }
    newUser, err := s.us.SaveUser(&user)
    if err != nil {
        c.JSON(http.StatusInternalServerError, err)
        return
    }
    c.JSON(http.StatusCreated, newUser.PublicUser())
}

func (s *Users) GetUsers(c *gin.Context) {
    users, err := s.us.GetUsers()
    if err != nil {
        c.JSON(http.StatusInternalServerError, err.Error())
        return
    }
    c.JSON(http.StatusOK, entity.Users(users).PublicUsers())
}

func (s *Users) GetUser(c *gin.Context) {
    userId, err := strconv.ParseUint(c.Param("user_id"), 10, 64)
    if err != nil {
        c.JSON(http.StatusBadRequest, err.Error())
        return
    }
    user, err := s.us.GetUser(userId)
    if err != nil {
        c.JSON(http.StatusInternalServerError, err.Error())
        return
    }
    c.JSON(http.StatusOK, user.PublicUser())
}

Interfaces Layer – Authentication Handlers

package interfaces

import (
    "fmt"
    "food-app/application"
    "food-app/domain/entity"
    "food-app/infrastructure/auth"
    "github.com/dgrijalva/jwt-go"
    "github.com/gin-gonic/gin"
    "net/http"
    "os"
    "strconv"
)

type Authenticate struct { us application.UserAppInterface; rd auth.AuthInterface; tk auth.TokenInterface }

func NewAuthenticate(uApp application.UserAppInterface, rd auth.AuthInterface, tk auth.TokenInterface) *Authenticate { return &Authenticate{us: uApp, rd: rd, tk: tk} }

func (au *Authenticate) Login(c *gin.Context) {
    var user *entity.User
    var tokenErr = map[string]string{}
    if err := c.ShouldBindJSON(&user); err != nil {
        c.JSON(http.StatusUnprocessableEntity, "Invalid json provided")
        return
    }
    validateUser := user.Validate("login")
    if len(validateUser) > 0 {
        c.JSON(http.StatusUnprocessableEntity, validateUser)
        return
    }
    u, userErr := au.us.GetUserByEmailAndPassword(user)
    if userErr != nil {
        c.JSON(http.StatusInternalServerError, userErr)
        return
    }
    ts, tErr := au.tk.CreateToken(u.ID)
    if tErr != nil {
        tokenErr["token_error"] = tErr.Error()
        c.JSON(http.StatusUnprocessableEntity, tErr.Error())
        return
    }
    if saveErr := au.rd.CreateAuth(u.ID, ts); saveErr != nil {
        c.JSON(http.StatusInternalServerError, saveErr.Error())
        return
    }
    userData := map[string]interface{}{"access_token": ts.AccessToken, "refresh_token": ts.RefreshToken, "id": u.ID, "first_name": u.FirstName, "last_name": u.LastName}
    c.JSON(http.StatusOK, userData)
}

func (au *Authenticate) Logout(c *gin.Context) {
    metadata, err := au.tk.ExtractTokenMetadata(c.Request)
    if err != nil {
        c.JSON(http.StatusUnauthorized, "Unauthorized")
        return
    }
    if deleteErr := au.rd.DeleteTokens(metadata); deleteErr != nil {
        c.JSON(http.StatusUnauthorized, deleteErr.Error())
        return
    }
    c.JSON(http.StatusOK, "Successfully logged out")
}

func (au *Authenticate) Refresh(c *gin.Context) {
    var mapToken map[string]string
    if err := c.ShouldBindJSON(&mapToken); err != nil {
        c.JSON(http.StatusUnprocessableEntity, err.Error())
        return
    }
    refreshToken := mapToken["refresh_token"]
    token, err := jwt.Parse(refreshToken, func(token *jwt.Token) (interface{}, error) {
        if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
            return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
        }
        return []byte(os.Getenv("REFRESH_SECRET")), nil
    })
    if err != nil {
        c.JSON(http.StatusUnauthorized, err.Error())
        return
    }
    if _, ok := token.Claims.(jwt.Claims); !ok && !token.Valid {
        c.JSON(http.StatusUnauthorized, err)
        return
    }
    claims, ok := token.Claims.(jwt.MapClaims)
    if ok && token.Valid {
        refreshUuid, ok := claims["refresh_uuid"].(string)
        if !ok {
            c.JSON(http.StatusUnprocessableEntity, "Cannot get uuid")
            return
        }
        userId, err := strconv.ParseUint(fmt.Sprintf("%.f", claims["user_id"]), 10, 64)
        if err != nil {
            c.JSON(http.StatusUnprocessableEntity, "Error occurred")
            return
        }
        if delErr := au.rd.DeleteRefresh(refreshUuid); delErr != nil {
            c.JSON(http.StatusUnauthorized, "unauthorized")
            return
        }
        ts, createErr := au.tk.CreateToken(userId)
        if createErr != nil {
            c.JSON(http.StatusForbidden, createErr.Error())
            return
        }
        if saveErr := au.rd.CreateAuth(userId, ts); saveErr != nil {
            c.JSON(http.StatusForbidden, saveErr.Error())
            return
        }
        tokens := map[string]string{"access_token": ts.AccessToken, "refresh_token": ts.RefreshToken}
        c.JSON(http.StatusCreated, tokens)
    } else {
        c.JSON(http.StatusUnauthorized, "refresh token expired")
    }
}

Interfaces Layer – Middleware

package middleware

import (
    "bytes"
    "food-app/infrastructure/auth"
    "github.com/gin-gonic/gin"
    "io/ioutil"
    "net/http"
)

func AuthMiddleware() gin.HandlerFunc {
    return func(c *gin.Context) {
        if err := auth.TokenValid(c.Request); err != nil {
            c.JSON(http.StatusUnauthorized, gin.H{"status": http.StatusUnauthorized, "error": err.Error()})
            c.Abort()
            return
        }
        c.Next()
    }
}

func CORSMiddleware() gin.HandlerFunc {
    return func(c *gin.Context) {
        c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
        c.Writer.Header().Set("Access-Control-Allow-Credentials", "true")
        c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With")
        c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, PATCH, DELETE")
        if c.Request.Method == "OPTIONS" {
            c.AbortWithStatus(204)
            return
        }
        c.Next()
    }
}

func MaxSizeAllowed(n int64) gin.HandlerFunc {
    return func(c *gin.Context) {
        c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, n)
        buff, errRead := c.GetRawData()
        if errRead != nil {
            c.JSON(http.StatusRequestEntityTooLarge, gin.H{"status": http.StatusRequestEntityTooLarge, "upload_err": "too large: upload an image less than 8MB"})
            c.Abort()
            return
        }
        buf := bytes.NewBuffer(buff)
        c.Request.Body = ioutil.NopCloser(buf)
    }
}

Main Entry Point

package main

import (
    "food-app/infrastructure/auth"
    "food-app/infrastructure/persistence"
    "food-app/interfaces"
    "food-app/interfaces/fileupload"
    "food-app/interfaces/middleware"
    "github.com/gin-gonic/gin"
    "github.com/joho/godotenv"
    "log"
    "os"
)

func init() {
    if err := godotenv.Load(); err != nil {
        log.Println("no env gotten")
    }
}

func main() {
    dbdriver := os.Getenv("DB_DRIVER")
    host := os.Getenv("DB_HOST")
    password := os.Getenv("DB_PASSWORD")
    user := os.Getenv("DB_USER")
    dbname := os.Getenv("DB_NAME")
    port := os.Getenv("DB_PORT")

    redis_host := os.Getenv("REDIS_HOST")
    redis_port := os.Getenv("REDIS_PORT")
    redis_password := os.Getenv("REDIS_PASSWORD")

    services, err := persistence.NewRepositories(dbdriver, user, password, port, host, dbname)
    if err != nil { panic(err) }
    defer services.Close()
    services.Automigrate()

    redisService, err := auth.NewRedisDB(redis_host, redis_port, redis_password)
    if err != nil { log.Fatal(err) }

    tk := auth.NewToken()
    fd := fileupload.NewFileUpload()

    users := interfaces.NewUsers(services.User, redisService.Auth, tk)
    foods := interfaces.NewFood(services.Food, services.User, fd, redisService.Auth, tk)
    authenticate := interfaces.NewAuthenticate(services.User, redisService.Auth, tk)

    r := gin.Default()
    r.Use(middleware.CORSMiddleware())

    // User routes
    r.POST("/users", users.SaveUser)
    r.GET("/users", users.GetUsers)
    r.GET("/users/:user_id", users.GetUser)

    // Food routes
    r.POST("/food", middleware.AuthMiddleware(), middleware.MaxSizeAllowed(8192000), foods.SaveFood)
    r.PUT("/food/:food_id", middleware.AuthMiddleware(), middleware.MaxSizeAllowed(8192000), foods.UpdateFood)
    r.GET("/food/:food_id", foods.GetFoodAndCreator)
    r.DELETE("/food/:food_id", middleware.AuthMiddleware(), foods.DeleteFood)
    r.GET("/food", foods.GetAllFood)

    // Authentication routes
    r.POST("/login", authenticate.Login)
    r.POST("/logout", authenticate.Logout)
    r.POST("/refresh", authenticate.Refresh)

    app_port := os.Getenv("PORT")
    if app_port == "" { app_port = "8888" }
    log.Fatal(r.Run(":" + app_port))
}

Running the application is as simple as executing go run main.go after setting the environment variables.

In summary, the article demonstrates a full‑stack Go project that follows Domain‑Driven Design, showing how to structure code into domain, infrastructure, application, and interface layers, implement repositories with Gorm, manage authentication with JWT and Redis, and wire everything together using the Gin framework.

BackendmicroservicesGoauthenticationDDDAPIGorm
360 Tech Engineering
Written by

360 Tech Engineering

Official tech channel of 360, building the most professional technology aggregation platform for the brand.

0 followers
Reader feedback

How this landed with the community

login Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.