Projects depend on packages, internal ones or from 3rd parties. In all cases, your architecture should dictate what it needs and not evolve around a package, otherwise changing the dependency with another package and mocking it in tests could take unnecessary effort and time.
While it’s very easy to include a package in any file you need and start using it, time will show it’s painful to spread dependencies all over. Someday you’ll want to change a package because it’s not maintained anymore or you discover security issues which lead to loss of trust, or maybe you just want to experiment with another package.
This is where abstraction comes in, helping to decouple an implementation in your project, to set the rules which a dependency must follow. Each package has its own API, thus you need to wrap it by your API.
To show an example, I’ve chosen to integrate a validation package into Echo framework. The validator package requires you to tag your structs with its validation rules.
type request struct { Name validate:"required"` Tag validate:"required,alpha"` Offset validate:"gte=0"` Limit validate:"gte=1,lte=30"` }
Tags are easy to be removed or changed, I don’t consider this level of coupling to be dangerous.
Then, you get a validator instance and validate your struct:
req := request{} v := validator.New() err := v.Struct(req) if errs, ok := err.(validator.ValidationErrors); ok { // iterate errs and get detailed information }
The above is not your API, it’s the package’s. And the error details are in a format you may not like. To make everything yours, from the API to the output, a simple package can wrap the 3rd party one and thus settle some important things:
- Define your own API through an interface, setting the coupling rules through your app
- Model the error details format
package validator import ( "strings" validate "github.com/go-playground/validator" ) // Validator offers struct validation type Validator interface { Validate(s interface{}) error } // Error provides validation detailed information type Error struct { Err error Fields []Field } // Error returns the string representation of the error validation error func (e *Error) Error() string { return e.Err.Error() } // Field representation of each struct field with validation error type Field struct { Name string `json:"name"` Error string `json:"error"` Value interface{} `json:"value"` } type validator struct { v *validate.Validate } func (v *validator) Validate(s interface{}) (err error) { if err = v.v.Struct(s); err == nil { return } if errs, ok := err.(validate.ValidationErrors); ok { fields := make([]Field, 0, len(errs)) for _, e := range errs { fields = append(fields, Field{ Name: field(e.Namespace()), Value: e.Value(), Error: translate(e), }) } return &Error{ Err: err, Fields: fields, } } return } // New returns a new Validator instance func New() Validator { return &validator{ v: validate.New(), } } func field(s string) (field string) { field = s if index := strings.Index(s, "."); index > -1 && index < len(s) { field = s[index+1:] } return strings.ToLower(field) } func translate(e validate.FieldError) string { translations := map[string]string{ "required": "Is empty.", "gte": "Must be equal to or greater than {param}.", "lt": "Must be lower than {param}.", "lte": "Must be lower than or equal to {param}.", "alpha": "Must contain only letters.", } if t, ok := translations[e.Tag()]; ok { return strings.ReplaceAll(t, "{param}", e.Param()) } return "invalid value" }
The validator is now yours, you set the rules:
type Validator interface { Validate(s interface{}) error }
The error response is structured as your app needs with the information which is helpful for you, based on an error type which you can check after a validation is performed.
type Error struct { Err error Fields []Field } func (e *Error) Error() string { return e.Err.Error() } type Field struct { Name string `json:"name"` Error string `json:"error"` Value interface{} `json:"value"` }
The Validate
method holds the highest level of coupling with the 3rd party dependency, using its API and translating its errors into yours.
And because output should be defined and predictable as well as the input is, a 100% coverage ensures you are in control:
package validator import ( "testing" "github.com/stretchr/testify/assert" ) func TestValidator_Validate(t *testing.T) { user := struct { Age int `validate:"gt=18"` Name string `validate:"alphanum"` }{ Age: 25, Name: "Doe", } v := New() err := v.Validate(user) assert.NoError(t, err) } func TestValidator_ValidateWithValidationErrors(t *testing.T) { user := struct { Age int `validate:"gte=18"` Name string `validate:"alphanum"` Height int `validate:"lte=130"` Profile struct { Description string `validate:"required"` } }{ Age: 7, Name: "John Doe", Height: 131, Profile: struct { Description string `validate:"required"` }{ Description: "", }, } v := New() err := v.Validate(user) assert.Error(t, err) assert.NotEmpty(t, err.Error()) fields := []Field{ { Name: "age", Value: 7, Error: "Must be equal to or greater than 18.", }, { Name: "name", Value: "John Doe", Error: "invalid value", }, { Name: "height", Value: 131, Error: "Must be lower than or equal to 130.", }, { Name: "description", Value: "", Error: "Is empty.", }, } assert.Equal(t, fields, err.(*Error).Fields) } func TestValidator_ValidateWithValidationFail(t *testing.T) { user := 0 v := New() err := v.Validate(user) assert.Error(t, err) } func TestValidator_ValidateWithUndefinedTag(t *testing.T) { user := struct { Age int `validate:"undefined tag"` }{ Age: 7, } defer func() { assert.NotEmpty(t, recover()) }() v := New() _ = v.Validate(user) }
Here you are an example of an app depending on your package. It validates user input and returns an error message adapted to your needs. Follow the comments for details.
package main import ( // The abstracted validator package "app/internal/validator" "net/http" "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" ) // A struct to bind the query string to type request struct { Name string `query:"name" validate:"required"` Tag string `query:"tag" validate:"required,alpha"` Offset int `query:"offset" validate:"gte=0"` Limit int `query:"limit" validate:"gte=1,lte=30"` } // Default error response type errorResponse struct { Error string `json:"error"` } // Validation error response type validationErrorResponse struct { *errorResponse Fields []validator.Field `json:"fields"` } // HTTP handler func users(c echo.Context) error { req := request{} if err := c.Bind(&req); err != nil { return err } if err := c.Validate(req); err != nil { return err } return c.JSON(http.StatusOK, req) } func main() { server := echo.New() server.Debug = false server.Use( middleware.Recover(), middleware.Logger(), middleware.RequestID(), ) // Get a validator instance v := validator.New() // and bind it to Echo. Data validation is a critical operation, // and this framework came up with a great solution of allowing you // to use any validator you like. server.Validator = v // Custom error handling server.HTTPErrorHandler = func(err error, c echo.Context) { status := http.StatusInternalServerError var response interface{} switch e := err.(type) { // Check if a validation error was returned by handler case *validator.Error: response = &validationErrorResponse{ errorResponse: &errorResponse{ Error: "Invalid input", }, Fields: e.Fields, } status = http.StatusBadRequest default: message := "Internal server error" if c.Echo().Debug { message = err.Error() } response = &errorResponse{ Error: message, } } err = echo.NewHTTPError(status, response).SetInternal(err) c.Echo().DefaultHTTPErrorHandler(err, c) } server.GET("/", users) server.Logger.Fatal(server.Start(":8877")) }