6

Building a Microservice Using DDD

In the previous chapter, we discussed how to build a monolithic application using domain-driven design (DDD). As your organization and code base scale, you may consider migrating to a microservice-based approach for development. To do this well, we need to use some DDD concepts we saw in the last chapter, but also some we did not. We know we are going to need to communicate with other microservices, and therefore we will be revisiting the anti-corruption layers, as well as ports and adaptors, that we learned about in Chapter 2, Ubiquitous Language Bounded Contexts, Domains, and Sub-Domains.

In this chapter, we will do the following:

  • Learn what a microservice is, and how it differs from a monolithic application
  • Learn at a high level when you and your company may benefit from considering a microservice-based architecture
  • Build another service from scratch, using the ports and adaptor pattern, as well as the anti-corruption layer pattern

By the end of this chapter, we will have built an entire microservice from scratch that interacts with other microservices (accounting for failure scenarios) by using domain-driven principles.

Technical requirements

In this chapter, we will write a large amount of Golang code. To be able to follow along, you will need the following:

A friendly warning (again)

The application we are going to create in this chapter is intended for demonstration and to really highlight how to work in the DDD style. It is not production-ready, and we will be skipping lots of best practices such as testing and documentation. These are critically important, but beyond the scope of this book.

Let’s get started by looking at what a microservice is.

What do we mean by microservices?

Microservices, or microservice architecture, is a pattern of software development where teams build small services, with their own databases that communicate by some form of Remote Procedure Call (RPC). Microservices are as much an organizational decision as they are a technical one; the goal is to make it as simple as possible to scale both teams and software.

The following diagram shows how an imaginary monolith might be split into microservices:

Figure 6.1 – A monolithic application split into three smaller services

Microservices typically exhibit the following characteristics:

  • The service can be developed, deployed, and scaled independently; it should not impact the function of other services. It does not share code with other services, and communication with other services happens over some form of RPC.
  • The service does not depend on other services being available to be successful. This does not necessarily mean it can do 100% of its function without another service being available, but it does mean another service crashing should not lead to our service crashing.
  • The service solves a specific problem and solves it well. As the system grows more capabilities, it might be reconsidered to be broken into smaller microservices.

Microservices sound great! Let’s look at their benefits in more detail.

What are the benefits of microservices?

The following are some of the key benefits of microservices:

  • Microservices enable teams to move fast. Teams work and act within a small well-defined context and are empowered to make decisions themselves. This means the software development life cycle can be completed much quicker.
  • Flexible scaling. Services can be scaled for their specific needs rather than just having one global configuration.
  • Easier deployments. Since the code base is smaller and more focused, it is easier to build a deployment approach that suits your needs. Because it’s faster, it also encourages and enables more experimentation as changes can be easily tested and rolled back.
  • Freedom to explore different technologies. Using a different programming language or database is no problem in a microservice architecture.
  • More resilient. Patterns can be adopted to ensure most of our architecture is available even when others are facing problems. We will explore this more in this chapter.

Up to now, we have made microservices sound like a silver bullet; they are not. They come with some major downsides. Let’s look at these.

What are the downsides of microservices?

The following are the key drawbacks:

  • Distributed systems require more expertise to manage than a monolithic architecture. Without much better visibility and tooling, you may see errors and issues that can appear “random”.
  • Engineers need to have a wider skillset than those who work on monolithic architectures. They may need to become familiar with platforms such as Kubernetes, and they need to think and care more about latency and networking.
  • Testing user journeys can be much harder, especially in event-driven systems.

As you can see, microservices are not a free lunch, and their adoption needs to be considered carefully. Let’s explore the adoption considerations together.

Should my company adopt microservices?

As with all technology initiatives, microservices come with trade-offs. What might be a great fit for one company might be a terrible fit for yours. Be sure to have a wide discussion with your team and be honest about the challenges ahead. Some questions you might want to answer:

  • Do we have the expertise to run a distributed system? If not, what is our strategy to hire that expertise or train our staff?
  • Do we have the necessary tooling in place to monitor a distributed system? If not, will we get the budget and time to put these in place?
  • Which platform will we use to manage our distributed system? Kubernetes? Something else?
  • How comfortable is our team with the said platform?
  • How comfortable are our teams with building and owning their own CI/CD pipelines?

