Go Microservice Template for Beginners by .NET Developer. Part 2

A visual diagram of what we want to do

A visual diagram of what we want to do

Now we move from describing what we want to implement to what our microservice will look like.

Let's start with how the client will interact with our bookstore. For interaction, we choose the REST API, which will include the following requests:

  • GET /api/v1/books – Receiving a list of books available for sale by the client.

  • POST /api/v1/books – Adding a new book to our store.

  • POST /api/v1/books/buy – Purchase of a book contained in our store

In my microservice architecture, I decided to abandon the classic service approach in favor of CQRS using the MediatR library, where the API contracts will be commands (Command) and queries (Query). This solution fits very well with microservices in .NET, and I think that in Go it will not cause any particular difficulties either, since I have seen many projects on GitHub that work on this basis.

We will simulate the delivery of books using a background task running alongside the HTTP server. This task will send new books to Kafka, and our consumer will store them in our store. I added this element to show an example of interaction with Kafka, so no claims about the appropriateness of using Kafka here.

General scheme of our store's work

General scheme of our store's work

The tests I prepared for this project are component tests. This is a kind of analogue of end-to-end tests, but all the necessary environment for the application is raised in containers, simulating the work of a real API. You can learn about what component tests are in the next article.

However, they will be in continuation of this template and in the next separate article, as well as the part with kafka.

Begin

After looking at various real projects on GitHub, I decided to deviate a bit from the structure I described in the previous article. I thought about it for a long time and came to the following decision.

First, let's create a project and add the first directory to it. cmdin which we will place our main file main.go. The equivalent in the latest versions of .NET is a file Program.csin which our server configuration takes place.

Our service will be configured using the “go.uber.org/fx” library, so let's open the console and run the following command:

go get "go.uber.org/fx"

We fill main.go as follows.

package main

import "go.uber.org/fx"

func main() {
	fx.New(
		fx.Options(
			fx.Provide(),
		),
	).Run()
}

Uber FX is a wrapper in which we register all the necessary dependencies and also launch tasks for processing.

To make it clearer, I'll show you how it would look in .NET:

var builder = WebApplication.CreateBuilder(args);

var app = builder.Build();

app.Run();

We need to configure our HTTP server that will handle incoming requests. For this purpose, I decided to use the Echo library.

go get "github.com/labstack/echo/v4"
go get "github.com/labstack/gommon/log"

We go to the directory where our common service packages will be located pkgand create a catalog httpin which we create a directory server with file echo_server.go.

package echoserver

import (
	"context"
	"github.com/labstack/echo/v4"
	"github.com/labstack/gommon/log"
	"time"
)

const (
	MaxHeaderBytes = 1 << 20
	ReadTimeout    = 15 * time.Second
	WriteTimeout   = 15 * time.Second
)

type EchoConfig struct {
	Port                string   `mapstructure:"port" validate:"required"`
	Development         bool     `mapstructure:"development"`
	BasePath            string   `mapstructure:"basePath" validate:"required"`
	DebugErrorsResponse bool     `mapstructure:"debugErrorsResponse"`
	IgnoreLogUrls       []string `mapstructure:"ignoreLogUrls"`
	Timeout             int      `mapstructure:"timeout"`
	Host                string   `mapstructure:"host"`
}

func NewEchoServer() *echo.Echo {
	e := echo.New()
	return e
}

// RunHttpServer - запустить наш HTTP-сервер
func RunHttpServer(ctx context.Context, echo *echo.Echo, cfg *EchoConfig) error {
	echo.Server.ReadTimeout = ReadTimeout
	echo.Server.WriteTimeout = WriteTimeout
	echo.Server.MaxHeaderBytes = MaxHeaderBytes

	go func() {
		for {
			select {
			case <-ctx.Done():
				log.Infof("Сервер завершает свою работу. HTTP POST: {%s}", cfg.Port)
				err := echo.Shutdown(ctx)
				if err != nil {
					log.Errorf("(ОТКЛЮЧЕНИЕ СЕРВЕРА) ошибка: {%v}", err)
					return
				}
				return
			}
		}
	}()

	err := echo.Start(cfg.Port)

	return err
}

This code contains the configuration of our server and the function for direct launch.

We also need to create a file context_provider.go in the catalog httpwhich will stop the server and cancel operations. The equivalent in .NET is CancellationToken.

package http

import (
	"context"
	"github.com/labstack/gommon/log"
	"os"
	"os/signal"
	"syscall"
)

