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.
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-appThe .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.
360 Tech Engineering
Official tech channel of 360, building the most professional technology aggregation platform for the brand.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.