All these questions are basically lower-level questions that roll up to the ultimate question: will leadership invest the money and give us the time to do this?

Now that we understand the pros and cons of microservices, let’s go ahead and build a microservice using DDD patterns and Golang!

Setting the scene (again)

You work for a travel comparison website. Your team is responsible for making recommendations on where a customer might be able to travel, given their budget and other factors. Your team is known internally as the recommendations team. Your team has been asked to expose your recommendations via an API so that other teams in the company may use it to build their own products.

There is another team in your company that is responsible for working with travel providers to onboard them and aggregate their costs and offer information to your system. They are known as the partnership team.

For your project, you are going to need to call the partnership system to gather information to allow you to create recommendations. The documentation for the partnership team is quite sparse, but thankfully the team has the following published details available on its team wiki:

"If you make a GET request to /partnerships?location=$country&from=$date&to=$date we will return all the hotels in that country on those dates.
$country must be in Alpha 3 ISO-3166 format and the date must be ISO-8601 format. You can expect one of the following responses:
400. This means you made a bad request, and your parameters aren't in the correct format or were missing.
401. You are not authorized. You must pass an agreed password in the Authorization header field"
200. This means your request was successful. You can expect the following response:
{
  "availableHotels": [
    {
            "name": "hotel1_name",
      "priceInUSDPerNight": 500
    },
    {
      "name": "hotel2_name",
      "priceInUSDPerNight": 300
    }
  ]
}
There is no pagination, so if there are lots of hotels, the response could be slow.
This API can be a little temperamental so fails sometimes; we are not sure why. If you are going to call it, please prepare for intermittent failure. Due to this, we are going to rebuild the system soon, so don't recommend being too coupled to this specific API. If it fails, you will receive a 500 response with no body.

Although not in the OpenAPI format we discussed in Chapter 2, this documentation is succinct and helpful. It tells us everything we need to know and even gives us some reminders to implement patterns that will help us manage failures that may occur due to our system being distributed.

A few notes before we proceed:

  • ISO is the International Organization for Standardization. It develops and publishes international standards for all sorts of things, including dates, currency codes, and times.
  • Alpha 3 ISO-3166 is one of the formats that they have defined, which outlines a three-character representation of each country. This standard is widely adopted and implemented in many libraries across many programming languages. By using this and being clear it is using it, the team had made an easy way for us to communicate our intention regarding our country to it without ambiguity. An example Alpha 3 ISO-3166 country code is URK, which represents Ukraine.
  • ISO-8601 is a format defined for timestamps. An example of this is 1969-01-14, which represents January 14, 1969.

We now know what we need to build and the other systems we need to talk to, so let’s get started!

Building a recommendation system

To ensure we can focus on the important pieces of building a microservice using DDD principles, I have provided some sample code for this chapter. It’s available here: https://github.com/PacktPublishing/Domain-Driven-Design-with-GoLang/tree/main/chapter6. In the repository, you will find an already completed Go program called partnerships. This is an API that gives back a response in the preceding format. However, to make it represent the system described previously, 30% of all requests will fail. You can run this program by running docker-compose up.

Once running, you can type the following into your terminal:

curl --location --request GET 'http://localhost:3031/partnerships?location=UK'

If you do this a few times, you will notice you get one of two responses back. One is a 500 response, with no body. The other is this:

{
    "availableHotels": [
        {
            "name": "some hotel",
            "priceInUSDPerNight": 300
        },
        {
            "name": "some other hotel",
            "priceInUSDPerNight": 30
        },
        {
            "name": "some third hotel",
            "priceInUSDPerNight": 90
        },
        {
            "name": "some fourth hotel",
            "priceInUSDPerNight": 80
        }
    ]
}

We will use this API to develop our recommendation system.

Let’s get started by creating a couple of folders, as shown in the following screenshot:

Figure 6.2 – Our folder structure so far

Here, we have made a new folder called recommendation. This will be the project’s root. We have also made a cmd folder that is going to be the folder for our main binary, and an internal folder for our domain logic. This looks very similar to the service we started with in the previous chapter.