// NewContext - создать новый контекст приложения. Context - является аналогом CancellationToken
func NewContext() context.Context {
	ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM, syscall.SIGINT)
	go func() {
		for {
			select {
			case <-ctx.Done():
				log.Info("context is canceled!")
				cancel()
				return
			}
		}
	}()
	return ctx
}

How will it look like:

Adding application configuration

Next, to configure our service, we need to load the main settings from the configuration files in JSON format.

The library works with the Viper configuration, so let's first connect it to the project.

go get "github.com/spf13/viper"

Now we create a folder in the root of the project config with files config.go , config.development.json

Contents of config.go

package config

import (
	"fmt"
	"github.com/pkg/errors"
	"github.com/spf13/viper"
	echoserver "go-template-microservice-v2/pkg/http/server"
	"os"
)

type Config struct {
	ServiceName string                 `mapstructure:"serviceName"`
	Echo        *echoserver.EchoConfig `mapstructure:"echo"`
}

func NewConfig() (*Config, *echoserver.EchoConfig, error) {
	env := os.Getenv("APP_ENV")
	if env == "" {
		env = "development"
	}

	cfg := &Config{}

	viper.SetConfigName(fmt.Sprintf("config.%s", env))
	viper.AddConfigPath("./config/")
	viper.SetConfigType("json")

	if err := viper.ReadInConfig(); err != nil {
		return nil, nil, errors.Wrap(err, "viper.ReadInConfig")
	}

	if err := viper.Unmarshal(cfg); err != nil {
		return nil, nil, errors.Wrap(err, "viper.Unmarshal")
	}

	return cfg, cfg.Echo, nil
}

Contents of config.development.json

{
  "serviceName": "book_service",
  "deliveryType": "http",
  "context": {
    "timeout": 20
  },
  "echo": {
    "port": ":5000",
    "development": true,
    "timeout": 30,
    "basePath": "/api/v1",
    "host": "http://localhost",
    "debugHeaders": true,
    "httpClientDebug": true,
    "debugErrorsResponse": true,
    "ignoreLogUrls": [
      "metrics"
    ]
  }
}
what will it look like in structure

what will it look like in structure

Now we return to main.go to connect our configuration to DI:

package main

import (
	"go-template-microservice-v2/config"
	"go-template-microservice-v2/pkg/http"
	echoserver "go-template-microservice-v2/pkg/http/server"
	"go.uber.org/fx"
)

func main() {
	fx.New(
		fx.Options(
			fx.Provide(
				config.NewConfig,
				http.NewContext,
				echoserver.NewEchoServer,
			),
		),
	).Run()
}

Server Tuning

Now, to implement the first part of our microservice, we need to configure a common server file in which echo_server will be launched and, in the future, a worker that will send new books from the warehouse via kafka.

The first part of our implementation

The first part of our implementation

So we go and create a directory in the root folder server with file server.go

package server

import (
	"context"
	"github.com/labstack/echo/v4"
	"github.com/pkg/errors"
	"go-template-microservice-v2/config"
	echoserver "go-template-microservice-v2/pkg/http/server"
	"go.uber.org/fx"
	"log"
	"net/http"
)

// RunServers - запустить все сервера
func RunServers(lc fx.Lifecycle, ctx context.Context, e *echo.Echo, cfg *config.Config) error {
	lc.Append(fx.Hook{
		OnStart: func(_ context.Context) error {
			log.Println("Starting server")

			// Запустить HTTP - сервер
			go func() {
				if err := echoserver.RunHttpServer(ctx, e, cfg.Echo); !errors.Is(err, http.ErrServerClosed) {
					log.Fatalf("error running http server: %v", err)
				}
			}()

			e.GET("/", func(c echo.Context) error {
				return c.String(http.StatusOK, cfg.ServiceName)
			})

			return nil
		},
		OnStop: func(_ context.Context) error {
			log.Println("all servers shutdown gracefully...")
			return nil
		},
	})

	return nil
}
what does the catalog look like

what does the catalog look like

Also for the future, install the package “github.com/go-playground/validator”

go get "github.com/go-playground/validator"

And we return to main.go to connect our server.

package main

import (
	"github.com/go-playground/validator"
	"go-template-microservice-v2/config"
	"go-template-microservice-v2/pkg/http"
	echoserver "go-template-microservice-v2/pkg/http/server"
	"go-template-microservice-v2/server"
	"go.uber.org/fx"
)

func main() {
	fx.New(
		fx.Options(
			fx.Provide(
				config.NewConfig,
				http.NewContext,
				echoserver.NewEchoServer,
				validator.New,
			),
			fx.Invoke(server.RunServers),
		),
	).Run()
}

