programming

Go Interfaces, Routes, and Middleware: Constructing a Simple Backend Service

Lyndi Castrejon • March 31, 2026 · 12 min read

In this post, we explore the architecture of a web server built using Go, focusing on the use of interfaces, routes, and middleware and how these components work together.

Interfaces in Go

Go interfaces give us a way to define behavior without specifying the underlying implementation. In other words, Go interfaces are implicit — when you define a type that implements the method(s) defined in an interface, then it satisfies that interface.

This allows us to write flexible, modular programs. However, it can feel kind of weird when you’re coming from a language like Java or TypeScript where interfaces are declared explicitly.

For instance, in TypeScript, you might have something like this:

interface Messenger {
  send(recipient: string, message: string): Promise<void>;
}

class SystemMessage implements Messenger {
  async send(recipient: string, message: string): Promise<void> {
    // ...
  }
}

In Go, you can achieve a similar effect without declaring that SystemMessage implements Messenger. Although, it’s a bit more verbose than the TypeScript example above. You can define the interface along with a struct, then use a receiver method to actually implement the interface and type:

type Messenger interface {
  Send(recipient string, message string) error
}

type SystemMessage struct {
  source string
}

func (s *SystemMessage) Send(recipient string, message string) error {
  fmt.Printf("[%s] System message to %s: %s\n", s.source, recipient, message)
  return nil
}

The receiver method is imperative because it tells Go what Send() actually does for SystemMessage. Here, we know that SystemMessage will use Send() to send a system message to the user.

Creating a Helper Function

Let’s write code where the SystemMessage sends a message to the user when they’ve successfully paid their bill:

func main() {
  systemMsg := &SystemMessage{source: "Billing"}
  deliver(systemMsg, "recipient-user-id", "Payment successful. Thank you!")
}

// Helper
func deliver(m Messenger, recipient string, message string) error {
  return m.Send(recipient, message)
}

In the above, we’re defining a variable, systemMsg, where the source is “Billing”. Then, we are using the deliver() helper to pass in the systemMsg that we just defined, along with the recipient’s user id, and the message that we want to send.

The deliver() function accepts anything that satisfies the Messenger interface – in this case, the receiver method – and calls Send() to deliver the message to the specified recipient.

Defining Variables on a Struct

You may have noticed that we only have a single variable defined in the SystemMessage struct:

source string

The source property stores any necessary data or configurations required for the system message to be delivered successfully. We’re only declaring the variable, source (the area of the app triggering the message), because the recipient and message have already been defined on the Send() method: Send(recipient string, message string) error.

We don’t define source directly on the Messenger because interfaces do not hold data, they define what a type can do. The method signature(s) of an interface defines the name, input arguments, and return types but can be used flexibly by different types.

You can use the Messenger interface to write code that can work with any type that implements it. For instance, you can define a UserMessage struct that holds a sender for the message sender’s user ID:

type UserMessage struct {
  sender string
}

Notice: You have only defined sender in the UserMessage struct because recipient and message are already provided as parameters through Send() in the Messenger interface. As mentioned earlier for SystemMessage, the UserMessage struct only needs to hold data that is necessary for a user to send a message within the app.

So, your backend service should now look like this:

type Messenger interface {
  Send(recipient string, message string) error
}

type SystemMessage struct {
  source string
}

type UserMessage struct {
  sender string
}

func (s *SystemMessage) Send(recipient string, message string) error {
  fmt.Printf("[%s] System message to %s: %s\n", s.source, recipient, message)
  return nil
}

func main() {
  systemMsg := &SystemMessage{source: "Billing"}
  deliver(systemMsg, "recipient-user-id", "Payment successful. Thank you!")
}

func deliver(m Messenger, recipient string, message string) error {
  return m.Send(recipient, message)
}

Take a moment to think about how you would define a userMsg variable, within main(), that accepts UserMessage. Then determine how to use userMsg to send an in-app message. Reveal the code below to check your answer.

