Building a Music Recommendation App with Golang, Neo4j and GraphQL

Sami Salih İbrahimbaş
19 min readApr 28, 2024

--

Exploring Graph-Based Operations in Golang Server Implementation

Story Cover

Introduction

In this story, we will explore how to build a music recommendation app using Golang, Neo4j, and GraphQL. We will start by setting up a Neo4j database and then create a Golang server that will interact with the database. We will also create a GraphQL API that will allow us to query the database for music recommendations.

Prerequisites

To follow along with this story, you will need the following:

  • Go installed on your machine. You can download it from the official website.
  • A text editor or an IDE of your choice. My personal favorite is Visual Studio Code.
  • Basic knowledge of Go, Neo4j, and GraphQL.
  • Basic knowledge of REST APIs.
  • Basic knowledge of Docker.
  • Basic knowledge of Git.
  • Basic knowledge of the command line.

Since we will be using docker, we do not need to install a database.

Start Point of the Project

I’ve created a starting point for you in my github repo. You can get the starting point by pulling the feat/initial branch.

Here is just the docker configuration and basic architecture.

git clone -b feat/initial https://github.com/9ssi7/music-recommendation.git

Setting Up the Neo4j Database

We will create a small client under pkg to connect to the database. Here we will also cover the steps to try to reconnect.

We can add the following to the neo4j.go file under pkg/clients/neo4j.

package neo4j

import (
"context"
"fmt"
"time"

"github.com/9ssi7/music-recommender/config"
"github.com/neo4j/neo4j-go-driver/v5/neo4j"
)

func Connect(cnf config.Neo4j) neo4j.DriverWithContext {
ctx := context.Background()
driver, err := neo4j.NewDriverWithContext(
cnf.Uri,
neo4j.BasicAuth(cnf.Username, cnf.Password, ""))
if err != nil {
panic(err)
}
verifyOrRetryConnectivity(driver, ctx)

fmt.Println("connected to neo4j")
return driver
}

func verifyOrRetryConnectivity(driver neo4j.DriverWithContext, ctx context.Context) {
retryCount := 0
for {
err := driver.VerifyConnectivity(ctx)
if err != nil {
if retryCount > 3 {
panic(err)
}
time.Sleep(5 * time.Second)
fmt.Println("retrying to connect to neo4j")
retryCount++
} else {
break
}
}
}

This code will try to connect to the Neo4j database and will retry if it fails.

We will call this code from cmd/main.go. How will it happen? here it is:

package main

import (
"fmt"

"github.com/9ssi7/music-recommender/config"
"github.com/9ssi7/music-recommender/pkg/clients/neo4j" // add this line
)

func main() {
cnf := config.Get()
driver := neo4j.Connect(cnf.Neo4j) // add this line
fmt.Println(driver.Target().Scheme) // add this line
}

Now, finally, let’s write another small package to parse the fields from the cypher used by neo4j to our go structures.

We will create pkg/cypher/parser.go and add the following code:

package cypher

import (
"fmt"
"reflect"

"github.com/google/uuid"
"github.com/mitchellh/mapstructure"
"github.com/neo4j/neo4j-go-driver/v5/neo4j"
)

// Parse parses a cypher query result record and store the result in target interface
// - record: neo4j result record
// - alias: the alias used in cypher query (e.g. m.title)
// - target: target interface (e.g. models.Movie)
// Target object should a "neo4j" tab (e.g. `neo4j:"title"`)
func Parse(record *neo4j.Record, alias string, target interface{}) error {
elem := reflect.ValueOf(target).Elem()
for i := 0; i < elem.Type().NumField(); i++ {
structField := elem.Type().Field(i)
tag := structField.Tag.Get("neo4j")
fieldType := structField.Type
fieldName := structField.Name
if val, ok := record.Get(fmt.Sprintf("%s.%s", alias, tag)); ok {
// Ignore nil values
if val == nil {
continue
}
field := elem.FieldByName(fieldName)
if field.IsValid() {
t := fieldType.String()
switch t {
case "string":
field.SetString(val.(string))
case "int64":
field.SetInt(val.(int64))
case "uuid.UUID":
field.Set(reflect.ValueOf(uuid.MustParse(val.(string))))
default:
return fmt.Errorf("invalid type: %s", t)
}
}
}

}

return nil
}

func ParseStruct(record *neo4j.Record, alias string, target interface{}) error {
val, ok := record.Get(alias)
if ok {
valDb := val.(neo4j.Node)
mapstructure.Decode(valDb.GetProperties(), &target)
}
return nil
}

This code will parse the cypher query result and store it in the target interface.

Is this confusing? Don’t worry. Here is the work we did under this title in the following commit: feat: add neo4j client and cypher parser.

Let’s Create Our Domains

In this section, we will create our models and repositories. We have three models: User, Genre and Song. We will create a repository for each model.

Each of our models corresponds to a folder under domains. Both our dtos and our repo are located here.

User Domain

Let’s start with the User model. We will create a folder called user under pkg/domains and create the dtos.go file.

package user

import "github.com/google/uuid"

type CreateDto struct {
Id uuid.UUID `json:"-" neo4j:"id"`
UserName string `json:"userName" neo4j:"userName"`
Email string `json:"email" neo4j:"email"`
FavoriteGenres []uuid.UUID `json:"favoriteGenres" neo4j:"favoriteGenres"`
}

