Learn gRPC, GraphQL and Kubernetes by building Microservices in Go: Part 2 - GraphQL BFF

Page content

Intro

This is the second post in a series about learning gRPC, GraphQL and Kubernetes by building Microservices in Go. Here is a list of posts in the series:

Full code is in here

We have implemented gRPC servers in part 1.

In part 2, we will develop a BFF server that reads from and writes to these gRPC servers and communicates with clients using GraphQL.

BFF directory structure:

tree .
.
├── bff // BFF talking to clients by GraphQL
│   ├── client
│   │   └── task_client.go
│   ├── cmd
│   │   └── server
│   │       └── main.go
│   ├── go.mod
│   ├── go.sum
│   ├── gqlgen.yml
│   ├── graph
│   │   ├── generated.go
│   │   ├── model
│   │   │   ├── models_gen.go
│   │   │   └── task.go
│   │   ├── resolver.go
│   │   ├── schema.graphqls
│   │   └── schema.resolvers.go
│   └── tools.go

BFF

Backend For Frontend (BFF) is an architectural pattern where a dedicated backend service is created for each frontend application or client type.

Its primary role is to act as an intermediary between the frontend and the backend services.

BFF aggregates data from multiple backend services and provides tailored APIs optimized for specific frontend requirements.

BFF decouples the frontend from the complexities of backend services. It allows frontend teams to evolve their applications independently without being tied to backend changes.

By reducing over-fetching and under-fetching of data, BFF improves performance and ensures a smoother user experience.

In our project, the BFF speaks to two backend services and provides APIs for frontend.

GraphQL

GraphQL is a query language for APIs and a runtime for executing those queries with existing data.

GraphQL enables clients to request only the data they need. Clients can specify the shape and structure of the response, avoiding unnecessary data retrieval.

Unlike traditional REST APIs, GraphQL provides a more flexible and efficient approach to data fetching. It allows clients to retrieve nested data in a single request.

Implementing BFF

We’ll use gqlgen to generate code automatically from our GraphQL schema.

First, add github.com/99designs/gqlgen to your project’s tools.go:

{PROJECT_ROOT}/bff

printf '// +build tools\npackage tools\nimport (_ "github.com/99designs/gqlgen"\n _ "github.com/99designs/gqlgen/graphql/introspection")' | gofmt > tools.go

go mod tidy

Next, initialize gqlgen config and generate models:

go run github.com/99designs/gqlgen init

go mod tidy

This will create a template for our GraphQL server.

Define Your GraphQL Schema

Now, let’s define our GraphQL schema in graph/schema.graphqls.

type Query {
  getTask(id: ID!): Task

  getTasksByTag(tag: String!): [Task]
}

type Mutation {
  createTask(input: NewTask!): Task!
}

type Task {
  Id: ID!
  Text: String!
  Tags: [String!]
  Attachments: [Attachment!]
}

input NewTask {
  Text: String!
  Tags: [String!]
  Attachments: [NewAttachment!]

}

scalar Time

type Attachment {
  Name: String!
  Date: Time
  Contents: String
}

input NewAttachment {
  Name: String!
  Date: Time
  Contents: String
}

I’ve introduced a new model named Attachment, which isn’t present in the microservices' model.

This addition is for demonstrating the benefit of GraphQL in fetching only necessary data.

Generate code from the schema:

go run github.com/99designs/gqlgen generate

Preventing over-fetching

Suppose we do not want to load the Attachment on the Task unless the user actually asked for it.

To archive that, we will add the custom Task model.

First let’s enable autobind, allowing gqlgen to use our custom models.

We can do this by uncommenting the autobind config line in gqlgen.yml:

# gqlgen will search for any type names in the schema in these go packages
# if they match it will use them, otherwise it will generate them.
autobind:
 - "bff/graph/model"

And add Task fields resolver config in gqlgen.yml to generate resolver for Attachment field:

# This section declares type mapping between the GraphQL and go type systems
#
# The first line in each type will be used as defaults for resolver arguments and
# modelgen, the others will be allowed when binding to fields. Configure them to
# your liking
models:
  ID:
    model:
      - github.com/99designs/gqlgen/graphql.ID
      - github.com/99designs/gqlgen/graphql.Int
      - github.com/99designs/gqlgen/graphql.Int64
      - github.com/99designs/gqlgen/graphql.Int32
  Int:
    model:
      - github.com/99designs/gqlgen/graphql.Int
      - github.com/99designs/gqlgen/graphql.Int64
      - github.com/99designs/gqlgen/graphql.Int32
  Task:
    fields:
      Attachments:
        resolver: true

Next, create a new file called graph/model/task.go

package model

type Task struct {
	ID   string   `json:"Id"`
	Text string   `json:"Text"`
	Tags []string `json:"Tags,omitempty"`
	Attachment *Attachment `json:"Attachment,omitempty"`
}

And run the following command to generate resolvers.

go run github.com/99designs/gqlgen generate

Now you can see Attachments resolver was implemented.

This will be called only when users asked for the Attachments field.

If assuming it fetches data from DB, it can reduce the unnecessary query to storage by implementing attachment resolver

// Attachments is the resolver for the Attachments field.
// If assuming it fetches data from DB, it can reduce the unnecessary query to storage by implementing attachment resolver
func (r *taskResolver) Attachments(ctx context.Context, obj *model.Task) ([]*model.Attachment, error) {
	panic(fmt.Errorf("not implemented"))
}

Implementing resolvers

We’ll implement clients and resolvers to interact with microservices and retrieve or create data.