Reveal Code
userMsg := &UserMessage{sender: "sender-user-id"}

deliver(userMsg, "recipient-user-id", "Great work on the project!")

However, if you add userMsg to your main() method, you would run into an error. We need to define an additional receiver method to satisfy the Messenger interface, like this:

func (u *UserMessage) Send(recipient string, message string) error {
	fmt.Printf("[%s] Message sent to %s: %s\n", u.sender, recipient, message)
	return nil
}

Routes in Go

Routes define the endpoints that a client accesses. In Go, the net/http package provides built-in support to create routes and middleware. However, another popular package to look into is gorilla/mux. We’re going to stick with net/http for the purposes of this walkthrough.

Let’s look at what a route may look like:

http.HandleFunc("/hello", helloHandler)
http.HandleFunc("/coffee", coffeeHandler)

Here, we have two routes defined: /hello and /coffee, each with its own handler function. The handler function sends or retrieves data that is needed to display the route to the user. So, in our helloHandler, it only returns the text, “hello”. However, the coffeeHandler queries an API and returns images of coffee.

Think about how you would define a route for users to send messages. Reveal the code below to compare your answer.

Reveal Code
http.HandleFunc("/message", messageHandler)

This route definition is a simple example, but it’s missing something that we would need on a protected route. It doesn’t verify the user to determine if they should be permitted to send a message. This means that someone can send messages without being logged in.

Middleware in Go

Middleware defines the rules for triggering a behavior. In the example of the /message endpoint defined earlier, we don’t want someone to have access to send messages without being logged into an account.

So, we need a middleware that checks who the user is and whether the user should be allowed to send messages. To do this, we need the middleware to intercept a request before it reaches the route handler. Which means that we will need to wrap it with a middleware.

Let’s try to define middleware that will check the user’s authentication status:

func checkAuth(next http.HandlerFunc) http.HandlerFunc {
  return func(w http.ResponseWriter, r *http.Request) {
    token := r.Header.Get("Authorization")

    if token == "" {
      http.Error(w, "Unauthorized", http.StatusUnauthorized)
      return
    }

    // In practice, you'd validate the token and extract the sender here
    sender := "user-from-token"

    ctx := context.WithValue(r.Context(), "sender", sender)
    next(w, r.WithContext(ctx))
  }
}

In checkAuth(), we’re verifying that the user is authenticated with the proper role or permissions to send a message, and then returning an appropriate response.

To call the route handler, we need to pass it as a parameter on a route, like we did earlier with helloHandler and coffeeHandler. However, we also need to wrap the checkAuth() middleware around the route handler in order to check the user’s authentication status, first.

Using the handler and middleware we’ve defined above, take a minute to think about how you would update the /message route so that it checks whether the user is authenticated before executing the send message logic. When you’re ready, reveal the answer.

Reveal Code
http.HandleFunc("/message", checkAuth(messageHandler))

Putting It All Together

Now that we have a basic understanding of interfaces, routes, and middleware, it’s time to build the rest of the message sending service. Taking our code from earlier, we need to add in a line to start the web server on port :8080 in main() along with our package imports.

With our listener and imports added, this is where we are at so far:

package main
import (
  "fmt"
  "net/http"
)

type Messenger interface {
  Send(recipient string, message string) error
}

type SystemMessage struct {
  source string
}

type UserMessage struct {
  sender string
}

func main() {
  http.HandleFunc("/message", checkAuth(messageHandler))

  fmt.Println("Server is running on http://localhost:8080")
  http.ListenAndServe(":8080", nil)
}

func checkAuth(next http.HandlerFunc) http.HandlerFunc {
  return func(w http.ResponseWriter, r *http.Request) {
    token := r.Header.Get("X-User-ID")

    if token == "" {
      http.Error(w, "Unauthorized", http.StatusUnauthorized)
      return
    }

    ctx := context.WithValue(r.Context(), "sender", token)
    next(w, r.WithContext(ctx))
  }
}

