The Qiscus Integration team's Go project boilerplate is designed to speed up application development and ensure consistency across all integration projects. By providing a standardized structure and set of guidelines, this boilerplate makes it easier for developers to understand, review, and maintain projects. By using this boilerplate, developers can ensure that all integration projects follow a consistent structure and coding style, making it easier to onboard new team members and maintain projects in the long term.
This boilerplate is used to standardize the directory structure for projects of medium to large complexity or with the potential for it. However, for other cases that only handle one or a few processes, it is not necessary to implement this boilerplate in order to avoid over-abstraction. For example, you can use a single file like main.go or a flat architecture where all components such as handlers, services, and repositories are placed in a single directory for simplicity and ease of navigation. Here’s a repository that implements this kind of flat structure: Repo.
One significant change from the v1 is moving away from grouping code by function and, instead, organizing it by module. This approach offers several advantages:
- Single Responsibility Principle: One of the SOLID principles of object-oriented design, states that a class or module should have only one reason to change.
- Reusability: Modules become more reusable across different clients, promoting code sharing and reducing duplication.
- Loose Coupling, High Cohesion: Two words that describe how easy or difficult it is to change a piece of software. Grouping by module enforces loose coupling between different parts of the code while promoting high cohesion within each module.
- Faster Contribution: Developers can contribute to specific modules without causing collateral damage in unrelated areas, speeding up the development process.
- Ease of Understanding: The codebase becomes more accessible and understandable as it's organized around modules and use cases. A use case repository clarifies what each module does.
This section guides you through creating a new API module following the established patterns in this codebase.
This project follows "Clean" Architecture principles with these layers:
- Handler (
internal/{module}/handler.go
) - HTTP layer that handles requests/responses - Service (
internal/{module}/service.go
) - Business logic layer - Repository (
internal/{module}/repo.go
) - Data access layer - Entity (
internal/entity/{module}.go
) - Domain models
1. Create the Entity
Create your domain model in internal/entity/{module}.go
:
package entity
import "time"
type YourModule struct {
ID int64 `json:"id"`
Name string `json:"name" gorm:"index"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
2. Create the Repository
Create internal/{module}/repo.go
:
package yourmodule
import (
"context"
"integration-go/internal/entity"
"gorm.io/gorm"
)
type repo struct {
db *gorm.DB
}
func NewRepository(db *gorm.DB) *repo {
return &repo{db: db}
}
func (r *repo) Save(ctx context.Context, item *entity.YourModule) error {
return r.db.WithContext(ctx).Save(item).Error
}
func (r *repo) FindByID(ctx context.Context, id int64) (*entity.YourModule, error) {
var item entity.YourModule
err := r.db.WithContext(ctx).First(&item, id).Error
if err != nil {
return nil, err
}
return &item, nil
}
3. Create the Service
Create internal/{module}/service.go
:
package yourmodule
import (
"context"
"errors"
"fmt"
"integration-go/internal/entity"
"gorm.io/gorm"
)
//go:generate mockery --with-expecter --case snake --name Repository
type Repository interface {
Save(ctx context.Context, item *entity.YourModule) error
FindByID(ctx context.Context, id int64) (*entity.YourModule, error)
}
type Service struct {
repo Repository
}
func NewService(repo Repository) *Service {
return &Service{repo: repo}
}
func (s *Service) GetByID(ctx context.Context, id int64) (*entity.YourModule, error) {
item, err := s.repo.FindByID(ctx, id)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fmt.Errorf("item not found")
}
return nil, fmt.Errorf("failed to find item: %w", err)
}
return item, nil
}
func (s *Service) Create(ctx context.Context, req *CreateRequest) error {
item := &entity.YourModule{
Name: req.Name,
}
if err := s.repo.Save(ctx, item); err != nil {
return fmt.Errorf("failed to save item: %w", err)
}
return nil
}
type CreateRequest struct {
Name string `json:"name" validate:"required"`
}
4. Create the Handler
Create internal/{module}/handler.go
:
package yourmodule
import (
"encoding/json"
"integration-go/internal/api/resp"
"net/http"
"strconv"
"github.com/rs/zerolog/log"
)
type httpHandler struct {
svc *Service
}
func NewHttpHandler(svc *Service) *httpHandler {
return &httpHandler{svc: svc}
}
func (h *httpHandler) GetByID(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
resp.WriteJSONFromError(w, err)
return
}
item, err := h.svc.GetByID(ctx, int64(id))
if err != nil {
log.Ctx(ctx).Error().Msgf("failed to get item: %s", err.Error())
resp.WriteJSONFromError(w, err)
return
}
resp.WriteJSON(w, http.StatusOK, item)
}
func (h *httpHandler) Create(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var req CreateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
resp.WriteJSONFromError(w, err)
return
}
if err := h.svc.Create(ctx, &req); err != nil {
log.Ctx(ctx).Error().Msgf("failed to create item: %s", err.Error())
resp.WriteJSONFromError(w, err)
return
}
resp.WriteJSON(w, http.StatusCreated, "created")
}
5. Register Routes in Server
Add your module to internal/api/server.go
in the NewServer()
function:
// YourModule
yourModuleRepo := yourmodule.NewRepository(db)
yourModuleSvc := yourmodule.NewService(yourModuleRepo)
yourModuleHandler := yourmodule.NewHttpHandler(yourModuleSvc)
// Add routes
r.Handle("GET /api/v1/yourmodule/{id}", authMidd.StaticToken(http.HandlerFunc(yourModuleHandler.GetByID)))
r.Handle("POST /api/v1/yourmodule", authMidd.StaticToken(http.HandlerFunc(yourModuleHandler.Create)))
6. Add Database Migration (if needed)
If your entity needs database tables, add migration to internal/postgres/migrate.go
:
func Migrate(db *gorm.DB) {
db.AutoMigrate(
&entity.Room{},
&entity.YourModule{}, // Add your entity here
)
}
- Error Handling: Use
resp.WriteJSONFromError(w, err)
for consistent error responses - Logging: Use
log.Ctx(ctx).Error().Msgf()
for contextual logging - Validation: Use struct tags with
validate
for request validation - Database: Always use
WithContext(ctx)
for database operations - Mocking: Add
//go:generate mockery
comments for interfaces that need mocks
resp.WriteJSON(w, statusCode, data)
- Standard JSON responseresp.WriteJSONFromError(w, err)
- Error response with proper status codesresp.WriteJSONWithPaginate(w, statusCode, data, total, page, limit)
- Paginated response
You can find the documentation for this application here
Create tagging and storing room data in the database when a user initiates a chat on Qiscus Omnichannel by utilizing a new session webhook, besides that there is a cronjob that does an auto resolved room when the on going room has reached 10 minutes. And also create an API to get all rooms that are stored in the database with an API key authentication.
Set up the environment file by copying .env.example:
Mac/Linux:
cp .env.example .env
Windows:
copy .env.example .env
Alternatively, you can create a copy of .env.example
and rename it to .env
in your project's root directory
To run the project locally, follow these steps:
- Clone this repository
- Navigate to the directory
- Format code and tidy modfile:
make tidy
- Run test:
make test
, make sure that all tests are passing - Run the server:
make run bin=api
, or run the application with reloading on file changes with:make run/live bin=api
. You can also apply this to the cron application by changing the parameter tobin=cron
- The backend server will be accessible at
http://localhost:8080
- You can find another usefull commands in
Makefile
-
Install Mockery:
go install github.com/vektra/mockery/[email protected]
Note: In this repo, we use Mockery v2.
-
Add the following code in the interface code file:
//go:generate mockery --case snake --name XXXX
-
Run go generate using
make generate
client.Error
complies with Go standard error. which support Error, Unwrap, Is, As
// Sample using errors.As
err := s.omni.AssignAgent(ctx, agentID, roomID)
if err != nil {
var cerr *client.Error
if errors.As(err, &cerr) {
fmt.Println(cerr.Message) // General error message
fmt.Println(cerr.StatusCode) // HTTP status code e.g: 400, 401 etc.
fmt.Println(cerr.RawError) // Raw Go error object
fmt.Println(cerr.RawAPIResponse) // Raw API response body in byte
}
}