Let's launch and check.

Our server has started, now we can start implementing our scheme, but first we will connect the database.

Database connection and configuration

First, connect the library for working with GUIDs and also download ORM gorm and additional drivers for connecting postgresql.

go get "github.com/satori/go.uuid"
go get "github.com/cenkalti/backoff/v4"
go get "github.com/uptrace/bun/driver/pgdriver"
go get "gorm.io/driver/postgres"
go get "gorm.io/gorm"

Let's go to the catalog pkg in which we create a folder gorm_pg since together with ORM we will use the postgresql database, and in this directory we create a file pg_gorm.go

The file itself with the connection settings and migration rollout will look like this.

package gormpg

import (
	"database/sql"
	"fmt"
	"github.com/cenkalti/backoff/v4"
	"github.com/pkg/errors"
	"github.com/uptrace/bun/driver/pgdriver"
	gorm_postgres "gorm.io/driver/postgres"
	"gorm.io/gorm"
	"time"
)

// PgConfig - конфигурация для соединения с Postgresql
type PgConfig struct {
	Host     string `mapstructure:"host"`
	Port     int    `mapstructure:"port"`
	User     string `mapstructure:"user"`
	DBName   string `mapstructure:"dbName"`
	SSLMode  bool   `mapstructure:"sslMode"`
	Password string `mapstructure:"password"`
}

// PgGorm - модель базы данных
type PgGorm struct {
	DB     *gorm.DB
	Config *PgConfig
}

func NewPgGorm(config *PgConfig) (*PgGorm, error) {
	err := createDatabaseIfNotExists(config)

	if err != nil {
		panic(err)
		return nil, err
	}

	connectionString := getConnectionString(config, config.DBName)

	bo := backoff.NewExponentialBackOff()
	bo.MaxElapsedTime = 10 * time.Second
	maxRetries := 5

	var gormDb *gorm.DB

	err = backoff.Retry(func() error {
		gormDb, err = gorm.Open(gorm_postgres.Open(connectionString), &gorm.Config{})

		if err != nil {
			return errors.Errorf("failed to connect postgres: %v and connection information: %s", err, connectionString)
		}

		return nil
	}, backoff.WithMaxRetries(bo, uint64(maxRetries-1)))

	return &PgGorm{DB: gormDb, Config: config}, err
}

func Migrate(gorm *gorm.DB, types ...interface{}) error {

	for _, t := range types {
		err := gorm.AutoMigrate
		if err != nil {
			return err
		}
	}
	return nil
}

func createDatabaseIfNotExists(config *PgConfig) error {

	connectionString := fmt.Sprintf("postgres://%s:%s@%s:%d/%s?sslmode=disable",
		config.User,
		config.Password,
		config.Host,
		config.Port,
		"postgres",
	)

	pgSqlDb := sql.OpenDB(pgdriver.NewConnector(pgdriver.WithDSN(connectionString)))

	var exists int

	selectDbQueryString := fmt.Sprintf("SELECT 1 FROM  pg_catalog.pg_database WHERE datname="%s"", config.DBName)

	rows, err := pgSqlDb.Query(selectDbQueryString)
	if err != nil {
		return err
	}

	if rows.Next() {
		err = rows.Scan(&exists)
		if err != nil {
			return err
		}
	}

	if exists == 1 {
		return nil
	}

	createDbQueryString := fmt.Sprintf("CREATE DATABASE %s", config.DBName)

	_, err = pgSqlDb.Exec(createDbQueryString)
	if err != nil {
		return err
	}

	defer func(pgSqlDb *sql.DB) {
		err := pgSqlDb.Close()
		if err != nil {
			panic(err)
		}
	}(pgSqlDb)

	return nil
}

func getConnectionString(config *PgConfig, dbName string) string {
	return fmt.Sprintf("host=%s port=%d user=%s dbname=%s password=%s",
		config.Host,
		config.Port,
		config.User,
		dbName,
		config.Password,
	)
}
How it will look in the structure

How it will look in the structure

Now let's correct our configuration files taking into account what has been added.

package config

import (
	"fmt"
	"github.com/pkg/errors"
	"github.com/spf13/viper"
	gormpg "go-template-microservice-v2/pkg/gorm_pg"
	echoserver "go-template-microservice-v2/pkg/http/server"
	"os"
)

type Config struct {
	ServiceName string                 `mapstructure:"serviceName"`
	Echo        *echoserver.EchoConfig `mapstructure:"echo"`
	PgConfig    *gormpg.PgConfig       `mapstructure:"pgConfig"`
}

