Building a 1v1 Game Matchmaking Service in Go – Part 3

This article is part 3 of a series.

Part 1 – Domain Modeling
Part 2 – Turning Domain Model into Code

The associated code repo is here.

Deployment

At the end of Part 2, we had a working implementation of a first come, first served matchmaker. Now we’re going to discuss how we can deploy that as part of a larger game playing ecosystem. We’ll look at two options – deploying it as part of a monolith, and deploying it as a microservice. We won’t spend much time discussing the pros and cons of the two approaches. Our focus is on flexibility. We want to separate deployment concerns from domain concerns, and make it easy to change how we deploy our matchmaking service.

Deploying in a Monolith

A monolith for the larger game playing ecosystem might look something like this:

my-game-service/
  |-matchmaking/
    # our service code
  |-games/
    # code to manage active games
  |-history/
    # code to keep track of wins/losses or all moves from previous games
  |-client/
    # the UI that the user interacts with

Typically that would be in a single repo since it would be part of the same deployment. Or the matchmaking code could still live in its own repo as a shared library and just get included as a module dependency. Either way, our matchmaking code is consumed directly through function calls on our MatchmakingService. So there really isn’t much for us to do here. What we’ve built so far is already consumable in that fashion.

The main thing to keep in mind here is that MatchmakingService is the entry point for other code that calls our code. That’s the public API.

Deploying as a Microservice

If we want to deploy our matchmaker as a standalone service, we will need to add some kind of inter-process communication. Typically this means running it as a server and wrapping an HTTP API around it. This could be a RESTful API, or a GraphQL API, or a gRPC API, or any number of other things. In the long run, we may need to support more than one API, or the choice of protocol may change. Recall that we are building our system using the Ports & Adapters architecture. The advantage of Ports & Adapters here is that we have cleanly separated the domain logic of our service from transportation layer concerns. Any API we put in place will call to our MatchmakingService, so it will be relatively easy to support different APIs or swap them out later.

Let’s get to work on the code. In Ports & Adapters terminology, we need to add a new Driving Adapter. We’re going to create one for a vanilla HTTP API. (Common parlance would say its a REST API, but there’s a lot more to REST and we’re not making any effort to be RESTful here). Here are the new folders and new files.

matchmaker-go/
  |-adapters/
    |-driving/
      |-api/
        |-api_http.go
        |-api_http_test.go

The adapter will be thin. It should just be mapping http endpoints to domain operations. Each domain action will have an endpoint. The parameters for the domain action will be in the JSON body of the request. We’re just going to use the http functionality from the go std library, but you could use whatever http server you want, like gin. This is what the /find-match endpoint handler looks like:

func (a *ApiHttp) handleFindMatch(w http.ResponseWriter, r *http.Request) {
    // Check if the request method is POST
    if r.Method != http.MethodPost {
        http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
        return
    }

    var f findMatchRequest

    err := json.NewDecoder(r.Body).Decode(&f)
    if err != nil {
        http.Error(w, err.Error(), http.StatusBadRequest)
        return
    }

    result, err := a.matchmakingService.FindMatch(f.PlayerId)
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }

    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusCreated)
    json.NewEncoder(w).Encode(result)
}

type findMatchRequest struct {
    PlayerId string `json:"playerId"`
}

We only need one line to invoke the domain action:

    result, err := a.matchmakingService.FindMatch(f.PlayerId)

Everything else is http related – either extracting info from the incoming request or formatting the response. The /leave-matchmaking endpoint is similar. We also need to add json tags to our various types that FindMatch and LeaveMatchmaking can return in our models/output.go file.

type FindingMatch struct {
    PlayerId string `json:"playerId"`
}

We add tests against the http server as well to test our adapter. We can use the go TestMain to setup the http server to run tests against.

var port int = 8888
var address string = fmt.Sprintf("http://localhost:%d", port)
func TestMain(m *testing.M) {
    // setup
    mm := services.NewMatchmakingService()
    a := NewApiHttp(mm)
    go a.Start(port)
    // give the server some time to start
    // a production implementation should do something fancier than sleep
    time.Sleep(100 * time.Millisecond)

    // Run tests
    exitCode := m.Run()

    os.Exit(exitCode)
}

Then our test cases make a request and validate the response status and response body.

func TestFindMatch(t *testing.T) {
    requestBody := `{"playerId":"p1"}`

    // Send a POST request to the test server
    resp := send(t, "find-match", requestBody)
    defer resp.Body.Close()

    wantStatus := http.StatusCreated
    wantBody := `{"playerId":"p1"}
`
    expectResponse(t, resp, wantStatus, wantBody)
}

We encapsulate the details of sending the request and validating the response so that our tests can be shorter and more clearly show what functionality they are exercising.

One thing to note about the tests for the http adapter is that they are very similar to the tests we wrote directly against our MatchmakingService. The http adapter doesn’t add new behaviors to the system, it just provides an API to all the same behaviors that MatchmakingService provides. This raises some interesting questions about how you should test the http adapter. Should you duplicate all the same tests, including edge cases? Should you just put the bare minimum in place to serve more as a smoke test to verify that things seem to be hooked together properly? Should you figure out a way to define the tests once but run them in different contexts – e.g. running the same test against the http api and directly against the MatchmakingService? There’s a lot you can do here. For the purposes of this little project, we’re just going to keep it simple and take a minimalistic smoke test approach.

The last bit of code we need is the main.go file to create the domain service, inject it into our driving adapter, and start the server.

matchmaker-go/
  cmd/
    main.go

And here it is:

func main() {
    // create the domain service
    mm := services.NewMatchmakingService()

    // create the driving adapter
    a := api.NewApiHttp(mm)

    // start the server
    a.Start(8888)
}

We can build that into an executable:

go build ./cmd/main.go

Depending on your platform and where you want your executable to live, you may want to tweak that command.

And voila! We now have a deployable server that provides our first come, first served matchmaking functionality. You can wrap this in a docker container or just use it in whatever-deployment-platform-you-want.

Note that we were able to build on top of the domain functionality to make the service deployable. We didn’t have to change any of our domain logic to deploy this to end users.

Next

Now that we have our service deployed, we can start collecting data and gathering user feedback to make informed business decisions on how to evolve our matchmaker. In Part 4, we will modify our business logic to support more interesting behaviors.

You can view all the code changes we made in Part 3 here.