Inside internal, let’s make another folder called recommendation and a file called recommendation.go. This is going to be where we write our domain recommendation service:

Figure 6.3 – Creation of recommendation.go

Let’s add a domain model:

type Recommendation struct {
   TripStart time.Time
   TripEnd   time.Time
   HotelName string
   Location  string
   TripPrice money.Money
}

Notice how we have used domain language again.

Next, we are going to define an interface for the partnerships system:

type Option struct {
   HotelName     string
   Location      string
   PricePerNight money.Money
}
type AvailabilityGetter interface {
   GetAvailability(ctx context.Context, tripStart time.Time, tripEnd time.Time, location string) ([]Option, error)
}

Notice here how we have not coupled our interface to the partnership’s implementation at all. We have used domain language from our bounded context and defined what a reasonable, sensible interface is. This will help us a lot in the long run, as it will make moving to the new partnerships system much easier.

Let’s create a service to house wrap this interface:

type Service struct {
   availability AvailabilityGetter
}
func NewService(availability AvailabilityGetter) (*Service, error) {
   if availability == nil {
      return nil, errors.New("availability must not be nil")
   }
   return &Service{availability: availability}, nil
}

We have also created a NewService function that does some basic validation to ensure our service is in a good state before we use it.

Next, we are going to define a function called Get with the following signature:

func (svc *Service) Get(ctx context.Context, tripStart time.Time, tripEnd time.Time, location string, budget money.Money) (*Recommendation, error) {}

This function is in the recommendation package, so will be referred to as recommendation.Get, which makes clear what it does.

Let’s implement some basic validation checks:

func (svc *Service) Get(ctx context.Context, tripStart time.Time, tripEnd time.Time, location string, budget money.Money) (*Recommendation, error) {
   switch {
   case tripStart.IsZero():
      return nil, errors.New("trip start cannot be empty")
   case tripEnd.IsZero():
      return nil, errors.New("trip end cannot be empty")
   case location == "":
      return nil, errors.New("location cannot be empty")
   }
   return nil, nil
}

From our domain rules, we know these cannot be empty, and it’s always a good idea to validate.

Now we know that all our parameters are valid, we need to call the availability service:

opts, err := svc.availability.GetAvailability(ctx, tripStart, tripEnd, location)
if err != nil {
   return nil, fmt.Errorf("error getting availability: %w", err)
}

Note that we do not actually have a concrete implementation of this service yet and we do not know how it works. However, it does not stop us from developing our recommendation system.

Finally, let’s do the calculation to make a recommendation:

tripDuration := math.Round(tripEnd.Sub(tripStart).Hours() / 24)
lowestPrice := money.NewFromFloat(999999999, "USD")
var cheapestTrip *Option
for _, option := range opts {
   price := option.PricePerNight.Multiply(int64(tripDuration))
   if ok, _ := price.GreaterThan(budget); ok {
      continue
   }
   if ok, _ := price.LessThan(lowestPrice); ok {
      lowestPrice = price
      cheapestTrip = &option
   }
}
if cheapestTrip == nil {
   return nil, errors.New("no trips within budget")
}
return &Recommendation{
   TripStart: tripStart,
   TripEnd:   tripEnd,
   HotelName: cheapestTrip.HotelName,
   Location:  cheapestTrip.Location,
   TripPrice: *lowestPrice,
}, nil

Here, we calculate the trip duration so that we can figure out if, given the price per night, we can find a trip within budget. We then loop through all the options we got back from our availability service, skipping any that are outside of the budget. Finally, we return an error if there is none within budget, and a recommendation for the cheapest if there were several. This is a small service, and it has used lots of language from our bounded context.

Let’s take a closer look at the AvailabilityGetter interface and the DDD adaptor pattern.

Revisiting the anti-corruption layer

We looked at the anti-corruption layer (also known as the adapter pattern) in Chapter 2. As a reminder, the adaptor pattern is useful for decoupling two different bounded contexts from each other, which helps separate concerns and ensure our systems can evolve independently and safely.

Let’s add an adaptor layer that satisfies the AvailabilityGetter interface.

Firstly, let’s make a new file called adapter.go:

Figure 6.4 – Creation of adapter.go