This resolver will communicate with our backend services to fulfill GraphQL queries and mutations.

bff/client/task_client.go

package client

import (
	"net/http"
	"os"

	"connectrpc.com/connect"
	"github.com/moonorange/gomicroservice/protogo/gen/genconnect"
	"github.com/sirupsen/logrus"
)

var (
	queryClient   genconnect.TaskServiceClient
	commandClient genconnect.TaskServiceClient
)

func NewQueryServiceClient() genconnect.TaskServiceClient {
	queryHost := os.Getenv("QUERY_SERVICE_HOST")
	logrus.Info("queryHost: ", queryHost)
	if queryHost == "" {
		logrus.Fatal("empty QUERY_SERVICE_HOST")
	}
	// Set up a connection to the server.
	// Create a gRPC client using the connect.WithGRPC() option
	if queryClient != nil {
		return queryClient
	}
	queryClient = genconnect.NewTaskServiceClient(
		http.DefaultClient,
		"http://"+queryHost,
		connect.WithGRPC(),
	)

	return queryClient
}

func NewCommandServiceClient() genconnect.TaskServiceClient {
	commandHost := os.Getenv("COMMAND_SERVICE_HOST")
	logrus.Info("commandHost: ", commandHost)
	if commandHost == "" {
		logrus.Fatal("empty COMMAND_SERVICE_HOST")
	}
	if commandClient != nil {
		return commandClient
	}
	// Set up a connection to the server.
	// Create a gRPC client using the connect.WithGRPC() option
	commandClient = genconnect.NewTaskServiceClient(
		http.DefaultClient,
		"http://"+commandHost,
		connect.WithGRPC(),
	)

	return commandClient
}

bff/graph/schema.resolvers.go

var (
	queryClient   genconnect.TaskServiceClient
	commandClient genconnect.TaskServiceClient
)

func init() {
	queryClient = client.NewQueryServiceClient()
	commandClient = client.NewCommandServiceClient()
}

// CreateTask is the resolver for the createTask field.
func (r *mutationResolver) CreateTask(ctx context.Context, input model.NewTask) (*model.Task, error) {
	// Access to command service
	res, err := commandClient.CreateTask(ctx, connect.NewRequest(&gen.CreateTaskRequest{
		Text: "task1",
		Tags: []string{"tag1"},
	}))
	if err != nil {
		return nil, err
	}

	return &model.Task{
		ID:   string(res.Msg.Task.Id),
		Text: res.Msg.Task.Text,
		Tags: res.Msg.Task.Tags}, nil
}

// GetTask is the resolver for the getTask field.
func (r *queryResolver) GetTask(ctx context.Context, id string) (*model.Task, error) {
	// Access to query service
	res, err := queryClient.GetTask(ctx, connect.NewRequest(&gen.GetTaskRequest{
		TaskId: "1",
	}))
	if err != nil {
		return nil, err
	}
	return &model.Task{
		ID:   string(res.Msg.Task.Id),
		Text: res.Msg.Task.Text,
		Tags: res.Msg.Task.Tags}, nil
}

// GetTasksByTag is the resolver for the getTasksByTag field.
func (r *queryResolver) GetTasksByTag(ctx context.Context, tag string) ([]*model.Task, error) {
	// Access to query service
	res, err := queryClient.ListTasksByTag(ctx, connect.NewRequest(&gen.ListTasksByTagRequest{
		TagName: "tag1",
	}))
	if err != nil {
		return nil, err
	}

	models := make([]*model.Task, len(res.Msg.Tasks))
	for i, t := range res.Msg.Tasks {
		models[i] = &model.Task{ID: string(t.Id), Text: t.Text, Tags: t.Tags}
	}
	return models, nil
}

// Attachments is the resolver for the Attachments field.
// By assuming it fetches data from DB, this function can reduce the unnecessary query to storage.
func (r *taskResolver) Attachments(ctx context.Context, obj *model.Task) ([]*model.Attachment, error) {
	now := time.Now()
	contents := "contents"
	// Sleep 1 second to see the difference of response time with or without Attachment field.
	time.Sleep(1 * time.Second)
	return []*model.Attachment{{Name: "attachment", Date: &now, Contents: &contents}}, nil
}

Running the servers

With our BFF and microservices in place, it’s time to run the servers and test our GraphQL endpoints:

cd bff
go run cmd/server/main.go
cd microservices/query_service
go run cmd/server/main.go
cd microservices/command_service
go run cmd/server/main.go

Go to localhost:8080 to interact with bff servers.

Use the provided GraphQL queries and mutations to interact with the BFF server and verify that it returns the expected results.

GetTasksByTag:

query{
  getTasksByTag(tag: "tag1") {
    Text
    Tags
    Attachments {
      Name
      Date
      Contents
    }
  }
}

CreateTask:

mutation {
  createTask(input: {
    Text: "My new task"
    Tags: ["important", "work"]
    Attachments: [
      {
        Name: "document.pdf"
        Date: "2023-05-01T10:00:00Z"
        Contents: "base64-encoded-file-contents"
      }
    ]
  }) {
    Id
    Text
    Tags
    Attachments {
      Name
      Date
      Contents
    }
  }
}
GraphQL Playground

Summary

In this post, we’ve explored the concept of BFF, implemented a GraphQL BFF service in Go, and learned how to optimize data fetching with GraphQL.

Check for the next part, where we’ll deploy our microservices with Kubernetes.

Part 3 - Deploy services by Kubernetes

References

REST Servers in Go: Part 7 - GraphQL

Building GraphQL servers in golang