type ListDto struct {
Id uuid.UUID `json:"id" neo4j:"id"`
UserName string `json:"userName" neo4j:"userName"`
Email string `json:"email" neo4j:"email"`
}

func (dto *CreateDto) Build() map[string]interface{} {
dto.Id = uuid.New()
return map[string]interface{}{
"id": dto.Id.String(),
"userName": dto.UserName,
"email": dto.Email,
"favoriteGenres": dto.FavoriteGenres,
}
}

This code will create a User DTO and a method to build the DTO to neo4j properties.

Let’s create msg.go to fix the errors in our user domain and bring i18n support.

package user

type messages struct {
EmailAlreadyExists string
CreateFailed string
FetchFailed string
ParseFailed string
}

var Messages = messages{
EmailAlreadyExists: "user_email_already_exists",
CreateFailed: "user_create_failed",
FetchFailed: "user_fetch_failed",
ParseFailed: "user_parse_failed",
}

Now, let’s create the repository for the User model. We will create a file called repo.go under the user folder.

package user

import (
"context"

"github.com/9ssi7/music-recommender/pkg/cypher"
"github.com/cilloparch/cillop/i18np"
"github.com/neo4j/neo4j-go-driver/v5/neo4j"
)

type Repo interface {
GetByEmail(ctx context.Context, email string) (*ListDto, *i18np.Error)
Create(ctx context.Context, dto CreateDto) (*ListDto, *i18np.Error)
}

type repo struct {
driver neo4j.DriverWithContext
}

func NewRepo(driver neo4j.DriverWithContext) Repo {
return &repo{driver}
}

func (r *repo) Create(ctx context.Context, dto CreateDto) (*ListDto, *i18np.Error) {
session := r.driver.NewSession(ctx, neo4j.SessionConfig{AccessMode: neo4j.AccessModeWrite})
defer session.Close(ctx)
already, _err := r.GetByEmail(ctx, dto.Email)
if _err != nil {
return nil, _err
}
if already != nil {
return nil, i18np.NewError(Messages.EmailAlreadyExists)
}
query := `
CREATE (u:User {id: $id, userName: $userName, email: $email, favoriteGenres: $favoriteGenres})
WITH u
UNWIND $favoriteGenres AS genreId
MATCH (g:Genre {id: genreId})
CREATE (u)-[:FAVORITE_GENRE]->(g)
RETURN u.id, u.userName, u.email
`
_, err := session.Run(ctx, query, dto.Build())
if err != nil {
return nil, i18np.NewError(Messages.CreateFailed)
}
return &ListDto{
Id: dto.Id,
UserName: dto.UserName,
Email: dto.Email,
}, nil
}

func (r *repo) GetByEmail(ctx context.Context, email string) (*ListDto, *i18np.Error) {
session := r.driver.NewSession(ctx, neo4j.SessionConfig{AccessMode: neo4j.AccessModeRead})
defer session.Close(ctx)
query := `
MATCH (u:User {email: $email})
RETURN u.id, u.userName, u.email
`
record, err := session.Run(ctx, query, map[string]interface{}{"email": email})
if err != nil {
return nil, i18np.NewError(Messages.FetchFailed)
}
if !record.Next(ctx) {
return nil, nil
}
var listDto ListDto
if err := cypher.Parse(record.Record(), "u", &listDto); err != nil {
return nil, i18np.NewError(Messages.ParseFailed)
}
return &listDto, nil
}

This code will create a User repository and implement the Create and GetByEmail methods.

Is this confusing? Don’t worry. Here is the work we did under this title in the following commit: feat: add user domain.

Genre Domain

Let’s create the Genre model. We will create a folder called genre under pkg/domains and create the dtos.go file.

package genre

import "github.com/google/uuid"

type ListDto struct {
Id uuid.UUID `json:"id" neo4j:"id"`
Name string `json:"name" neo4j:"name"`
}

type CreateDto struct {
Id uuid.UUID `json:"-" neo4j:"id"`
Name string `json:"name" neo4j:"name"`
}

func (dto *CreateDto) Build() map[string]interface{} {
dto.Id = uuid.New()
return map[string]interface{}{
"id": dto.Id.String(),
"name": dto.Name,
}
}

This code will create a Genre DTO and a method to build the DTO to neo4j properties.

Let’s create msg.go to fix the errors in our user domain and bring i18n support.

package genre

type messages struct {
CreateFailed string
FetchFailed string
ParseFailed string
DeleteFailed string
}

var Messages = messages{
CreateFailed: "genre_create_failed",
FetchFailed: "genre_fetch_failed",
ParseFailed: "genre_parse_failed",
DeleteFailed: "genre_delete_failed",
}

Now, let’s create the repository for the Genre model. We will create a file called repo.go under the genre folder.

package genre

import (
"context"

"github.com/9ssi7/music-recommender/pkg/cypher"
"github.com/cilloparch/cillop/i18np"
"github.com/neo4j/neo4j-go-driver/v5/neo4j"
)