func deliver(m Messenger, recipient string, message string) error {
  return m.Send(recipient, message)
}

Note: We’re currently grabbing the X-User-ID header to get the sender ID in checkAuth() for the purposes of this walkthrough. You would normally grab the Bearer token from an Authorization header to verify the user.

A Note About deliver()

If you recall, we defined the deliver() function in the Interfaces section. There are a couple of things to note about his helper.

Notice how the m instance of Messenger calls Send(). The result of this method — either, an error as seen in the function signature Send(recipient string, message string) error, or a quiet success — is returned wherever deliver() is called.

In a real-world application, the deliver() helper function would include logging and other tasks that are necessary for most (if not all) implementations of Messenger. For now, it’s a very simple helper that is a bit unnecessary, but we’ve added it for demonstration purposes.

Next, we need to define the route handler found in the /message route. This is where we will be calling our deliver() function that we just talked about:

func messageHandler(w http.ResponseWriter, r *http.Request) {
	sender := r.Context().Value("sender").(string)

  var req MessageRequest
  err := json.NewDecoder(r.Body).Decode(&req)
  if err != nil {
    http.Error(w, "Invalid request body", http.StatusBadRequest)
    return
  }

  userMsg := &UserMessage{sender: sender}
  deliver(userMsg, req.Recipient, req.Message)

  w.WriteHeader(http.StatusOK)
  fmt.Fprintf(w, "Message sent to %s", req.Recipient)
}

Here, we can see the handler grabs the parameters from the request body (r.Body) and parses the json data. Then we’re sending the message to the recipient, a success code is returned, and a message is printed to the console. However, we’ve also introduced something new that we haven’t discussed yet: Context.

Understanding Context in Go

Context is a Go package that carries request-scoped values. All incoming and outgoing requests creates or accepts a Context, respectively. In our messageHandler(), we’re extracting the Context with the value of sender formatted as a string.

sender := r.Context().Value("sender").(string)

What is a Leaky goroutine?

One of the rules of working with Context in Go is: never store Context within a struct. This confuses the server and causes scope to be intermingled in unexpected ways. Which means the server’s requests are unable to honor cancellation, leading to exhausted memory.

The ignored processes never stop since there’s no signal telling them to quit. The processes consume resources until someone manually clears them or the server crashes. In other words, you end up with a leaky goroutine.

How Context Can Be Exploited

Normally, a goroutine leak takes a while to accumulate before becoming a problem. However, if an attacker is somehow able to identify the leak, they could accelerate it by flooding the server with requests designed to trigger the leaky code path.

Each request spawns a goroutine that is never cleaned up. With a high volume of requests, the server could be flooded within minutes. Meaning, someone could execute a targeted denial-of-service by exploiting the application’s own cleanup failure.

Now that we have a better understanding of Context, let’s break down the messageHandler into smaller blocks:

sender := r.Context().Value("sender").(string)

var req MessageRequest
err := json.NewDecoder(r.Body).Decode(&req)

As we went over earlier, the sender is taken from the request Context and converted to a string, then we parse the json from the request body.

  if err != nil {
    http.Error(w, "Invalid request body", http.StatusBadRequest)
    return
  }

  userMsg := &UserMessage{sender: sender}
  deliver(userMsg, req.Recipient, req.Message)

In this section, the userMsg variable may look somewhat familiar, as we’re taking some of the code that we wrote in our main() function earlier. Here, if we run into an issue, an error and bad status is returned, otherwise, the data is passed along to the deliver() helper.

w.WriteHeader(http.StatusOK)
fmt.Fprintf(w, "Message sent to %s", req.Recipient)

If deliver() succeeds, the handler returns a 200 status code and prints the recipient.