func NewConfig() (*Config, *echoserver.EchoConfig, *gormpg.PgConfig, error) {
	env := os.Getenv("APP_ENV")
	if env == "" {
		env = "development"
	}

	cfg := &Config{}

	viper.SetConfigName(fmt.Sprintf("config.%s", env))
	viper.AddConfigPath("./config/")
	viper.SetConfigType("json")

	if err := viper.ReadInConfig(); err != nil {
		return nil, nil, nil, errors.Wrap(err, "viper.ReadInConfig")
	}

	if err := viper.Unmarshal(cfg); err != nil {
		return nil, nil, nil, errors.Wrap(err, "viper.Unmarshal")
	}

	return cfg, cfg.Echo, cfg.PgConfig, nil
}
{
  "serviceName": "book_service",
  "deliveryType": "http",
  "context": {
    "timeout": 20
  },
  "echo": {
    "port": ":5000",
    "development": true,
    "timeout": 30,
    "basePath": "/api/v1",
    "host": "http://localhost",
    "debugHeaders": true,
    "httpClientDebug": true,
    "debugErrorsResponse": true,
    "ignoreLogUrls": [
      "metrics"
    ]
  },
  "PgConfig": {
    "Host": "localhost",
    "Port": 5432,
    "User": "tgbotchecker",
    "DbName": "tgbotchecker",
    "SSLMode": false,
    "Password": "tgbotchecker"
  }
}

Next we connect dependencies in main.go

package main

import (
	"github.com/go-playground/validator"
	"go-template-microservice-v2/config"
	gormpg "go-template-microservice-v2/pkg/gorm_pg"
	"go-template-microservice-v2/pkg/http"
	echoserver "go-template-microservice-v2/pkg/http/server"
	"go-template-microservice-v2/server"
	"go.uber.org/fx"
)

func main() {
	fx.New(
		fx.Options(
			fx.Provide(
				config.NewConfig,
				http.NewContext,
				gormpg.NewPgGorm,
				echoserver.NewEchoServer,
				validator.New,
			),
			fx.Invoke(server.RunServers),
		),
	).Run()
}

To raise our database in Docker, we will create a folder in the root directory deployments in which we will create docker-compose.ymlwhich we can call using the command in the console from the directory with the docker-compose up file.

version: "3.9"
services:
  postgres:
    image: postgres
    environment:
      POSTGRES_DB: "tgbotchecker"
      POSTGRES_USER: "tgbotchecker"
      POSTGRES_PASSWORD: "tgbotchecker"
    ports:
      - "5432:5432"
    volumes:
      - ./data:/var/lib/postgresql/data

Let's create DB entities and repositories

Let's go to the root directory and create a folder internal in which we will create a directory data and we will create a directory in it entities and create a file in it book_entity.go

package entities

import (
	uuid "github.com/satori/go.uuid"
)

// BookEntity model
type BookEntity struct {
	Id      uuid.UUID `json:"id" gorm:"primaryKey"`
	Name    string    `json:"name"`
	Author  string    `json:"author"`
	Price   float64   `json:"price"`
	Enabled bool      `json:"enabled"`
}

// CreateBookEntity создать модель
func CreateBookEntity(name string, author string, price float64) BookEntity {
	return BookEntity{
		Name:    name,
		Author:  author,
		Price:   price,
		Id:      uuid.NewV4(),
		Enabled: true,
	}
}

Now, to create migrations in the main.go file, we need to register our entity.

package main

import (
	"github.com/go-playground/validator"
	"go-template-microservice-v2/config"
	"go-template-microservice-v2/internal/data/entities"
	gormpg "go-template-microservice-v2/pkg/gorm_pg"
	"go-template-microservice-v2/pkg/http"
	echoserver "go-template-microservice-v2/pkg/http/server"
	"go-template-microservice-v2/server"
	"go.uber.org/fx"
)

func main() {
	fx.New(
		fx.Options(
			fx.Provide(
				config.NewConfig,
				http.NewContext,
				gormpg.NewPgGorm,
				echoserver.NewEchoServer,
				validator.New,
			),
			fx.Invoke(server.RunServers),
			fx.Invoke(
				func(sql *gormpg.PgGorm) error {
					return gormpg.Migrate(sql.DB, &entities.BookEntity{})
				}),
		),
	).Run()
}

Now in the folder data let's create 2 directories contracts And repositories. In the catalog contracts there will be an abstraction interface for our repository book_repository.go and in the catalog repository there will be a direct implementation for postgresql pg_book_repository.go