type Repo interface {
List(ctx context.Context) ([]ListDto, *i18np.Error)
View(ctx context.Context, id string) (*ListDto, *i18np.Error)
Create(ctx context.Context, dto CreateDto) (*ListDto, *i18np.Error)
Delete(ctx context.Context, id string) *i18np.Error
}

type repo struct {
driver neo4j.DriverWithContext
}

func NewRepo(driver neo4j.DriverWithContext) Repo {
return &repo{driver}
}

func (r *repo) List(ctx context.Context) ([]ListDto, *i18np.Error) {
session := r.driver.NewSession(ctx, neo4j.SessionConfig{AccessMode: neo4j.AccessModeRead})
defer session.Close(ctx)
query := `
MATCH (g:Genre)
RETURN g.id, g.name
`
record, err := session.Run(ctx, query, nil)
if err != nil {
return nil, i18np.NewError(Messages.FetchFailed)
}
var listDto []ListDto
for record.Next(ctx) {
var dto ListDto
if err := cypher.Parse(record.Record(), "g", &dto); err != nil {
return nil, i18np.NewError(Messages.ParseFailed)
}
listDto = append(listDto, dto)
}
return listDto, nil
}

func (r *repo) View(ctx context.Context, id string) (*ListDto, *i18np.Error) {
session := r.driver.NewSession(ctx, neo4j.SessionConfig{AccessMode: neo4j.AccessModeRead})
defer session.Close(ctx)
query := `
MATCH (g:Genre {id: $id})
RETURN g.id, g.name
`
record, err := session.Run(ctx, query, map[string]interface{}{"id": id})
if err != nil {
return nil, i18np.NewError(Messages.FetchFailed)
}
if !record.Next(ctx) {
return nil, nil
}
var dto ListDto
if err := cypher.Parse(record.Record(), "g", &dto); err != nil {
return nil, i18np.NewError(Messages.ParseFailed)
}
return &dto, nil
}

func (r *repo) Create(ctx context.Context, dto CreateDto) (*ListDto, *i18np.Error) {
session := r.driver.NewSession(ctx, neo4j.SessionConfig{AccessMode: neo4j.AccessModeWrite})
defer session.Close(ctx)
query := `
CREATE (g:Genre {id: $id, name: $name})
RETURN g.id, g.name
`
_, err := session.Run(ctx, query, dto.Build())
if err != nil {
return nil, i18np.NewError(Messages.CreateFailed)
}
return &ListDto{
Id: dto.Id,
Name: dto.Name,
}, nil
}

func (r *repo) Delete(ctx context.Context, id string) *i18np.Error {
session := r.driver.NewSession(ctx, neo4j.SessionConfig{AccessMode: neo4j.AccessModeWrite})
defer session.Close(ctx)
query := `
MATCH (g:Genre {id: $id})
DETACH DELETE g
`
_, err := session.Run(ctx, query, map[string]interface{}{"id": id})
if err != nil {
return i18np.NewError(Messages.DeleteFailed)
}
return nil
}

This code will create a Genre repository and implement the List, View, Create, and Delete methods.

Is this confusing? Don’t worry. Here is the work we did under this title in the following commit: feat: add genre domain.

Song Domain

Let’s create the Song model. We will create a folder called song under pkg/domains and create the dtos.go file.

package song

import "github.com/google/uuid"

type ListDto struct {
Id uuid.UUID `json:"id" neo4j:"id"`
Title string `json:"title" neo4j:"title"`
Artist string `json:"artist" neo4j:"artist"`
}

type CreateDto struct {
Id uuid.UUID `json:"-" neo4j:"id"`
Title string `json:"title" neo4j:"title"`
Artist string `json:"artist" neo4j:"artist"`
GenreId uuid.UUID `json:"genreId" neo4j:"genreId"`
}

func (dto *CreateDto) Build() map[string]interface{} {
dto.Id = uuid.New()
return map[string]interface{}{
"id": dto.Id.String(),
"title": dto.Title,
"artist": dto.Artist,
"genreId": dto.GenreId.String(),
}
}

This code will create a Song DTO and a method to build the DTO to neo4j properties.

Let’s create msg.go to fix the errors in our user domain and bring i18n support.

package song

type messages struct {
CreateFailed string
FetchFailed string
ParseFailed string
DeleteFailed string
MarkListenedFailed string
}

var Messages = messages{
CreateFailed: "song_create_failed",
FetchFailed: "song_fetch_failed",
ParseFailed: "song_parse_failed",
DeleteFailed: "song_delete_failed",
MarkListenedFailed: "song_mark_listened_failed",
}

Now, let’s create the repository for the Song model. We will create a file called repo.go under the song folder.

package song

import (
"context"
"fmt"

"github.com/9ssi7/music-recommender/pkg/cypher"
"github.com/cilloparch/cillop/i18np"
"github.com/google/uuid"
"github.com/neo4j/neo4j-go-driver/v5/neo4j"
)

type Repo interface {
List(ctx context.Context) ([]ListDto, *i18np.Error)
ListByGenre(ctx context.Context, genreId uuid.UUID) ([]ListDto, *i18np.Error)
ListUserRecommendation(ctx context.Context, userId uuid.UUID) ([]ListDto, *i18np.Error)
View(ctx context.Context, id uuid.UUID) (*ListDto, *i18np.Error)
Create(ctx context.Context, dto CreateDto) (*ListDto, *i18np.Error)
Delete(ctx context.Context, id uuid.UUID) *i18np.Error
MarkListened(ctx context.Context, userId, songId uuid.UUID) *i18np.Error
}

