Welcome back! In our last couple of lessons, you built the two essential halves of our client's communication logic: a function to send a framed message and the logic to receive and decode one. Your main function currently executes these steps in sequence: it sends a request, waits for a response, and then exits.
Today, we will formalize this interaction. We will compose those two halves into a single, cohesive unit that represents a complete, synchronous request-response cycle. This is a fundamental pattern in network programming and a key step toward creating a robust and reusable client library for our broker.
Your Goal for Today
Learning Outcome: Compose the send and receive functions into a client that executes a full request-response cycle.
By the end of this lesson, you will have refactored our simple client script into a more structured client package. This package will provide a clean, high-level function that hides the underlying details of serialization, sending, receiving, and decoding, allowing a caller to simply "make a request" and "get a response."
1. The Request-Response Pattern
At its core, the request-response pattern is one of the simplest forms of network communication. The client initiates communication, sends a request, and then blocks, waiting until the server processes the request and sends back a response.
This synchronous, blocking behavior is illustrated perfectly by the diagrams below. The client's timeline shows a "write" operation followed by a "read" operation, and it cannot proceed until the read is complete.

While we will eventually explore more complex, asynchronous patterns, mastering this synchronous model is the essential first step. It forms the building block for almost any client-server interaction.
2. Implementing the Cycle in Go
You've already implemented the necessary components: protocol.Encode to send and protocol.Decode to receive. Composing them means creating a single function that orchestrates this sequence:
- Establish a connection (if not already present).
- Encode and send the request message.
- Block while waiting to decode the response message.
- Validate the response (e.g., check for the correct message type).
- Decode and return the response payload.
- Handle any errors that occur during the cycle.
This is a very common pattern. To see how it's implemented in practice, let's look at a couple of real-world examples.
TCP/IP Networking - Applied Go
The article 'TCP/IP Networking' from Applied Go provides a complete, well-documented example of a client that performs a request-response cycle. Although it uses a text-based protocol, the fundamental sequence of operations is identical to what we need.
Please read the section titled 'The client and server functions', focusing specifically on the client function. Notice the clear sequence: Open the connection, WriteString for the request, Flush the buffer, and then ReadString to get the reply. This is the exact pattern we're building, just with our binary protocol instead of strings.
The example above uses buffered I/O (bufio.ReadWriter) and a newline-delimited protocol, but the logic holds. Another example, which is even closer to our implementation, uses a binary protocol.
How to Implement Custom Binary Protocols Over TCP in Go
The article 'How to Implement Custom Binary Protocols Over TCP in Go' also demonstrates this cycle concisely. It was a resource from our previous lesson, but now we can look at it with a focus on the full cycle.
Review the code in the 'Building the Client' section. Pay attention to the ping/pong interaction: the code calls protocol.Encode to send the 'ping' and then immediately calls protocol.Decode to wait for the 'pong' response. This is a perfect, minimal example of a synchronous request-response cycle.
These examples confirm that our goal is to create a function that neatly bundles the Encode and Decode calls into a single, logical operation.
3. Hands-On: Building a Reusable Client
Let's refactor our client code into a more professional structure. We'll create a dedicated client package and a Client struct to manage the connection and expose high-level methods like Produce.
Step 1: Create the Client Package and Struct
First, create a new directory and file for our client package.
- Create a new directory:
internal/client. - Inside that directory, create a new file:
client.go.
Now, let's define the Client struct and a constructor in internal/client/client.go. This struct will hold the connection, and the constructor will handle dialing the server.
// internal/client/client.go
package client
import (
"fmt"
"net"
"our-project/internal/protocol" // Use your project's module path
)
// Client is a client for our message broker.
type Client struct {
conn net.Conn
}
// New connects to the given address and returns a new Client.
func New(addr string) (*Client, error) {
conn, err := net.Dial("tcp", addr)
if err != nil {
return nil, fmt.Errorf("failed to dial server at %s: %w", addr, err)
}
return &Client{conn: conn}, nil
}
// Close closes the connection to the server.
func (c *Client) Close() error {
return c.conn.Close()
}
Step 2: Compose the Send and Receive Logic
Now for the main event. Let's add a Produce method to our Client struct. This method will encapsulate the entire request-response cycle for a produce operation. It will take a request body and return a response body, hiding all the network details.
Add the following method to internal/client/client.go:
// internal/client/client.go
// ... (add this after the Close method)
// Produce sends a produce request to the server and waits for a response.
// This is a synchronous, blocking call.
func (c *Client) Produce(reqBody *protocol.ProduceRequestBody) (*protocol.ProduceResponseBody, error) {
// 1. Marshal the request body into a byte payload.
payload, err := reqBody.MarshalBinary()
if err != nil {
return nil, fmt.Errorf("failed to marshal produce request body: %w", err)
}
// 2. Send the request using our protocol's Encode function.
err = protocol.Encode(c.conn, protocol.MsgProduceRequest, payload)
if err != nil {
return nil, fmt.Errorf("failed to send produce request: %w", err)
}
// 3. Wait for and decode the server's response.
msg, err := protocol.Decode(c.conn)
if err != nil {
return nil, fmt.Errorf("failed to decode produce response: %w", err)
}
// 4. Validate the response type.
if msg.Header.MsgType != protocol.MsgProduceResponse {
return nil, fmt.Errorf("received unexpected message type: got %d, want %d",
msg.Header.MsgType, protocol.MsgProduceResponse)
}
// 5. Unmarshal the payload into the response body struct.
respBody := &protocol.ProduceResponseBody{}
err = respBody.UnmarshalBinary(msg.Payload)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal produce response body: %w", err)
}
// 6. Return the successful response.
return respBody, nil
}
Look at how clean this is! We have composed our low-level Encode and Decode functions into a high-level Produce operation. The caller doesn't need to know anything about message framing or binary serialization.
Step 3: Update main to Use the New Client
Finally, let's simplify cmd/client/main.go to use our new, elegant client API. Your main function will become much shorter and more readable.
Replace the contents of cmd/client/main.go with this:
// cmd/client/main.go
package main
import (
"log"
"our-project/internal/client" // Use your project's module path
"our-project/internal/protocol"
)
const serverAddr = "localhost:9000"
func main() {
// 1. Create a new client instance.
c, err := client.New(serverAddr)
if err != nil {
log.Fatalf("Error creating client: %v", err)
}
defer c.Close()
log.Println("Client connected to", serverAddr)
// 2. Prepare the request.
reqBody := &protocol.ProduceRequestBody{
TopicName: "logs",
PartitionID: 1,
Data: []byte("hello from our new client!"),
}
// 3. Call the high-level Produce method and get a response.
log.Println("Sending produce request...")
respBody, err := c.Produce(reqBody)
if err != nil {
log.Fatalf("Produce request failed: %v", err)
}
// 4. Log the response from the server.
log.Printf(
"Successfully received produce response: PartitionID=%d, Offset=%d",
respBody.PartitionID,
respBody.Offset,
)
}
Now, run your server in one terminal and your client in another. The output should be the same as before, but your code is now vastly better organized. You have successfully composed the individual pieces into a complete, reusable request-response cycle.
Conclusion
In this lesson, you took a significant step in maturing your project's codebase. You transformed a simple script into a structured client package that cleanly abstracts network communication.
Key Takeaways:
- The synchronous request-response cycle (send, block, receive) is a fundamental pattern in network programming.
- Composing low-level functions (
Encode,Decode) into high-level, domain-specific methods (Produce) is a powerful abstraction technique. - Encapsulating connection state and behavior within a
Clientstruct leads to cleaner, more maintainable, and reusable code—a practice that resonates well with your software engineering background.
Next Up
You have now completed the entire network layer module! You have a server that can handle concurrent connections and a client that can send requests and receive responses using a custom binary protocol.
However, our server doesn't do anything with the data it receives yet. In the next module, "Designing the Broker's Storage Engine," we will begin building the heart of the broker. We'll start by defining the core data structures for topics, partitions, and messages, and we'll introduce the necessary concurrency controls to manage them safely.