Skip to Content
Course content

54: Designing Microservices in Go

Click on the "Edit" button in the top corner of the screen to edit your slide content.

By the time you're designing microservices, you've likely realized that the hardest part isn't the coding—it's the boundaries. In Go, we have a lot of tools to make these services fast, but it's easy to accidentally build a "distributed monolith" where everything is coupled, and one crash takes down the whole fleet. Let's look at how to avoid that using a real-world scenario: an e-commerce system with an Order Service and a Catalog Service.

How should I actually organize my folders so the project doesn't become a mess?

I see a lot of developers just throw everything into a /models or /utils folder. Please, don't do that. In Go, we want to organize by responsibility and visibility. I highly recommend the /internal pattern. Anything inside an internal directory cannot be imported by other modules, which is a lifesaver when you're trying to ensure the Order Service doesn't accidentally start relying on the Catalog Service's private logic.

Here is a layout I've used in production that scales well:


/order-service
  /cmd
    /server
      main.go        // Entry point: wire up dependencies here
  /internal
    /order
      service.go     // Business logic
      repository.go  // Database interactions
      handler.go     // HTTP/gRPC transport logic
    /catalogclient   // Logic to talk to the Catalog Service
      client.go
  /pkg
    /api             // Public types/interfaces if other services need them
  go.mod

The key here is that main.go is just the "glue." It initializes the database, creates the CatalogClient, and passes it into the OrderService. This makes unit testing a breeze because you can just swap the real client for a mock.

Should I be using REST/JSON or gRPC for service-to-service calls?

If you're building a public API for a frontend, stick with REST. But for internal communication between your Go services? Use gRPC. I'll be blunt: sending JSON back and forth between two services you control is a waste of CPU and bandwidth. gRPC uses Protocol Buffers, which are binary and strongly typed. You get a generated client and server, meaning you don't have to manually write http.Get calls and hope the JSON keys match.

Imagine your Order Service needs to check if a product is in stock. Instead of parsing a JSON string, you define it once in a .proto file:


syntax = "proto3";

service CatalogService {
  rpc GetProductStock (StockRequest) returns (StockResponse);
}

message StockRequest {
  string product_id = 1;
}

message StockResponse {
  int32 quantity = 1;
  bool is_available = 2;
}

Go's protoc compiler then generates the Go structs and methods for you. It's faster, it catches type errors at compile time, and it documents the API automatically.

What happens when the Catalog Service goes down? Does the Order Service just hang?

This is where most people trip up. If your Order Service makes a synchronous call to the Catalog Service and that service is lagging, your order requests will pile up, memory will spike, and eventually, your Order Service will crash too. This is a cascading failure.

You must use context.Context with a timeout for every single external call. Never make a network request without a deadline. I also suggest a simple "circuit breaker" pattern—if the Catalog Service fails five times in a row, stop trying to call it for 30 seconds and just return a "Service Temporarily Unavailable" error immediately.

Here is how you should wrap your client calls:


func (c *CatalogClient) CheckStock(ctx context.Context, id string) (int, error) {
    // Create a derived context that expires in 2 seconds
    ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
    defer cancel()

    // Pass that context into the gRPC or HTTP call
    resp, err := c.grpcClient.GetProductStock(ctx, &pb.StockRequest{ProductId: id})
    if err != nil {
        if errors.Is(ctx.Err(), context.DeadlineExceeded) {
            return 0, fmt.Errorf("catalog service timed out")
        }
        return 0, err
    }
    return int(resp.Quantity), nil
}

By doing this, you're protecting your own service. It's better to tell a user "We can't verify stock right now" than to let your entire infrastructure melt down because one small service is having a bad day.




📋 Practical Task

Exercise: Implementing a Resilient Product-Stock Checker

You are tasked with adding a safety layer to the Order Service. Currently, it calls the Catalog Service without any timeouts, causing the system to hang when the network is slow.

Your Goal: Modify the provided StockClient implementation to ensure that no request to the Catalog Service takes longer than 500 milliseconds. If the timeout is reached, the function should return a custom error: "catalog_timeout".

Requirements:

  • Use context.WithTimeout to wrap the existing request.
  • Ensure cancel() is called to prevent context leaks.
  • Correctly identify if the error returned by the client is a context.DeadlineExceeded error.
  • The return value should be (0, errors.New("catalog_timeout")) upon expiration.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.