type repo struct {
driver neo4j.DriverWithContext
}

func NewRepo(driver neo4j.DriverWithContext) Repo {
return &repo{driver}
}

func (r *repo) List(ctx context.Context) ([]ListDto, *i18np.Error) {
session := r.driver.NewSession(ctx, neo4j.SessionConfig{AccessMode: neo4j.AccessModeRead})
defer session.Close(ctx)
query := `
MATCH (s:Song)
RETURN s.id, s.title, s.artist
`
record, err := session.Run(ctx, query, nil)
if err != nil {
return nil, i18np.NewError(Messages.FetchFailed)
}
var listDto []ListDto
for record.Next(ctx) {
var dto ListDto
if err := cypher.Parse(record.Record(), "s", &dto); err != nil {
return nil, i18np.NewError(Messages.ParseFailed)
}
listDto = append(listDto, dto)
}
return listDto, nil
}

func (r *repo) ListByGenre(ctx context.Context, genreId uuid.UUID) ([]ListDto, *i18np.Error) {
session := r.driver.NewSession(ctx, neo4j.SessionConfig{AccessMode: neo4j.AccessModeRead})
defer session.Close(ctx)
query := `
MATCH (g:Genre {id: $genreId})-[:HAS]->(s:Song)
RETURN s.id, s.title, s.artist
`
args := map[string]interface{}{"genreId": genreId.String()}
record, err := session.Run(ctx, query, args)
if err != nil {
return nil, i18np.NewError(Messages.FetchFailed)
}
var listDto []ListDto
for record.Next(ctx) {
var dto ListDto
if err := cypher.Parse(record.Record(), "s", &dto); err != nil {
return nil, i18np.NewError(Messages.ParseFailed)
}
listDto = append(listDto, dto)
}
return listDto, nil
}

func (r *repo) ListUserRecommendation(ctx context.Context, userId uuid.UUID) ([]ListDto, *i18np.Error) {
session := r.driver.NewSession(ctx, neo4j.SessionConfig{AccessMode: neo4j.AccessModeRead})
defer session.Close(ctx)
query := `
MATCH (u:User {id: $userId})-[:LISTENED]->(s:Song)-[:HAS_GENRE]->(g:Genre)<-[:HAS_GENRE]-(recommendedSong:Song)
WHERE NOT (u)-[:LISTENED]->(recommendedSong)
RETURN recommendedSong.id, recommendedSong.title, recommendedSong.artist, COUNT(DISTINCT g) AS commonGenres
ORDER BY COUNT(DISTINCT g) DESC, recommendedSong.artist
LIMIT 10
`
args := map[string]interface{}{"userId": userId.String()}
record, err := session.Run(ctx, query, args)
if err != nil {
return nil, i18np.NewError(Messages.FetchFailed)
}
var listDto []ListDto
for record.Next(ctx) {
var dto ListDto
if err := cypher.Parse(record.Record(), "recommendedSong", &dto); err != nil {
return nil, i18np.NewError(Messages.ParseFailed)
}
listDto = append(listDto, dto)
}
return listDto, nil
}

func (r *repo) View(ctx context.Context, id uuid.UUID) (*ListDto, *i18np.Error) {
session := r.driver.NewSession(ctx, neo4j.SessionConfig{AccessMode: neo4j.AccessModeRead})
defer session.Close(ctx)
query := `
MATCH (s:Song {id: $id})
RETURN s.id, s.title, s.artist
`
args := map[string]interface{}{"id": id.String()}
record, err := session.Run(ctx, query, args)
if err != nil {
return nil, i18np.NewError(Messages.FetchFailed)
}
if !record.Next(ctx) {
return nil, nil
}
var dto ListDto
if err := cypher.Parse(record.Record(), "s", &dto); err != nil {
return nil, i18np.NewError(Messages.ParseFailed)
}
return &dto, nil
}

func (r *repo) Create(ctx context.Context, dto CreateDto) (*ListDto, *i18np.Error) {
session := r.driver.NewSession(ctx, neo4j.SessionConfig{AccessMode: neo4j.AccessModeWrite})
defer session.Close(ctx)
query := `
MATCH (g:Genre {id: $genreId})
CREATE (s:Song {id: $id, title: $title, artist: $artist})
CREATE (s)-[:HAS_GENRE]->(g)
RETURN s.id, s.title, s.artist
`
_, err := session.Run(ctx, query, dto.Build())
if err != nil {
return nil, i18np.NewError(Messages.CreateFailed)
}
return &ListDto{
Id: dto.Id,
Title: dto.Title,
Artist: dto.Artist,
}, nil
}

func (r *repo) Delete(ctx context.Context, id uuid.UUID) *i18np.Error {
session := r.driver.NewSession(ctx, neo4j.SessionConfig{AccessMode: neo4j.AccessModeWrite})
defer session.Close(ctx)
query := `
MATCH (s:Song {id: $id})
DETACH DELETE s
`
args := map[string]interface{}{"id": id.String()}
_, err := session.Run(ctx, query, args)
if err != nil {
return i18np.NewError(Messages.DeleteFailed)
}
return nil
}