package contracts

import (
	uuid "github.com/satori/go.uuid"
	"go-template-microservice-v2/internal/data/entities"
)

type IBookRepository interface {
	AddBook(bookEntity entities.BookEntity) error
	GetBook(id uuid.UUID) (*entities.BookEntity, error)
	GetAllBook() ([]*entities.BookEntity, error)
	UpdateBook(bookEntity entities.BookEntity) error
}
package repositories

import (
	"fmt"
	"github.com/pkg/errors"
	uuid "github.com/satori/go.uuid"
	"go-template-microservice-v2/internal/data/contracts"
	"go-template-microservice-v2/internal/data/entities"
	gormpg "go-template-microservice-v2/pkg/gorm_pg"
)

type PgBookRepository struct {
	PgGorm *gormpg.PgGorm
}

func NewPgBookRepository(pgGorm *gormpg.PgGorm) contracts.IBookRepository {
	return &PgBookRepository{PgGorm: pgGorm}
}

func (p PgBookRepository) AddBook(bookEntity entities.BookEntity) error {
	err := p.PgGorm.DB.Create(bookEntity).Error
	if err != nil {
		return errors.Wrap(err, "error in the inserting book into the database.")
	}

	return nil
}

func (p PgBookRepository) GetBook(id uuid.UUID) (*entities.BookEntity, error) {
	var book entities.BookEntity

	if err := p.PgGorm.DB.First(&book, id).Error; err != nil {
		return nil, errors.Wrap(err, fmt.Sprintf("can't find the book with id %s into the database.", id))
	}

	return &book, nil
}

func (p PgBookRepository) GetAllBook() ([]*entities.BookEntity, error) {
	var books []*entities.BookEntity

	if err := p.PgGorm.DB.Find(&books).Error; err != nil {
		return nil, errors.Wrap(err, fmt.Sprintf("can't find the books into the database."))
	}

	return books, nil
}

func (p PgBookRepository) UpdateBook(bookEntity entities.BookEntity) error {
	err := p.PgGorm.DB.Save(bookEntity).Error
	if err != nil {
		return errors.Wrap(err, "error in the inserting book into the database.")
	}

	return nil
}
structure

structure

Now let's register our repository in DI in main.go

package main

import (
	"github.com/go-playground/validator"
	"go-template-microservice-v2/config"
	"go-template-microservice-v2/internal/data/entities"
	"go-template-microservice-v2/internal/data/repositories"
	gormpg "go-template-microservice-v2/pkg/gorm_pg"
	"go-template-microservice-v2/pkg/http"
	echoserver "go-template-microservice-v2/pkg/http/server"
	"go-template-microservice-v2/server"
	"go.uber.org/fx"
)

func main() {
	fx.New(
		fx.Options(
			fx.Provide(
				config.NewConfig,
				http.NewContext,
				gormpg.NewPgGorm,
				repositories.NewPgBookRepository,
				echoserver.NewEchoServer,
				validator.New,
			),
			fx.Invoke(server.RunServers),
			fx.Invoke(
				func(sql *gormpg.PgGorm) error {
					return gormpg.Migrate(sql.DB, &entities.BookEntity{})
				}),
		),
	).Run()
}

Implementation of mediator commands

We are almost close to implementing the controller, there is only one last step left – to describe the commands that will play the role of services in our application.

go get "github.com/mehdihadeli/go-mediatr"

The commands in our application will also act as contracts for requests and responses.

For our implementation we will need the following commands: adding a new book, buying a book, and a request to get a list of all books.

So let's go to the catalog internal and create a catalog features in which we create 3 directories add_book , buy_book , get_all_books .

First, we will work with the catalog add_book . In which we will create a directory commands in which we will create files: add_book_command.go , add_book_handler.go , add_book_response.go.

package commands

// AddBookCommand - модель добавления книги в каталог
type AddBookCommand struct {
	Name   string  `json:"name"   validate:"required"`
	Author string  `json:"author" validate:"required"`
	Price  float64 `json:"price"  validate:"required"`
}
package commands

import (
	"context"
	"go-template-microservice-v2/internal/data/contracts"
	"go-template-microservice-v2/internal/data/entities"
)

// AddBookHandler - хендлер для команды AddUserRequestCommand
type AddBookHandler struct {
	Repository contracts.IBookRepository
	Ctx        context.Context
}

// NewAddBookHandler - DI
func NewAddBookHandler(
	repository contracts.IBookRepository,
	ctx context.Context) *AddBookHandler {
	return &AddBookHandler{Repository: repository, Ctx: ctx}
}