Firstly, let’s define a client struct and a New function:

type PartnershipAdaptor struct {
   client *http.Client
   url    string
}
func NewPartnerShipAdaptor(client *http.Client, url string) (*Client, error) {
   if client == nil {
      return nil, errors.New("client cannot be nil")
   }
   if url == "" {
      return nil, errors.New("url cannot be empty")
   }
   return &Client{client: client, url: url}, nil
}

This is a pattern we have used throughout the book; we are simply validating that nothing we expect to not be empty or nil is.

Next, we want our adaptor to satisfy the AvailabilityGetter interface. This means we need to add the GetAvailability function to the client. Let’s stub that out:

func (p PartnershipAdaptor) GetAvailability(ctx context.Context, tripStart time.Time, tripEnd time.Time, location string) ([]Option, error) {
    return nil,nil
}

Great! Let’s start implementing it:

from := fmt.Sprintf("%d-%d-%d", tripStart.Year(), tripStart.Month(), tripStart.Day())
to := fmt.Sprintf("%d-%d-%d", tripEnd.Year(), tripEnd.Month(), tripEnd.Day())
url := fmt.Sprintf("%s/partnerships?location=%s&from=%s&to=%s", p.url, location, from, to)
res, err := p.client.Get(url)
if err != nil {
   return nil, fmt.Errorf("failed to call partnerships: %w", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
   return nil, fmt.Errorf("bad request to partnerships: %d", res.StatusCode)
}

Firstly, we make a GET call to the partnership endpoint using the client we have passed in. We will revisit this later to talk about how to ensure this is more resilient.

Assuming this is successful, we want to access the response. Therefore, we need to define what that response looks like as a Golang struct. We know what the response looks like from the partnership team’s documentation, so we can define it as follows:

type partnerShipsResponse struct {
   AvailableHotels []struct {
      Name               string `json:"name"`
      PriceInUSDPerNight int    `json:"priceInUSDPerNight"`
   } `json:"availableHotels"`
}

We can now decode the response from the request into this struct:

var pr partnerShipsResponse
if err := json.NewDecoder(res.Body).Decode(&pr); err != nil {
   return nil, fmt.Errorf("could not decode the response body of partnerships: %w", err)
}

Finally, we need to convert the response from the partnerships’ structure into our required []Options:

opts := make([]Option, len(pr.AvailableHotels))
for i, p := range pr.AvailableHotels {
   opts[i] = Option{
      HotelName:     p.Name,
      Location:      location,
      PricePerNight: *money.New(int64(p.PriceInUSDPerNight), "USD"),
   }
}
return opts, nil

Since we know the response size, we can make an array of the exact size we need and fill it as we iterate over AvailableHotels.

The entire function, therefore, looks like this:

func (p PartnershipAdaptor) GetAvailability(ctx context.Context, tripStart time.Time, tripEnd time.Time, location string) ([]Option, error) {
   res, err := p.client.Get(fmt.Sprintf("%s/partnerships?location=%s?from=%s?to=%s ", p.url, location, tripStart, tripEnd))
   if err != nil {
      return nil, fmt.Errorf("failed to call partnerships: %w", err)
   }
   defer res.Body.Close()
   var pr partnerShipsResponse
   if err := json.NewDecoder(res.Body).Decode(&pr); err != nil {
      return nil, fmt.Errorf("could not decode the response body of partnerships: %w", err)
   }
   opts := make([]Option, len(pr.AvailableHotels))
   for i, p := range pr.AvailableHotels {
      opts[i] = Option{
         HotelName:     p.Name,
         Location:      location,
         PricePerNight: *money.New(int64(p.PriceInUSDPerNight), "USD"),
      }
   }
   return opts, nil
}

This is everything we need for our adapter layer (apart from testing it rigorously, of course).

Exposing our service via an open host service

We have a requirement that our service must also expose an API. This is so other microservices or user interfaces may call us to get a recommendation. One method we could use to do this is to generate an API using OpenAPI or gRPC, as we discussed in Chapter 2. However, for completeness, we are going to write this one from scratch.

Let’s define a contract first. We are going to create an API that receives the following request:

/recommendation?location=$country?from=$date&to=$date&budget =$budget

It returns the following response:

{
    "hotelName": "hotel Name",
    "totalCost": {
    "cost": 300,
    "currency": "USD"
    }
}

Notice how the response we intend to return is different from the partnership system? This is completely normal. We own our domain, and as such, we can decide on requests/responses that make sense given the use case of our system. Typically, we will work with teams that will be calling our service to ensure we are returning something that is reasonable for their use case, but also makes sense for our API to return.

Now we have a contract, let’s go ahead and define an HTTP handler. Firstly, let’s define the following:

type Handler struct {
   svc Service
}
func NewHandler(svc Service) (*Handler, error) {
   if svc == (Service{}) {
      return nil, errors.New("service cannot be empty")
   }
   return &Handler{svc: svc}, nil
}

In the preceding code, we define a Handler struct and a New function that does some basic validation to ensure it’s not empty. We will need the Service shortly since our Handler function is just a means to expose our business logic, and our business logic lives on our Service.

Next, let’s define a struct that matches the contract we created previously:

type GetRecommendationResponse struct {
   HotelName string `json:"hotelName"`
   TotalCost struct {
      Cost     int64  `json:"cost"`
      Currency string `json:"currency"`
   } `json:"totalCost"`
}

We will use this shortly to marshal/unmarshal our response from Golang to JSON.

Finally, we can define our actual Handler function. Let’s do it in stages since it’s quite verbose:

func (h Handler) GetRecommendation(w http.ResponseWriter, req *http.Request) {
   q := mux.Vars(req)
   location, ok := q["location"]
   if !ok {
      w.WriteHeader(http.StatusBadRequest)
      return
   }
   from, ok := q["from"]
   if !ok {
      w.WriteHeader(http.StatusBadRequest)
      return
   }
   to, ok := q["to"]
   if !ok {
      w.WriteHeader(http.StatusBadRequest)
      return
   }
   budget, ok := q["budget"]
   if !ok {
      w.WriteHeader(http.StatusBadRequest)
      return
   }

GetRecomendation matches the criteria for a Handler function. This will be important in a moment because it means we can register it on an HTTP router, and therefore we’ll be able to expose it to the outside world for others to call it.

We are using the github.com/gorilla/mux package to extract all the expectation query strings from our request and check they are not empty. If any of them are, we return a bad request response. This serves as another adaptor layer and protects our business logic from receiving requests that will never succeed due to missing pre-requisite information:

const expectedFormat = "2006-01-02"
formattedStart, err := time.Parse(expectedFormat, from)
if err != nil {
   w.WriteHeader(http.StatusBadRequest)
   return
}
formattedEnd, err := time.Parse(expectedFormat, to)
if err != nil {
   w.WriteHeader(http.StatusBadRequest)
   return
}

The next thing we do is transform the dates we received on the request into a format that our service expects and return a bad request if we cannot. Again, this allows us to “fail fast” if we know the requests can never succeed due to not being of the right type or in the right format:

b, err := strconv.ParseInt(budget, 10, 64)
if err != nil {
   w.WriteHeader(http.StatusBadRequest)
   return
}
budgetMon := money.New(b, "USD")

We do the same thing for the budget. Our server expects the budget to be of a money type, but it is currently a string. We need to convert it. For now, we assume all requests are USD, but this is something we would need to improve in the future:

rec, err := h.svc.Get(req.Context(), formattedStart, formattedEnd, location, budgetMon)
if err != nil {
   w.WriteHeader(http.StatusInternalServerError)
   return
}
res, err := json.Marshal(GetRecommendationResponse{
   HotelName: rec.HotelName,
   TotalCost: struct {
      Cost     int64  `json:"cost"`
      Currency string `json:"currency"`
   }{
      Cost:     rec.TripPrice.Amount(),
      Currency: "USD",
   },
})
if err != nil {
   w.WriteHeader(http.StatusInternalServerError)
   return
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write(res)
return

Finally, we call our service. If we receive an error from the service, we return an internal server error since something must have gone wrong that we did not expect. If it does succeed, we marshal our response into our expected response format and return it.

Our open host service is looking good! We mentioned previously that we used an http.handler function type to enable us to “attach” it to a router. Let’s have a look at how we might do that.

Firstly, we are going to make an entirely new package called transport and a file called transporthttp.go:

Figure 6.5 – Creation of transporthttp.go

This is a nice way to decouple the more specific implementation of transport types from your domain code. The transporthttp.go file is fairly simple and looks like this:

package transport
import (
   "net/http"
   "github.com/gorilla/mux"
   "github.com/PacktPublishing/Domain-Driven-Design-with-GoLang/chapter6/recommendation/internal/recommendation"
)
func NewMux(recHandler recommendation.Handler) *mux.Router {
   m := mux.NewRouter()
   m.HandleFunc("/recommendation", recHandler.GetRecommendation).Methods(http.MethodGet)
   return m
}

We again use the gorilla/mux package to make it easy to create a router, and we connect our service to the /recommendation endpoint. That’s it!

We have an entire microservice now. The only thing left to do is to write a main.go file to put everything to get it so that we can run it. Let’s do that now.

In the recommendation root folder, we create a cmd folder and a main.go file:

Figure 6.6 – Creation of main.go

In main.go, we write the main function. Let’s step through it:

package main
import (
   "log"
   "net/http"
   "github.com/hashicorp/go-retryablehttp"
   "github.com/PacktPublishing/Domain-Driven-Design-with-GoLang/chapter6/recommendation/internal/recommendation"
   "github.com/PacktPublishing/Domain-Driven-Design-with-GoLang/chapter6/recommendation/internal/transport"
)
func main() {
   c := retryablehttp.NewClient()
   c.RetryMax = 10

Firstly, we create a retryablehttp client using a library provided by HashiCorp. This enables us to configure a retry policy that determines how we handle 5xx errors. This is helpful in our case as we know the partnership service can and will fail regularly. This is an important lesson to always keep in mind when working on distributed systems; we should always expect failure and account for it in our programming:

partnerAdaptor, err := recommendation.NewPartnerShipAdaptor(
   c.StandardClient(),
   "http://localhost:3031",
)
if err != nil {
   log.Fatal("failed to create a partnerAdaptor: ", err)
}

Next, we create a partnerAdaptor. Our NewPartnerShipAdaptor takes an *http.StandardClient, so we need to convert our retryablehttp client to that. Thankfully, the library provides an easy means to do that. We also must provide a base URL for our partnership service. Here, we have hardcoded the URL that our partnership system runs on if we do docker-compose up. You may want to move this to be an environment variable:

svc, err := recommendation.NewService(partnerAdaptor)
if err != nil {
   log.Fatal("failed to create a service: ", err)
}
handler, err := recommendation.NewHandler(*svc)
if err != nil {
   log.Fatal("failed to create a handler: ", err)
}

Next, we make a new service and a new handler. If for any reason we cannot create either, we call log.Fatal, which shuts down the program immediately. This is because there is no point in proceeding as our basic pre-requisite conditions are not met:

m := transport.NewMux(*handler)
if err := http.ListenAndServe(":4040", m); err != nil {
   log.Fatal("server errored: ", err)
}

Finally, we create a server and expose it on port 4040. We now have a microservice ready to run! You can run it by typing go run recommendation/cmd/main.go into your terminal.

Assuming you still have the Docker image running from before, you should be able to run the following command in your terminal:

curl --location --request GET 'http://localhost:4040/recommendation?location=UK&from=2022-09-01&to=2022-09-08&budget=5000'

After running it, you’ll see the following response:

{
    "hotelName": "some fourth hotel",
    "totalCost": {
        "cost": 210,
        "currency": "USD"
    }
}

Due to our retryablehttp client, even when the partnerships’ service returns an error, we do not see it as it is retried automatically.

Summary

In this chapter, we have discussed the pros and cons of building microservices and seen how domain-driven patterns such as the domain model, the anti-corruption layer, and the open host service can help us to build maintainable microservices. We also discussed how we should expect failure and how we could use simple patterns such as retryable HTTP calls to make our system resilient to these failures.

In the next (and final) chapter, we are going to dig deeper into distributed systems and explore some more advanced DDD patterns that we can use to make our system simpler to reason about, easier to maintain, and—perhaps most importantly—easy to add new functionality to.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
18.222.111.134