func (r *repo) MarkListened(ctx context.Context, userId, songId uuid.UUID) *i18np.Error {
session := r.driver.NewSession(ctx, neo4j.SessionConfig{AccessMode: neo4j.AccessModeWrite})
defer session.Close(ctx)
query := `
MATCH (u:User {id: $userId}), (s:Song {id: $songId})
MERGE (u)-[r:LISTENED]->(s)
SET r.listenedAt = datetime()
`
args := map[string]interface{}{"userId": userId.String(), "songId": songId.String()}
_, err := session.Run(ctx, query, args)
if err != nil {
fmt.Println(err)
return i18np.NewError(Messages.MarkListenedFailed)
}
return nil
}

This code will create a Song repository and implement the List, ListByGenre, ListUserRecommendation, View, Create, Delete, and MarkListened methods.

So here we have algorithmic work other than CRUD. To explain these:

  • ListUserRecommendation: This method will return the songs that the user has not listened to but are similar to the songs the user has listened to.
  • MarkListened: This method will mark the song as listened by the user.
  • ListByGenre: This method will return the songs of the given genre.

Is this confusing? Don’t worry. Here is the work we did under this title in the following commit: feat: add song domain.

Let’s Create Our Commands and Queries

In this section, we will create our commands and queries. We will create command for create and update operations in each repo and query for read operations.

Brief explanation: We call the repo from here, not directly from the api server, because we need a layer between our Rpc, GraphQL or Rest api and the repo. Application level logic is processed here.

User Command and Query

Let’s create the user_create.go in the app/command folder.

package command

import (
"context"

"github.com/9ssi7/music-recommender/domains/user"
"github.com/cilloparch/cillop/cqrs"
"github.com/cilloparch/cillop/i18np"
"github.com/google/uuid"
)

type UserCreateCmd struct {
Email string
UserName string
FavoriteGenres []string
}

type UserCreateRes struct {
Dto *user.ListDto
}

type UserCreateHandler cqrs.HandlerFunc[UserCreateCmd, *UserCreateRes]

func NewUserCreateHandler(repo user.Repo) UserCreateHandler {
return func(ctx context.Context, cmd UserCreateCmd) (*UserCreateRes, *i18np.Error) {
genres := make([]uuid.UUID, len(cmd.FavoriteGenres))
for i, genre := range cmd.FavoriteGenres {
genres[i] = uuid.MustParse(genre)
}
dto, err := repo.Create(ctx, user.CreateDto{
UserName: cmd.UserName,
Email: cmd.Email,
FavoriteGenres: genres,
})
if err != nil {
return nil, err
}
return &UserCreateRes{
Dto: dto,
}, nil
}
}

This code will create a UserCreate command and handler. It takes the parameters and passes them to the user repo.

Let’s create the user_get_by_email.go in the app/query folder.

package query

import (
"context"

"github.com/9ssi7/music-recommender/domains/user"
"github.com/cilloparch/cillop/cqrs"
"github.com/cilloparch/cillop/i18np"
)

type UserGetByEmailQuery struct {
Email string
}

type UserGetByEmailResult struct {
Dto *user.ListDto
}

type UserGetByEmailHandler cqrs.HandlerFunc[UserGetByEmailQuery, *UserGetByEmailResult]

func NewUserGetByEmailHandler(repo user.Repo) UserGetByEmailHandler {
return func(ctx context.Context, query UserGetByEmailQuery) (*UserGetByEmailResult, *i18np.Error) {
dto, err := repo.GetByEmail(ctx, query.Email)
if err != nil {
return nil, err
}
return &UserGetByEmailResult{
Dto: dto,
}, nil
}
}

This code will create a UserGetByEmail query and handler. It takes the parameters and passes them to the user repo.

Now let’s apply these changes in app.go under our app folder.

package app

import (
"github.com/9ssi7/music-recommender/app/command"
"github.com/9ssi7/music-recommender/app/query"
)

type Commands struct {
UserCreate command.UserCreateHandler // add this line
}

type Queries struct {
UserGetByEmail query.UserGetByEmailHandler // add this line
}

type App struct {
Commands Commands
Queries Queries
}

This code will add the UserCreate command and UserGetByEmail query to the App struct.

Is this confusing? Don’t worry. Here is the work we did under this title in the following commit: feat: add user command and query.

Genre Command and Query

Since we will write command or query to each function in the repo here, just like we did in the user domain, I do not write the codes here so that the article does not become too long.

You can still quickly access the codes in this section. Here is the work we did under this title in the following commit: feat: add genre command and query.

Song Command and Query

Since we will write command or query to each function in the repo here, just like we did in the user domain, I do not write the codes here so that the article does not become too long.

You can still quickly access the codes in this section. Here is the work we did under this title in the following commit: feat: add song command and query.

Let’s Create Our GraphQL API

In this section, we will create our GraphQL API. First, we will create a schema and generate the code using gqlgen. Then we will create the resolvers.

Schema

Let’s create the schema.graphqls file in the server/graph folder.