// Handle - выполнить
func (handler *AddBookHandler) Handle(ctx context.Context, command *AddBookCommand) (*AddBookResponse, error) {
	bookEntity := entities.CreateBookEntity(
		command.Name,
		command.Author,
		command.Price)

	err := handler.Repository.AddBook(bookEntity)
	if err != nil {
		return nil, err
	}

	return &AddBookResponse{BookId: bookEntity.Id}, nil
}
package commands

import uuid "github.com/satori/go.uuid"

type AddBookResponse struct {
	BookId uuid.UUID `json:"book_id"`
}

Now let's create an analogue of our controller in the catalog add_book let's make a catalog endpoints in which we will create a file add_book_endpoints

package endpoints

import (
	"context"
	"github.com/go-playground/validator"
	"github.com/labstack/echo/v4"
	"github.com/labstack/gommon/log"
	"github.com/mehdihadeli/go-mediatr"
	"github.com/pkg/errors"
	"go-template-microservice-v2/internal/features/add_book/commands"
	"net/http"
)

// MapRoute - настройка маршрутизации
func MapRoute(validator *validator.Validate, echo *echo.Echo, ctx context.Context) {
	group := echo.Group("/api/v1/books")
	group.POST("", addBook(validator, ctx))
}

// AddBook
// @Tags        Book
// @Summary     Add Book
// @Description Add new Book in catalogue
// @Accept      json
// @Produce     json
// @Param       AddBookCommand body commands.AddBookCommand true "Book data"
// @Success     200  {object} commands.AddBookResponse
// @Security -
// @Router      /api/v1/books [post]
func addBook(validator *validator.Validate, ctx context.Context) echo.HandlerFunc {
	return func(c echo.Context) error {
		request := &commands.AddBookCommand{}

		if err := c.Bind(request); err != nil {
			badRequestErr := errors.Wrap(err, "[addBookEndpoint_handler.Bind] error in the binding request")
			log.Error(badRequestErr)
			return echo.NewHTTPError(http.StatusBadRequest, err)
		}

		if err := validator.StructCtx(ctx, request); err != nil {
			validationErr := errors.Wrap(err, "[addBook_handler.StructCtx] command validation failed")
			log.Error(validationErr)
			return echo.NewHTTPError(http.StatusBadRequest, err)
		}

		result, err := mediatr.Send[*commands.AddBookCommand, *commands.AddBookResponse](ctx, request)

		if err != nil {
			log.Errorf("(Handle) id: {%s}, err: {%v}", request.Name, err)
			return echo.NewHTTPError(http.StatusBadRequest, err)
		}

		log.Infof("(auto added) id: {%s}", result.BookId)
		return c.JSON(http.StatusCreated, result)
	}
}

Let's move on to the next directory get_all_books and create a directory in it queries in which we create get_all_books_query , get_all_books_handler , get_all_books_response

package queries

type GetAllBooksQuery struct{}
package queries

import (
	"context"
	"go-template-microservice-v2/internal/data/contracts"
)

type GetAllBooksHandler struct {
	Repository contracts.IBookRepository
	Ctx        context.Context
}

// NewGetAllBooksHandler - DI
func NewGetAllBooksHandler(
	repository contracts.IBookRepository,
	ctx context.Context) *GetAllBooksHandler {
	return &GetAllBooksHandler{Repository: repository, Ctx: ctx}
}

// Handle - выполнить
func (handler *GetAllBooksHandler) Handle(ctx context.Context, command *GetAllBooksQuery) (*GetAllBooksResponse, error) {
	getAllBooksResponse := &GetAllBooksResponse{
		Books: make([]GetAllBooksResponseItem, 0),
	}

	result, err := handler.Repository.GetAllBook()
	if err != nil {
		return nil, err
	}

	for _, element := range result {
		getAllBooksResponse.Books = append(getAllBooksResponse.Books, GetAllBooksResponseItem{
			Id:      element.Id,
			Name:    element.Name,
			Author:  element.Author,
			Price:   element.Price,
			Enabled: element.Enabled,
		})
	}

	return getAllBooksResponse, nil
}
package queries

import uuid "github.com/satori/go.uuid"

type GetAllBooksResponse struct {
	Books []GetAllBooksResponseItem `json:"books,omitempty"`
}

type GetAllBooksResponseItem struct {
	Id      uuid.UUID `json:"id"`
	Name    string    `json:"name"`
	Author  string    `json:"author"`
	Price   float64   `json:"price"`
	Enabled bool      `json:"enabled"`
}