However, if you’ve initialized the go module, using go mod init <name-of-directory>, then your text editor has probably been giving you an error about MessageRequest. We still haven’t defined this type after adding in the messageHandler.

Take a moment to consider how you would define the MessageRequest struct. Hint: this one is a little tricky as you will need to add the json signature after the type of each property.

Reveal Code
type MessageRequest struct {
  Recipient string `json:"Recipient"`
  Message   string `json:"Message"`
}

Full Code

Finally, your code should look like this:

package main

import (
	"context"
	"encoding/json"
	"fmt"
	"net/http"
)

type Messenger interface {
  Send(recipient string, message string) error
}

type SystemMessage struct {
  source string
}

type UserMessage struct {
  sender string
}

type MessageRequest struct {
  Recipient string `json:"Recipient"`
  Message   string `json:"Message"`
}

func main() {
  http.HandleFunc("/message", checkAuth(messageHandler))

  fmt.Println("Server is running on http://localhost:8080")
  http.ListenAndServe(":8080", nil)
}

// Receiver methods
func (s *SystemMessage) Send(recipient string, message string) error {
  fmt.Printf("[%s] System message to %s: %s\n", s.source, recipient, message)
  return nil
}

func (u *UserMessage) Send(recipient string, message string) error {
  fmt.Printf("[%s] Message to %s: %s\n", u.sender, recipient, message)
  return nil
}

// Middleware
func checkAuth(next http.HandlerFunc) http.HandlerFunc {
  return func(w http.ResponseWriter, r *http.Request) {
    token := r.Header.Get("X-User-ID")

    if token == "" {
      http.Error(w, "Unauthorized", http.StatusUnauthorized)
      return
    }

    ctx := context.WithValue(r.Context(), "sender", token)
    next(w, r.WithContext(ctx))
  }
}

// Route handler
func messageHandler(w http.ResponseWriter, r *http.Request) {
	sender := r.Context().Value("sender").(string)

  var req MessageRequest
  err := json.NewDecoder(r.Body).Decode(&req)
  if err != nil {
    http.Error(w, "Invalid request body", http.StatusBadRequest)
    return
  }

  userMsg := &UserMessage{sender: sender}
  deliver(userMsg, req.Recipient, req.Message)

  w.WriteHeader(http.StatusOK)
  fmt.Fprintf(w, "Message sent to %s", req.Recipient)
}

// Helper
func deliver(m Messenger, recipient string, message string) error {
  return m.Send(recipient, message)
}

Now, it’s time to test the code. Start a web server using the command, go run main.go. Then, we can use a curl request like this:

curl -X POST http://localhost:8080/message \
  -H "X-User-ID: user-alice" \
  -H "Content-Type: application/json" \
  -d '{"Recipient": "user-bob", "Message": "Great work on the project!"}'

If it’s successful, then you should see the curl message:

Message sent to user-bob

Return to the terminal window where your Go server is running and you should see the message:

[user-alice] Message to user-bob: Great work on the project!

To try out the unauthorized case, we just need to remove the X-User-ID header from our request:

curl -X POST http://localhost:8080/message \
  -H "Content-Type: application/json" \
  -d '{"Recipient": "user-bob", "Message": "This should fail"}'

This returns an “unauthorized” message in curl, and nothing should appear in the log for the running Go server.

Wrapping Up

In this walkthrough, we constructed a simple messaging service in Go to explore how interfaces, routes, and middleware work together. Interfaces define shared behavior across types, routes map endpoints to handlers, and middleware intercepts requests to enforce rules (like authentication). We also touched on Go’s Context package, how it carries request-scoped data between middleware and handlers, and how misusing it can introduce security risks.

Challenge

If you want to take this example a step further, try to swap out the X-User-ID header with a real JWT-based authentication flow. Try looking into golang-jwt/jwt to achieve this. Otherwise, you can also try to expand the functionality by adding another endpoint that allows authenticated users to read their inbox.