Introduction
It is easy when we want to build an API in Go, especially using a well-known routing library (sometimes people called it framework) like echo/fiber.
package route
import (
"net/http"
"github.com/labstack/echo/v5"
)
// Oversimplify version on how to handle register requests
func HandleRegister(c echo.Context) error {
var payload struct {
Email string `json:"email"`
Username string `json:"username"`
Password string `json:"password"`
}
var body payload
if err := c.Bind(&body); err != nil {
return c.String(http.StatusBadRequest, "bad request")
}
response, err := businessLogic(c.Request().Context(), body)
if err != nil {
return c.String(http.StatusInternalServerError, err.Error())
}
return c.JSON(http.StatusOK, response)
}
In this quick posts, we will see the problem that might comes and approach to solve it.
Problem
The code above is fine, nothing bad. But, sometimes you want to skip the serialization layer and focus directly on business logic. For example, you want to avoid repetitive code like this:
package route
import (
"net/http"
"github.com/labstack/echo/v5"
)
func HandleRegister(c echo.Context) error {
var payload struct {
Email string `json:"email"`
Name string `json:"username"`
Password string `json:"password"`
}
var body payload
if err := c.Bind(&body); err != nil {
return c.String(http.StatusBadRequest, "bad request")
}
// Handle business logic
}
func HandleLogin(c echo.Context) error {
var payload struct {
Email string `json:"email"`
Password string `json:"password"`
}
var body payload
if err := c.Bind(&body); err != nil {
return c.String(http.StatusBadRequest, "bad request")
}
// Handle business logic
}
func HandleLogout(c echo.Context) error {
var payload struct {
Token string `header:"Authorization"`
}
var body payload
if err := c.Bind(&body); err != nil {
return c.String(http.StatusBadRequest, "bad request")
}
// Handle business logic
}
func HandleForgotPassword(c echo.Context) error {
var payload struct {
Email string `json:"email"`
}
var body payload
if err := c.Bind(&body); err != nil {
return c.String(http.StatusBadRequest, "bad request")
}
// Handle business logic
}
Let’s see alternative approach to avoid this.
NOTE You might don’t want to use this. Be careful when you want to add some additional abstraction. Repetitive code is fine. Like wiseman said: A little copying is better than a little dependency.
Solution
Solution and the code will be shown and changed periodically according to the approach/thought that emerges.
First Attempt
On first attempt, we can check if request struct from business logic have some struct tag that indicates to be parsed.
package route
import (
"context"
"net/http"
"reflect"
"slices"
"github.com/labstack/echo/v5"
)
// NoParam indicates the request has no http body to parse.
type NoParam = struct{}
// BusinessLogic is a type that matches all business logic function in any places.
type BusinessLogic[Request, Response any] func(context.Context, Request) (Response, error)
func Handle[Req, Res any](businessLogic BusinessLogic[Req, Res]) echo.HandlerFunc {
skipParseBody := hasStructTags[Req]("json", "form", "xml", "header", "query")
return func(c echo.Context) error {
var (
request Req
err error
)
// if business logic uses route.NoParam, that means we dont need to parse into the request.
if !skipParseBody {
err = c.Bind(&request)
if err != nil {
// return error
}
}
ctx := c.Request().Context()
response, err := businessLogic(ctx, request)
if err != nil {
// handle error
}
return c.JSON(http.StatusOK, response)
}
}
// hasStructTags check if struct type contains at least one tag on its property.
func hasStructTags[Request any](tags ...string) bool {
for field := range slices.Values(reflect.VisibleFields(reflect.TypeFor[Request]())) {
for tag := range slices.Values(tags) {
structTag := field.Tag.Get(tag)
if structTag != "" {
return true
}
}
}
return false
}
Then, in main.go
or any entrypoint file in your program, you can use it like this:
package main
import (
"context"
"net/http"
"my-go-package/route"
"my-go-package/model"
"github.com/labstack/echo/v5"
)
type request struct {
Email string `json:"email"`
Name string `json:"username"`
Password string `json:"password"`
}
type response struct {
User model.User `json:"user"`
}
func register(ctx context.Context, req request) (response, error) {
// handle business logic
}
func main() {
e := echo.New()
e.GET(route.Handle(register))
}
Second Attempt: Put Dynamic HTTP Status Code in Response
If you want to add different http response, you can add additional parameter in route.Handle
function to accept statusCode
as parameter.
- func Handle[Req, Res any](businessLogic BusinessLogic[Req, Res]) echo.HandlerFunc {
+ func Handle[Req, Res any](businessLogic BusinessLogic[Req, Res], statusCode int) echo.HandlerFunc {
skipParseBody := hasStructTags[Req]("json", "form", "xml", "header")
return func(c echo.Context) error {
var (
request Req
err error
)
if !skipParseBody {
err = c.Bind(&request)
if err != nil {
// return error
}
}
ctx := c.Request().Context()
response, err := businessLogic(ctx, request)
if err != nil {
// handle error
}
- return c.JSON(http.StatusOK, response)
+ return c.JSON(statusCode, response)
}
}
package main
// code above is omitted for simplicity.
func main() {
e := echo.New()
- e.GET(route.Handle(register))
+ e.GET(route.Handle(register, 201))
}
Third Attempt: Put Dynamic HTTP Response for Error type
This one is so tricky and has many approach. We’ll go with easiest one. We will put custom struct that implements error
interface.
package route
+type ErrorResponse struct {
+ StatusCode int `json:"-"`
+ Message string `json:"message"`
+ // add any additional response you might want to add
+}
+func ErrorBadRequest(message string) Response {
+ var r ErrorResponse
+ r.StatusCode = 400
+ r.Message = message
+ return r
+}
+func ErrorInternal() Response {
+ var r ErrorResponse
+ r.StatusCode = 500
+ r.Message = "Internal Server Error"
+ return r
}
func Handle[Req, Res any](businessLogic BusinessLogic[Req, Res], statusCode int) echo.HandlerFunc {
skipParseBody := hasStructTags[Req]("json", "form", "xml", "header")
return func(c echo.Context) error {
var (
request Req
err error
)
if !skipParseBody {
err = c.Bind(&request)
if err != nil {
// return error
}
}
ctx := c.Request().Context()
response, err := businessLogic(ctx, request)
- if err != nil {
- // handle error
- }
+ if err == nil {
+ return c.JSON(statusCode, response)
+ }
+ errResponse, ok := err.(ErrResponse)
+ if !ok {
+ // we dont know what type is it.
+ // you might want to add logging right here to check the actual error result.
+ return c.JSON(500, ErrorInternal())
+ }
- return c.JSON(errResponse.StatusCode, errResponse)
}
}
In business logic function, we can use it like this:
package main
import (
"context"
"net/http"
"my-go-package/route"
"my-go-package/model"
)
type request struct {
Email string `json:"email"`
Name string `json:"username"`
Password string `json:"password"`
}
type response struct {
User model.User `json:"user"`
}
+func register(ctx context.Context, req request) (response, error) {
+ var w response
+ user, err := insertIntoDatabase(ctx, req.Email, req.Name, req.Password)
+ if err == nil {
+ w.User = user
+ return w, nil
+ }
+ if errors.Is(err, model.ErrDuplicateEmail) {
+ return w, route.ErrorBadRequest("email already exists")
+ }
+ return w, err
+}
- func register(ctx context.Context, req request) (response, error) {
- // handle business logic
- }
// code below is omitted for simplicity
What if your API does not return generic JSON response? Like application/octet-stream
for example? You can go back to traditional approach, or you can even create another layer route.Stream()
that mimicks route.Handle()
.
And there are so much you can add to route.Handle
. For example, you can add custom logging for every request and response that comes in to your API. Or you can add your own custom logic to route.Handler
. Just be careful, because you’re adding complexity in your codebase.
Conclusion
We can skip serialization layer in Go. But, ask yourself/your team before do this kind of abstraction, do you really need it? Because in the end, all we need is balance between readibility and abstraction. If you’re fine with it, go for it.