And now, by analogy, we will create a catalog endpoints with file get_all_books_endpoints.go

package endpoints

import (
	"context"
	"github.com/go-playground/validator"
	"github.com/labstack/echo/v4"
	"github.com/labstack/gommon/log"
	"github.com/mehdihadeli/go-mediatr"
	"go-template-microservice-v2/internal/features/get_all_books/queries"
	"net/http"
)

// MapRoute - настройка маршрутизации
func MapRoute(validator *validator.Validate, echo *echo.Echo, ctx context.Context) {
	group := echo.Group("/api/v1/books")
	group.GET("", getAllBooks(validator, ctx))
}

// AddBook
// @Tags        Book
// @Summary     Get All Books
// @Description Get All Books from catalogue
// @Accept      json
// @Produce     json
// @Param       GetAllBooksQuery body queries.GetAllBooksQuery true "Book data"
// @Success     200  {object} queries.GetAllBooksResponse
// @Security -
// @Router      /api/v1/books [get]
func getAllBooks(validator *validator.Validate, ctx context.Context) echo.HandlerFunc {
	return func(c echo.Context) error {
		query := queries.GetAllBooksQuery{}

		result, err := mediatr.Send[*queries.GetAllBooksQuery, *queries.GetAllBooksResponse](ctx, &query)

		if err != nil {
			log.Errorf("(Handle) err: {%v}", err)
			return echo.NewHTTPError(http.StatusBadRequest, err)
		}
		
		return c.JSON(http.StatusCreated, result)
	}
}

All that remains is to implement the last command and we can begin registering mediators.

Let's move on to the next directory buy_book and create a directory in it commands in which we create buy_book_commands, buy_book_handler, buy_book_response.

package commands

import uuid "github.com/satori/go.uuid"

// BuyBookCommand - модель добавления книги в каталог
type BuyBookCommand struct {
	BookId uuid.UUID `json:"BookId"   validate:"required"`
}
package commands

import (
	"context"
	"go-template-microservice-v2/internal/data/contracts"
)

// BuyBookHandler - хендлер для команды AddUserRequestCommand
type BuyBookHandler struct {
	Repository contracts.IBookRepository
	Ctx        context.Context
}

// NewBuyBookHandler - DI
func NewBuyBookHandler(
	repository contracts.IBookRepository,
	ctx context.Context) *BuyBookHandler {
	return &BuyBookHandler{Repository: repository, Ctx: ctx}
}

// Handle - выполнить
func (handler *BuyBookHandler) Handle(ctx context.Context, command *BuyBookCommand) (*BuyBookResponse, error) {
	book, err := handler.Repository.GetBook(command.BookId)

	if err != nil {
		return nil, err
	}

	book.Enabled = false

	err = handler.Repository.UpdateBook(*book)
	if err != nil {
		return nil, err
	}

	return &BuyBookResponse{Result: book.Enabled}, nil
}
package commands

type BuyBookResponse struct {
	Result bool `json:"result"`
}
package endpoints

import (
	"context"
	"github.com/go-playground/validator"
	"github.com/labstack/echo/v4"
	"github.com/labstack/gommon/log"
	"github.com/mehdihadeli/go-mediatr"
	"github.com/pkg/errors"
	"go-template-microservice-v2/internal/features/buy_book/commands"
	"net/http"
)

// MapRoute - настройка маршрутизации
func MapRoute(validator *validator.Validate, echo *echo.Echo, ctx context.Context) {
	group := echo.Group("/api/v1/books/buy")
	group.POST("", buyBook(validator, ctx))
}

// AddBook
// @Tags        Book
// @Summary     Buy Book
// @Description Buy Book in catalogue
// @Accept      json
// @Produce     json
// @Param       BuyBookCommand body commands.BuyBookCommand true "Book data"
// @Success     200  {object} commands.BuyBookResponse
// @Security -
// @Router      /api/v1/books/buy [post]
func buyBook(validator *validator.Validate, ctx context.Context) echo.HandlerFunc {
	return func(c echo.Context) error {
		request := &commands.BuyBookCommand{}

		if err := c.Bind(request); err != nil {
			badRequestErr := errors.Wrap(err, "[addBookEndpoint_handler.Bind] error in the binding request")
			log.Error(badRequestErr)
			return echo.NewHTTPError(http.StatusBadRequest, err)
		}

		if err := validator.StructCtx(ctx, request); err != nil {
			validationErr := errors.Wrap(err, "[addBook_handler.StructCtx] command validation failed")
			log.Error(validationErr)
			return echo.NewHTTPError(http.StatusBadRequest, err)
		}

		result, err := mediatr.Send[*commands.BuyBookCommand, *commands.BuyBookResponse](ctx, request)

		if err != nil {
			log.Errorf("(Handle) err: {%v}", err)
			return echo.NewHTTPError(http.StatusBadRequest, err)
		}

		log.Infof("(auto added) id: {%s}", result.Result)
		return c.JSON(http.StatusCreated, result)
	}
}

