Adeshina Hammed Hassan
21 Apr 2021
β’
5 min read
In this blog, we are going to learn about a very scalable approach and structure that can be adopted to write REST service in Go. First, it is important to note that, there are many design patter one can adopt while writing a REST service in Go. The pattern we are going to adopt in this blog has proved to be very scalable and absolutely maintainable.
TL;DR: Here is the repository (main branch): github.com/D-sense/go-scalable-rest-api-example
The REST service we are going to build is a Library System, and we want to keep the number of endpoints/resources minimal and simple; the API will cater to books, authors, and customers. Since the point of this blog is to focus on a scalable and maintainable structure of the service, we will not be implementing customersβ authentication and authorization in this blog (there will be another blog written entirely for that). With that said, let us dive in.
Letβs break our whole architecture into four phases:
Without wasting time, let us start with the implementation:
In this phase, we define the interface for features we need for authors, books, and customers. These features are simply the way we create, get, update, delete, or look for the specific record(s) in the database:
// In objects/author.go, we have:
type AuthorService interface {
Create(ctx context.Context, data *Author) error
Authors(ctx context.Context) ([]Author, error)
Author(ctx context.Context, id string) (Author, error)
FindAuthorByEmail(ctx context.Context, email string) (*Author, error)
}
// In objects/customer.go, we have:
type CustomerService interface {
Create(ctx context.Context, data Customer) error
Customers(ctx context.Context) ([]*Customer, error)
Customer(ctx context.Context, id string) (*Customer, error)
FindCustomerByEmail(ctx context.Context, email string) (*Customer, error)
}
// In objects/book.go, we have:
type BookService interface {
CreateBook(ctx context.Context, book Book) error
Books(ctx context.Context) ([]*Book, error)
Book(ctx context.Context, id string) (*Book, error)
Delete(ctx context.Context, id string) error
Update(ctx context.Context, data *Book) error
}
type Author struct {
// fields
}
type Book struct {
// fields
}
type Customer struct {
// fields
}
We should now have this structure below:
|ββ objects
βββ author.go.go
βββ customer.go
βββ book.go
// In database/postgres/author_service.go, we have:
func CreateAuthor(context.Context, data *Author) error {
// logic goes here
}
func Authors(context.Context) ([]Author, error) {
// logic goes here
}
func Author(context.Context, id string) (Author, error) {
// logic goes here
}
// In database/postgres/customer_service.go, we have:
func CreateCustomer(context.Context, data *Customer) error {
// logic goes here
}
func Customers(context.Context) ([]Customer, error) {
// logic goes here
}
func Customer(context.Context, id string) (Author, error) {
// logic goes here
}
// In database/postgres/book_service.go, we have:
func CreateBook(context.Context, data *Book) error {
// logic goes here
}
func Customers(context.Context) ([]Book, error) {
// logic goes here
}
func Customer(context.Context, id string) (Book, error) {
// logic goes here
}
func Delete(context.Context, id string) error {
// logic goes here
}
func Update(context.Context, data *Book) (Book, error ){
// logic goes here
}
We should now have this structure below: |ββ database βββ postgres βββ author_service.go βββ customer_service.go βββ book_service.go
In this phase, we define a handler for each of the defined features in Phase 2. Inside each handler, you may perform many things such as validation of data (before it is passed to the storage; we shall see this in a bit), logging, tracing, and more importantly, injecting and calling service(s) from within:
type Handler struct {
authorService objects.AuthorService
bookService objects.BookService
// add more services, such as Email Delivery service, Session Service, Third-parties services...as required.
}
// NewHandler is the Author handler constructor
func NewHandler(
authorService objects.AuthorService,
bookService objects.BookService,
) *Handler {
h := &Handler{
authorService: authorService,
bookService: bookService,
}
return h
}
// Example of Signup function can be defined as below:
func (h *Handler) SignUp(ctx *gin.Context, input *objects.RegistrationVM) (*objects.Author, error) {
// input validation should be handled first.
// Handle it yourself
//
pHashed, err := password.NewHashedPassword(input.Password)
if err != nil {
return nil, errors.Wrap(err, fmt.Sprintf("error hasshng a password"))
}
// do some checking such as if the email address already exists
// Handle it yourself
//
author := &objects.Author{
FullName: input.FullName,
Email: input.Email,
Password: pHashed,
}
// here we are calling upon the author database service
err = h.authorService.Create(ctx, author)
if err != nil {
return nil, errors.Wrap(err, fmt.Sprintf("error creating an author"))
}
return author, nil
}
We should now have this structure below:
|ββ handlers
βββ author
βββ handler.go
βββ customer
βββ handler.go
βββ book
βββ handler.go
At this phase, we will be passing the Handlers (the ones we created at Phase 3) to the Server (of course, the Server could be JSON or GraphQL). As mentioned earlier, the Server will serve the resources to the API consumer. In this tutorial, we will be using a JSON format to expose the resources.
We should now have this structure below: |ββ server βββ json βββ author.go βββ book.go βββ costumer.go
Now that we are done designing and developing our REST API using the Service Pattern architecture/approach, letβs think of the scalability and maintainability of the service. How do we scale and/maintain the application? Letβs say after the release, we decide to add new features because the business demands them! Because we have designed and crafted out our application in such a very maintainable approach, the task becomes seamless to achieve; simply go from phase 1 to phase 4.
Letβs assume we used an ORM package such as GORM in Phase 2 but now we have decided to re-write the implementation using pure SQL? As you can see, there is nothing stopping in our way; absolutely we do not need to tamper or modify any part of Phase 1, Phase 3, or Phase 4. We simply re-write Phase 2 (replace ORM implementation with pure SQL) and plug it back as it was and everything remains just fine.
Or in the case of Database, as we have been using Postgres, connecting to MySQL or any other database is as simple as modifying just our database connection function; nothing more.
In this article, we explored a clean and scalable approach to apply when writing an API (it could be REST or GraphQL by the way) using Service Pattern and other techniques. We also learned about a very safe approach of connecting to a database by avoiding all sorts of issues such as race condition, unsafe thread, multiple connections. In the episode, we will be looking at implementing authentication and authorization around it.
In the next blog, we are going to learn about crafting an efficient and secured database as a data source with extensive discussion on the GORM and Migrate packages.
I hope this was an interesting read for you as it was for me whilst writing it.
Keep Go-ing :)
Ground Floor, Verse Building, 18 Brunswick Place, London, N1 6DZ
108 E 16th Street, New York, NY 10003
Join over 111,000 others and get access to exclusive content, job opportunities and more!