type UserListDto {
id: UUID!
userName: String!
email: String!
}
input CreateUserInput {
email: String!
userName: String!
favoriteGenres: [String!]!
}
type GenreListDto {
id: UUID!
name: String!
}
input CreateGenreInput {
name: String!
}
type SongListDto {
id: UUID!
title: String!
artist: String!
}
input CreateSongInput {
title: String!
artist: String!
genreId: UUID!
}
type Query {
user(email: String!): UserListDto!
genres: [GenreListDto!]!
genre(id: UUID!): GenreListDto!
songs: [SongListDto!]!
song(id: UUID!): SongListDto!
songsByGenre(genreId: UUID!): [SongListDto!]!
songsRecommendation(userId: UUID!): [SongListDto!]!
}
type Mutation {
createUser(input: CreateUserInput!): UserListDto!
createGenre(input: CreateGenreInput!): GenreListDto!
deleteGenre(id: UUID!): Boolean!
createSong(input: CreateSongInput!): SongListDto!
deleteSong(id: UUID!): Boolean!
markSongAsListened(userId: UUID!, songId: UUID!): Boolean!
}
scalar UUID

This code will create the schema for our GraphQL API. It defines the types, inputs, queries, and mutations. We will use UUID as a scalar type. We will use this schema to generate the code using gqlgen.

Dependency Injection in the Resolver

Let’s create the resolver.go file in the server/graph folder.

package graph

import (
"github.com/9ssi7/music-recommender/app"
)

// This file will not be regenerated automatically.
//
// It serves as dependency injection for your app, add any dependencies you require here.
type Resolver struct {
app *app.App
}

This code will create the Resolver struct that will hold the app instance. We will use this struct to access the app instance in the resolvers.

Run gqlgen

We will use gqlgen to generate the code for our GraphQL API.

We must be add a config file for gqlgen. Let’s create the gqlgen.yml file in the server/graph folder.