Registration of routes and mediator commands.

To register routing and commands, the mediator must be in the directory internal create a directory configurations in which create 2 files endpoints_configurations And mediator_configurations .

package configurations

import (
	"context"
	"github.com/go-playground/validator"
	"github.com/labstack/echo/v4"
	addBookEndpoints "go-template-microservice-v2/internal/features/add_book/endpoints"
	buyBookEndpoints "go-template-microservice-v2/internal/features/buy_book/endpoints"
	getAllBooksEndpoints "go-template-microservice-v2/internal/features/get_all_books/endpoints"
)

// ConfigEndpoints - конфигурирование ендпоинтов нашего API
func ConfigEndpoints(validator *validator.Validate, echo *echo.Echo, ctx context.Context) {
	addBookEndpoints.MapRoute(validator, echo, ctx)
	buyBookEndpoints.MapRoute(validator, echo, ctx)
	getAllBooksEndpoints.MapRoute(validator, echo, ctx)
}
package configurations

import (
	"context"
	"github.com/mehdihadeli/go-mediatr"
	"go-template-microservice-v2/internal/data/contracts"
	addBookCommand "go-template-microservice-v2/internal/features/add_book/commands"
	buyBookCommand "go-template-microservice-v2/internal/features/buy_book/commands"
	getAllBooksQueries "go-template-microservice-v2/internal/features/get_all_books/queries"
)

// ConfigMediator - DI
func ConfigMediator(
	ctx context.Context,
	repository contracts.IBookRepository) (err error) {

	err = mediatr.RegisterRequestHandler[
		*addBookCommand.AddBookCommand,
		*addBookCommand.AddBookResponse](addBookCommand.NewAddBookHandler(repository, ctx))

	err = mediatr.RegisterRequestHandler[
		*buyBookCommand.BuyBookCommand,
		*buyBookCommand.BuyBookResponse](buyBookCommand.NewBuyBookHandler(repository, ctx))

	err = mediatr.RegisterRequestHandler[
		*getAllBooksQueries.GetAllBooksQuery,
		*getAllBooksQueries.GetAllBooksResponse](getAllBooksQueries.NewGetAllBooksHandler(repository, ctx))

	if err != nil {
		return err
	}

	return nil
}

Now all that remains is to register all this in DI in the main.go file.

package main

import (
	"github.com/go-playground/validator"
	"go-template-microservice-v2/config"
	"go-template-microservice-v2/internal/configurations"
	"go-template-microservice-v2/internal/data/entities"
	"go-template-microservice-v2/internal/data/repositories"
	gormpg "go-template-microservice-v2/pkg/gorm_pg"
	"go-template-microservice-v2/pkg/http"
	echoserver "go-template-microservice-v2/pkg/http/server"
	"go-template-microservice-v2/server"
	"go.uber.org/fx"
)

func main() {
	fx.New(
		fx.Options(
			fx.Provide(
				config.NewConfig,
				http.NewContext,
				gormpg.NewPgGorm,
				repositories.NewPgBookRepository,
				echoserver.NewEchoServer,
				validator.New,
			),
			fx.Invoke(configurations.ConfigEndpoints),
			fx.Invoke(configurations.ConfigMediator),
			fx.Invoke(server.RunServers),
			fx.Invoke(
				func(sql *gormpg.PgGorm) error {
					return gormpg.Migrate(sql.DB, &entities.BookEntity{})
				}),
		),
	).Run()
}

Let's check that everything is up and running

We check in the database that the migration table has been created

Checking the request

Checking price validation

Checking if the book has been added

Great, we have a working microservice!

Conclusion

In this article, I decided to share and introduce the structure of the microservice project, where we had validation of input requests, contracts were replaced with commands and mediator requests, a database was connected, DI and configuration were set up, and endpoints were also set up as analogs of controllers.

Thanks for your attention! In the next article I plan to connect kafka and start writing component tests for this functionality, as well as connect swagger.

If you liked it, here is mine telegram channelThere I share article announcements, share my thoughts on various points in IT, and also talk about my study of Golang.

Repository link: https://github.com/ItWithMisha/go-template-microservice-v2

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *