A Practical Methodology, HSM, Handler,Service,Model, for Golang Backend Development
Everybody is familiar with the widely adopted MVC (Model-View-Controller) pattern, which has been used for many years across various languages and frameworks. MVC has proven to be a practical pattern for organizing programs with user interfaces and multiple object models.
In this article, I would like to introduce a simple methodology or design pattern called HSM (Handler, Service, Model) or Golang backend development. HSM is similar to MVC but specifically tailored for pure backend development in the Go programming language (Golang).
The HSM pattern provides a clear and organized structure for developing backend applications using Golang. Let's explore its three key components:
In a nutshell
Handler
The Handler component in HSM serves as the entry point for incoming requests. It handles the routing and request/response handling. Handlers are responsible for receiving requests from clients and orchestrating the flow of data between the service layer and the external world. They act as a bridge between the client and the underlying application logic. For example, HTTP, gRPC, WebSocket, JSONRPC, XMLRPC, TCP, UDP etc.
Service
The Service component encapsulates the business logic of the application. It handles the processed input, translated from the handlers. Services are responsible for executing complex operations, interacting with databases or external APIs, and implementing the core functionality of the application. They are designed to be modular and reusable, promoting code maintainability and separation of concerns.
Model
The Model component represents the data structures and domain-specific entities used in the application. It includes the data access layer responsible for interacting with databases or other persistent storage. Models in HSM are designed to represent the application's data schema and provide methods for data retrieval, manipulation, and storage.
By adopting the HSM pattern in Golang backend development, you can achieve several benefits:
- Improved organization: HSM provides a clear separation of concerns, making it easier to understand and maintain the codebase. Each component has a distinct responsibility, making the codebase more modular and scalable.
- Testability: With the separation of concerns, each component can be tested independently, enabling comprehensive testing of the application's functionality. Mocking and stubbing can be utilized effectively to isolate components during testing.
- Code reusability: The modular design of HSM allows for the reuse of components across different parts of the application. Handlers, services, and models can be shared and composed to build new features or extend existing functionality, reducing duplication of code and promoting code reuse.
- Flexibility: The HSM pattern provides flexibility in terms of adapting to changing requirements. As the application evolves, components can be modified or added without affecting the entire codebase, making it easier to accommodate new features or adjust the existing behavior.
Generally speaking, when handling requests, they are often wrapped in different protocols and need to be interpreted by the program before the actual logic can be executed. This necessitates certain considerations:
- Separation of protocol-related request and response from the business logic.
- Processing of business logic involving multiple object models.
- Returning a response based on the data or error from the previous steps.
In the case of a backend that processes HTTP requests, a Golang program typically follows these steps:
For a backend which processes HTTP request, the Golang program will have to
- Translating the HTTP request into one or multiple inputs for services.
- Invoking one or multiple services to generate output and handle any errors.
- Processing the service output along with errors and returning an appropriate HTTP response.
One common mistake in Golang programs is writing business logic directly in the HTTP handler or around specific protocols. This approach leads to code that cannot be easily reused later on and is challenging to test efficiently. By introducing the concept of services, inputs, and outputs, complex logic can be organized sequentially, and data flow can be monitored within unit tests without relying on a mock HTTP server, using only a local database.
Let me illustrate the HSM methodology with an example.
Consider the scenario where we need to develop an HTTP server that accepts user requests to create a book and specify whether they liked it or not.
Declare models firstly.
type Book struct { gorm.Model Name string } type BookLike struct { gorm.Model BookID uint }
Create the Golang service.
type CreateBookInput struct { Name string Like bool } type CreateBookOutput struct { ID uint `json:"id"` } type ServiceInterface interface { CreateBook(context.Context, *CreateBookInput) (*CreateBookOutput, error) } type Service struct { DB *gorm.DB } func (s *Service) CreateBook(ctx context.Context, in *CreateBookInput) (*CreateBookOutput, error) { book := Book{ Name: in.Name, } err := s.DB.Transaction(func(tx *gorm.DB) error { err := tx.Create(&book).Error if err != nil { return err } if in.Like { err = tx.Create(&BookLike{BookID: book.ID}).Error if err != nil { return err } } return nil }) if err != nil { return nil, err } return &CreateBookOutput{ ID: book.ID, }, nil }
As we can observe, the service related to books, specifically the
CreateBook
method, handles interactions with both object models. However, it focuses solely on receiving input and generating an output with an error.After implementing the necessary service methods, we are now ready to invoke them from the HTTP handler.
type CreateBookRequest struct { Name string `json:"name"` Like bool `json:"like"` } type CreateBookResponse struct { ID uint `json:"id"` } type Server struct { Service ServiceInterface } func (s *Server) CreateBookHandler(w http.ResponseWriter, r *http.Request) { // Parse request. var req CreateBookRequest err := json.NewDecoder(r.Body).Decode(&req) if err != nil { w.WriteHeader(http.StatusBadRequest) return } // Call service with input and output. out, err := s.Service.CreateBook(r.Context(), &CreateBookInput{ Name: req.Name, Like: req.Like, }) if err != nil { w.WriteHeader(http.StatusInternalServerError) return } // Create response from output from last step. res := CreateBookResponse{ ID: out.ID, } // Write response. resJSON, _ := json.Marshal(&res) w.Write(resJSON) } // Create the server with service. func main() { // Open GORM database. db, _ := gorm.Open() // Create service with all clients it needs. svc := Service{DB: db} // Create the HTTP handler and serve it. srv := Server{Service: &svc} http.ListenAndServe(":8080", srv) }
This is a typical HTTP handler responsible for handling requests and generating responses. The structure of the HTTP request and response can be automatically generated using tools like oapi-gen or OpenAPI generator, based on your preferences.
Now, let's consider the deployment of this book service as a microservice on AWS Lambda. The resulting program would look like the following:
var svc ServiceInterface type CreateBookEvent struct { Name string `json:"name"` } func HandleLambdaRequest(ctx context.Context, evt CreateBookEvent) (string, error) { out, err := svc.CreateBook(ctx, &CreateBookInput{ Name: evt.Name, }) if err != nil { return "", err } outJSON, err := json.Marshal(out) return string(outJSON), err } func main() { db, _ := gorm.Open() svc = &Service{DB: db} lambda.Start(HandleLambdaRequest) }
Nearly the same right.
By organizing all the business logic into a concept called "service," we can easily encapsulate complex logic without coupling it to any specific protocol. This means that the book service we have developed can be fully reused. By adding the necessary glue code for a specific protocol or framework, the new program can be deployed instantly without altering the underlying logic. The service can also contain interfaces to other services, and the handler structure can include a list of services for cohesive functionality.
Now, let's consider another common pattern found in web development. However, most of these patterns fail to address a fundamental problem: how to efficiently handle complex business logic that involves multiple object models and write reusable code.
The straightforward solution to this challenge is the repository-based pattern, which is inspired by the classical Java DAO (Data Access Object) pattern. When dealing with numerous object models or when the program is in its early stages, writing and constantly changing duplicated interfaces can become time-consuming. The repository pattern aims to centralize single model access logic, not many or complicated.
However, in practice, bugs tend to reside within the business logic code rather than in the repository or database IO. Furthermore, repositories can be overly simplistic, and writing unit tests for them may be unnecessary at the early stages of development.
The more complex the repository code becomes, the more effort is required to access the objects themselves. In the context of the program, accessing object models is often the most critical part of the business logic. When objects are retrieved from the database, the actual computation begins immediately, and the results are written back to the database. As SQL queries become more intricate, or when multiple object models need to be handled within a transaction, or when database access optimization is necessary, the repository pattern can become a bottleneck within the program. This often leads to the repository classes being replaced by direct object access, which is where the HSM pattern comes into play.
This article aims to provide valuable insights for your Golang backend development by introducing the HSM pattern as a solution to the challenges of handling complex business logic efficiently.