schema:
- ./*.graphqls
exec:
filename: generated.go
package: graph
model:
filename: model/models_gen.go
package: model
resolver:
layout: follow-schema
dir: ./
package: graph
filename_template: "{name}.resolvers.go"
autobind:
models:
ID:
model:
- github.com/99designs/gqlgen/graphql.ID
- github.com/99designs/gqlgen/graphql.Int
- github.com/99designs/gqlgen/graphql.Int64
- github.com/99designs/gqlgen/graphql.Int32
Int:
model:
- github.com/99designs/gqlgen/graphql.Int
- github.com/99designs/gqlgen/graphql.Int64
- github.com/99designs/gqlgen/graphql.Int32
UUID:
model:
- github.com/99designs/gqlgen/graphql.UUID

We can run the following command to generate the code.

cd server/graph
go run github.com/99designs/gqlgen generate

This command will generate the code for our GraphQL API. We can now implement the resolvers.

Resolvers

Let’s edit the schema.resolvers.go file in the server/graph folder.

package graph

// This file will be automatically regenerated based on the schema, any resolver implementations
// will be copied through when generating and any unknown code will be moved to the end.
// Code generated by github.com/99designs/gqlgen version v0.17.45
import (
"context"
"github.com/9ssi7/music-recommender/app/command"
"github.com/9ssi7/music-recommender/app/query"
"github.com/9ssi7/music-recommender/server/graph/model"
"github.com/google/uuid"
)
// CreateUser is the resolver for the createUser field.
func (r *mutationResolver) CreateUser(ctx context.Context, input model.CreateUserInput) (*model.UserListDto, error) {
res, err := r.app.Commands.UserCreate(ctx, command.UserCreateCmd{
Email: input.Email,
UserName: input.UserName,
FavoriteGenres: input.FavoriteGenres,
})
if err != nil {
return nil, err
}
return &model.UserListDto{
ID: res.Dto.Id,
UserName: res.Dto.UserName,
Email: res.Dto.Email,
}, nil
}
// CreateGenre is the resolver for the createGenre field.
func (r *mutationResolver) CreateGenre(ctx context.Context, input model.CreateGenreInput) (*model.GenreListDto, error) {
res, err := r.app.Commands.GenreCreate(ctx, command.GenreCreateCommand{
Name: input.Name,
})
if err != nil {
return nil, err
}
return &model.GenreListDto{
ID: res.Dto.Id,
Name: res.Dto.Name,
}, nil
}
// DeleteGenre is the resolver for the deleteGenre field.
func (r *mutationResolver) DeleteGenre(ctx context.Context, id uuid.UUID) (bool, error) {
_, err := r.app.Commands.GenreDelete(ctx, command.GenreDeleteCommand{
Id: id,
})
if err != nil {
return false, err
}
return true, nil
}
// CreateSong is the resolver for the createSong field.
func (r *mutationResolver) CreateSong(ctx context.Context, input model.CreateSongInput) (*model.SongListDto, error) {
res, err := r.app.Commands.SongCreate(ctx, command.SongCreateCommand{
Title: input.Title,
Artist: input.Artist,
GenreId: input.GenreID,
})
if err != nil {
return nil, err
}
return &model.SongListDto{
ID: res.Dto.Id,
Title: res.Dto.Title,
Artist: res.Dto.Artist,
}, nil
}
// DeleteSong is the resolver for the deleteSong field.
func (r *mutationResolver) DeleteSong(ctx context.Context, id uuid.UUID) (bool, error) {
_, err := r.app.Commands.SongDelete(ctx, command.SongDeleteCommand{
Id: id,
})
if err != nil {
return false, err
}
return true, nil
}
// MarkSongAsListened is the resolver for the markSongAsListened field.
func (r *mutationResolver) MarkSongAsListened(ctx context.Context, userID uuid.UUID, songID uuid.UUID) (bool, error) {
_, err := r.app.Commands.SongMarkListened(ctx, command.SongMarkListenedCommand{
UserId: userID,
Id: songID,
})
if err != nil {
return false, err
}
return true, nil
}
// User is the resolver for the user field.
func (r *queryResolver) User(ctx context.Context, email string) (*model.UserListDto, error) {
res, err := r.app.Queries.UserGetByEmail(ctx, query.UserGetByEmailQuery{
Email: email,
})
if err != nil {
return nil, err
}
return &model.UserListDto{
ID: res.Dto.Id,
UserName: res.Dto.UserName,
Email: res.Dto.Email,
}, nil
}
// Genres is the resolver for the genres field.
func (r *queryResolver) Genres(ctx context.Context) ([]*model.GenreListDto, error) {
res, err := r.app.Queries.GenreList(ctx, query.GenreListQuery{})
if err != nil {
return nil, err
}
result := make([]*model.GenreListDto, 0, len(res.Dtos))
for _, dto := range res.Dtos {
result = append(result, &model.GenreListDto{
ID: dto.Id,
Name: dto.Name,
})
}
return result, nil
}
// Genre is the resolver for the genre field.
func (r *queryResolver) Genre(ctx context.Context, id uuid.UUID) (*model.GenreListDto, error) {
res, err := r.app.Queries.GenreView(ctx, query.GenreViewQuery{
Id: id,
})
if err != nil {
return nil, err
}
return &model.GenreListDto{
ID: res.Dto.Id,
Name: res.Dto.Name,
}, nil
}
// Songs is the resolver for the songs field.
func (r *queryResolver) Songs(ctx context.Context) ([]*model.SongListDto, error) {
res, err := r.app.Queries.SongList(ctx, query.SongListQuery{})
if err != nil {
return nil, err
}
result := make([]*model.SongListDto, 0, len(res.Dtos))
for _, dto := range res.Dtos {
result = append(result, &model.SongListDto{
ID: dto.Id,
Title: dto.Title,
Artist: dto.Artist,
})
}
return result, nil
}
// Song is the resolver for the song field.
func (r *queryResolver) Song(ctx context.Context, id uuid.UUID) (*model.SongListDto, error) {
res, err := r.app.Queries.SongView(ctx, query.SongViewQuery{
Id: id,
})
if err != nil {
return nil, err
}
return &model.SongListDto{
ID: res.Dto.Id,
Title: res.Dto.Title,
Artist: res.Dto.Artist,
}, nil
}
// SongsByGenre is the resolver for the songsByGenre field.
func (r *queryResolver) SongsByGenre(ctx context.Context, genreID uuid.UUID) ([]*model.SongListDto, error) {
res, err := r.app.Queries.SongListByGenre(ctx, query.SongListByGenreQuery{
GenreId: genreID,
})
if err != nil {
return nil, err
}
result := make([]*model.SongListDto, 0, len(res.Dtos))
for _, dto := range res.Dtos {
result = append(result, &model.SongListDto{
ID: dto.Id,
Title: dto.Title,
Artist: dto.Artist,
})
}
return result, nil
}
// SongsRecommendation is the resolver for the songsRecommendation field.
func (r *queryResolver) SongsRecommendation(ctx context.Context, userID uuid.UUID) ([]*model.SongListDto, error) {
res, err := r.app.Queries.SongListUserRecommendation(ctx, query.SongListUserRecommendationQuery{
UserId: userID,
})
if err != nil {
return nil, err
}
result := make([]*model.SongListDto, 0, len(res.Dtos))
for _, dto := range res.Dtos {
result = append(result, &model.SongListDto{
ID: dto.Id,
Title: dto.Title,
Artist: dto.Artist,
})
}
return result, nil
}
// Mutation returns MutationResolver implementation.
func (r *Resolver) Mutation() MutationResolver { return &mutationResolver{r} }
// Query returns QueryResolver implementation.
func (r *Resolver) Query() QueryResolver { return &queryResolver{r} }
type mutationResolver struct{ *Resolver }
type queryResolver struct{ *Resolver }

This code will implement the resolvers for our GraphQL API. It will call the commands and queries from the app instance.

Here we have updated only the areas of the automatically generated code that need to be implemented.

We can access the app because we passed the app parameter to the resolver in the Dependency Injection step.

Is this confusing? Don’t worry. Here is the work we did under this title in the following commit: feat: add graphql api.

Let’s Create Our GraphQL Server

In this section, we will create our GraphQL server with creating srv.go file in the server/graph folder.

package graph

import (
"fmt"
"log"
"net/http"
"github.com/99designs/gqlgen/graphql/handler"
"github.com/99designs/gqlgen/graphql/playground"
"github.com/9ssi7/music-recommender/app"
"github.com/9ssi7/music-recommender/config"
"github.com/cilloparch/cillop/server"
)

type srv struct {
app app.App
cnf config.Graphql
}
func NewServer(cnf config.Graphql, app app.App) server.Server {
return &srv{
app: app,
cnf: cnf,
}
}
func (s *srv) Listen() error {
srv := handler.NewDefaultServer(NewExecutableSchema(Config{Resolvers: &Resolver{
app: &s.app,
}}))
http.Handle("/", playground.Handler("GraphQL playground", "/query"))
http.Handle("/query", srv)
log.Printf("connect to %s://%s:%v/ for GraphQL playground", s.cnf.Protocol, s.cnf.Host, s.cnf.Port)
log.Fatal(http.ListenAndServe(":"+fmt.Sprintf("%v", s.cnf.Port), nil))
return nil
}

This code will create a GraphQL server. It will create a handler for the GraphQL playground and the GraphQL API. It will use the Resolver struct to access the app instance.

Is this confusing? Don’t worry. Here is the work we did under this title in the following commit: feat: add graphql server.

Let’s Create Our Service App

In this section, we will create our service app with creating app.go file in the service folder.

package service

import (
"github.com/9ssi7/music-recommender/app"
"github.com/9ssi7/music-recommender/app/command"
"github.com/9ssi7/music-recommender/app/query"
"github.com/9ssi7/music-recommender/domains/genre"
"github.com/9ssi7/music-recommender/domains/song"
"github.com/9ssi7/music-recommender/domains/user"
"github.com/neo4j/neo4j-go-driver/v5/neo4j"
)

type Config struct {
Driver neo4j.DriverWithContext
}

func NewApp(cnf Config) app.App {
userRepo := user.NewRepo(cnf.Driver)
genreRepo := genre.NewRepo(cnf.Driver)
songRepo := song.NewRepo(cnf.Driver)

return app.App{
Commands: app.Commands{
UserCreate: command.NewUserCreateHandler(userRepo),
GenreCreate: command.NewGenreCreateHandler(genreRepo),
GenreDelete: command.NewGenreDeleteHandler(genreRepo),
SongCreate: command.NewSongCreateHandler(songRepo),
SongDelete: command.NewSongDeleteHandler(songRepo),
SongMarkListened: command.NewSongMarkListenedHandler(songRepo),
},
Queries: app.Queries{
UserGetByEmail: query.NewUserGetByEmailHandler(userRepo),
GenreList: query.NewGenreListHandler(genreRepo),
GenreView: query.NewGenreViewHandler(genreRepo),
SongList: query.NewSongListHandler(songRepo),
SongView: query.NewSongViewHandler(songRepo),
SongListByGenre: query.NewSongListByGenreHandler(songRepo),
SongListUserRecommendation: query.NewSongListUserRecommendationHandler(songRepo),
},
}
}

This code will create the service app. It will create the repositories and commands and queries for the user, genre, and song domains.

Is this confusing? Don’t worry. Here is the work we did under this title in the following commit: feat: add service app.

Let’s Edit Our Main Function

In this section, we will create our main function with editing main.go file in the cmd folder.

package main

import (
"github.com/9ssi7/music-recommender/config"
"github.com/9ssi7/music-recommender/pkg/clients/neo4j"
"github.com/9ssi7/music-recommender/server/graph"
"github.com/9ssi7/music-recommender/service"
)
func main() {
cnf := config.Get()
driver := neo4j.Connect(cnf.Neo4j)
app := service.NewApp(service.Config{
Driver: driver,
})
graphSrv := graph.NewServer(cnf.Graphql, app)
graphSrv.Listen()
}

This code will create the main function. It will get the configuration, connect to the Neo4j database, create the service app, and create the GraphQL server.

Is this confusing? Don’t worry. Here is the work we did under this title in the following commit: feat: update the main function.

Let’s Create Our Translations

In this section, we will create our translations with creating en.toml file in the locales folder.

# Genre Translation
genre_create_failed = "Genre creation failed"
genre_fetch_failed = "Genre fetch failed"
genre_parse_failed = "Genre parse failed"
genre_delete_failed = "Genre delete failed"

# Song Translation
song_create_failed = "Song creation failed"
song_fetch_failed = "Song fetch failed"
song_parse_failed = "Song parse failed"
song_delete_failed = "Song delete failed"
song_mark_listened_failed = "Song mark listened failed"

# User Translation
user_email_already_exists = "User email already exists"
user_create_failed = "User creation failed"
user_fetch_failed = "User fetch failed"
user_parse_failed = "User parse failed"

This code will create the translations for our application. We will use these translations in our repositories.

Is this confusing? Don’t worry. Here is the work we did under this title in the following commit: feat: add translations.

Let’s Run Our Application

In this section, we will run our application.

For this, we need to run the following command in the root folder.

make dev

This command will run the application. We can access the GraphQL playground at http://localhost:4000/.

Conclusion

In this story, we have created a music recommender system using Go, Neo4j, and GraphQL. We have created the user, genre, and song domains. We have created the repositories, commands, and queries for these domains. We have created the GraphQL API and server. We have created the service app. We have created the translations. We have run the application.

I hope this story will be useful for you. You can access the source code of the project from the following link: music-recommender.

--

--