DEV Community

Eyo
Eyo

Posted on • Edited on

A straightforward guide for Go interface

As a developer transitioning from JavaScript to Go, I found the concept of interfaces challenging when I first started learning Go.
This guide aims to provide a straightforward explanation of Go interfaces for those in similar situations - whether you're coming from a JavaScript background or are new to programming.

The goal is to demonstrate the power and flexibility of Go interfaces, while providing practical insights. By the end of this article, I hope you'll have a solid understanding of how to use interfaces effectively in your Go projects.

 

Overview

Let's take a step back from Go and use a simple analogy to understand what an interface is at a high level.

Imagine we're living in a world without Uber, Lyft, or any ride-sharing services.
There's a person named Tony who owns various types of vehicles, including a car, a truck, and an airplane. He wonders, "How can I make the most of all these vehicles?"

Meanwhile, there's Tina, a salesperson whose job requires frequent travel. She doesn't enjoy driving and doesn't have a driver's license, so she usually takes taxis. However, as she gets promoted, her schedule becomes busier and more dynamic, and taxis sometimes fall short of meeting her needs.

Let's look at Tina's schedule from Monday to Wednesday:

  • Monday: She needs to reach Client A's office by 2 PM and must use her laptop during the ride due to an important meeting at 1 PM.
  • Tuesday: She has to be at Client B's office by 5 PM. The commute is about 2 hours, so she needs a car where she can lie down and take a nap.
  • Wednesday: She needs to get to the airport by 10 AM with a lot of luggage, requiring a spacious vehicle.

One day, Tina discovers that Tony has several different types of vehicles, and she can choose the most suitable one based on her needs.

conversation between Tony and TinaIn this scenario, every time Tina wants to go somewhere, she has to visit Tony and listen to his explanation of all the technical details before choosing a suitable car. However, Tina doesn't care about these technical details, nor does she need to know them. She just needs a car that meets her requirements.

Here's a simple diagram illustrating the relationship between Tina and Tony in this scenario:
Tina directly ask TonyAs we can see, Tina directly asks Tony. In other words, Tina depends on Tony because she needs to contact him directly whenever she needs a car.

 

In order to make Tina's life easier, she creates a contract with Tony, which is essentially a list of requirements for the car. Tony will then choose the most suitable car based on this list.
Tina makes a contractIn this example, the contract with a list of requirements helps Tina abstract away the details of the car and focus only on her requirements. All Tina needs to do is define the requirements in the contract, and Tony will choose the most suitable car for her.

We can further illustrate the relationship in this scenario with this diagram:
Tina define and use a contractInstead of asking Tony directly, Tina can now use the contract to get the car she needs. In this case, she's not dependent on Tony anymore; instead, she depends on the contract. The main purpose of the contract is to abstract away the details of the car, so Tina doesn't need to know the specific details of the car. All she needs to know is that the car satisfies her requirements.

From this diagram, we can identify the following characteristics:

  • The contract is defined by Tina; it's up to her to decide what requirements she needs.
  • The contract acts as a middleman between Tony and Tina. They're not directly dependent on each other; instead, they depend on the contract.
    • Tina can use the same contract with others if Tony stops providing the service.
  • There might be multiple cars that satisfy the same requirements
    • For example, both a Tesla Model S and a Mercedes-Benz S-Class could meet Tina's requirements.

I hope this diagram makes sense to you because understanding it is key to grasp the concept of interfaces. Similar diagrams will appear throughout the following sections, reinforcing this important concept.

 

What's an interface?

In the previous example, a contract with a list of requirements is exactly what an interface is in Go.

  1. A contract helps Tina to abstract away the details of the car and focus only on her requirements.
    • An interface abstracts away the details of an implementation and focus only on the behavior.
  2. A contract is defined by a list of requirements.
    • An interface is defined by a list of method signatures.
  3. Any car that satisfies the requirements is said to implement the contract.
    • Any type that implements all the methods specified in the interface is said to implement that interface.
  4. A contract is owned by the consumer (in this case, Tina)
    • An interface is owned by who uses it (the caller)
  5. A contract acts as a middleman between the Tina and Tony
    • An interface acts as a middleman between the caller and the implementer
  6. There might be multiple cars that satisfy the same requirements
    • There might be multiple implementations of an interface

A key feature that sets Go apart from many other languages is its use of implicit interface implementation. This means you don't need to explicitly declare that a type implements an interface. As long as a type defines all the methods required by an interface, it automatically implements that interface.

When working with interfaces in Go, it's important to note that it only provides the list of behavior, not the detail implementation. An interface defines what methods a type should have, not how they should work.

 

Simple Example

Let's walk through a simple example to illustrate how interfaces work in Go.

First, we'll define a Car interface:

type Car interface {
    Drive()
}
Enter fullscreen mode Exit fullscreen mode

This simple Car interface has a single method, Drive(), which takes no arguments and returns nothing. Any type that has a Drive() method with this exact signature is considered to implement the Car interface.

Now, let's create a Tesla type that implements the Car interface:

type Tesla struct{}

func (t Tesla) Drive() {
    println("driving a tesla")
}
Enter fullscreen mode Exit fullscreen mode

The Tesla type implements the Car interface because it has a Drive() method with the same signature as defined in the interface.

To demonstrate how we can use this interface, let's create a function that accepts any Car:

func DriveCar(c Car) {
    c.Drive()
}

func main() {
    t := Tesla{}
    DriveCar(t)
}
/*
Output:
driving a tesla
*/
Enter fullscreen mode Exit fullscreen mode

This code proves that the Tesla type implements the Car interface because we can pass a Tesla value to the DriveCar function, which accepts any Car.
Note: You can find the complete code in this repository.

It's important to understand that Tesla implements the Car interface implicitly. There's no explicit declaration like type Tesla struct implements Car interface. Instead, Go recognizes that Tesla implements Car simply because it has a Drive() method with the correct signature.

Let's visualize the relationship between the Tesla type and the Car interface with a diagram:
simple exampleThis diagram illustrates the relationship between the Tesla type and the Car interface. Notice that the Car interface doesn't know anything about the Tesla type. It doesn't care which type is implementing it, and it doesn't need to know.

I hope this example helps clarify the concept of interfaces in Go. Don't worry if you're wondering about the practical benefits of using an interface in this simple scenario. We'll explore the power and flexibility of interfaces in more complex situations in the next section.

 

Usecase

In this section, we'll explore some practical examples to see why interfaces are useful.

Polymorphism

What makes interfaces so powerful is their ability to achieve polymorphism in Go.
Polymorphism, a concept in object-oriented programming, allows us to treat different types of objects in the same way. In simpler terms, polymorphism is just a fancy word for "having many forms".
In the Go world, we can think of polymorphism as "one interface, multiple implementations".

Let's explore this concept with an example. Imagine we want to build a simple ORM (Object-Relational Mapping) that can work with different types of databases. We want the client to be able to insert, update, and delete data from the database easily without worrying about the specific query syntax.
For this example, let's say we only support mysql and postgres for now, and we'll focus solely on the insert operation. Ultimately, we want the client to use our ORM like this:

conn := sql.Open("mysql", "root:root@tcp(127.0.0.1:3306)/test")
orm := myorm.New(&myorm.MySQL{Conn: conn})
orm.Insert("users", user)
Enter fullscreen mode Exit fullscreen mode

 

First, let's see how we might achieve this without using an interface.
We'll start by defining MySQL and Postgres structs, each with an Insert method:

type MySQL struct {
    Conn *sql.DB
}

func (m *MySQL) Insert(table string, data map[string]interface{}) error {
    // insert into mysql using mysql query
}

type Postgres struct {
    Conn *sql.DB
}

func (p *Postgres) Insert(table string, data map[string]interface{}) error {
    // insert into postgres using postgres query
}
Enter fullscreen mode Exit fullscreen mode

Next, we'll define an ORM struct with a driver field:

type ORM struct {
    db any
}
Enter fullscreen mode Exit fullscreen mode

The ORM struct will be used by the client. We use the any type for the driver field because we can't determine the specific type of the driver at compile time.

Now, let's implement the New function to initialize the ORM struct:

func New(db any) *ORM {
    return &ORM{db: db}
}
Enter fullscreen mode Exit fullscreen mode

Finally, we'll implement the Insert method for the ORM struct:

func (o *ORM) Insert(table string, data map[string]interface{}) error {
    switch d := o.db.(type) {
    case MySQL:
        return d.Insert(table, data)
    case Postgres:
        return d.Insert(table, data)
    default:
        return fmt.Errorf("unsupported database driver")
    }
}
Enter fullscreen mode Exit fullscreen mode

We have to use a type switch (switch d := o.db.(type)) to determine the type of the driver because the db field is of type any.

While this approach works, it has a significant drawback: if we want to support more database types, we need to add more case statements. This might not seem like a big issue initially, but as we add more database types, our code becomes harder to maintain.

 

Now, let's see how interfaces can help us solve this problem more elegantly.
First, we'll define a DB interface with an Insert method:

type DB interface {
    Insert(table string, data map[string]interface{}) error
}
Enter fullscreen mode Exit fullscreen mode

Any type that has an Insert method with this exact signature automatically implements the DB interface.
Recall that our MySQL and Postgres structs both have Insert methods matching this signature, so they implicitly implement the DB interface.

Next, we can use the DB interface as the type for the db field in our ORM struct:

type ORM struct {
    db DB
}
Enter fullscreen mode Exit fullscreen mode

Let's update the New function to accept a DB interface:

func New(db DB) *ORM {
    return &ORM{db: db}
}
Enter fullscreen mode Exit fullscreen mode

Finally, we'll modify the Insert method to use the DB interface:

func (o *ORM) Insert(table string, data map[string]interface{}) error {
    return o.db.Insert(table, data)
}
Enter fullscreen mode Exit fullscreen mode

Instead of using a switch statement to determine the database type, we can simply call the Insert method of the DB interface.

Now, clients can use the ORM struct to insert data into any supported database without worrying about the specific implementation details:

// using mysql
conn := sql.Open("mysql", "root:root@tcp(127.0.0.1:3306)/test")
orm := myorm.New(&myorm.MySQL{Conn: conn})
orm.Insert("users", user)

// using postgres
conn = sql.Open("postgres", "user=pqgotest dbname=pqgotest sslmode=disable")
orm = myORM.New(&myorm.Postgres{Conn: conn})
orm.Insert("users", user)
Enter fullscreen mode Exit fullscreen mode

With the DB interface, we can easily add support for more database types without modifying the ORM struct or the Insert method. This makes our code more flexible and easier to extend.

Consider the following diagram to illustrate the relationship between the ORM, MySQL, Postgres, and DB interfaces:
polymorphism exampleIn this diagram, the ORM struct depends on the DB interface, and the MySQL and Postgres structs implement the DB interface. This allows the ORM struct to use the Insert method of the DB interface without knowing the specific implementation details of the MySQL or Postgres structs.

This example demonstrates the power of interfaces in Go. We can have one interface and multiple implementations, allowing us to write more adaptable and maintainable code.

Note: You can find the complete code in this repository.

 

Making testing easier

Let's consider an example where we want to implement an S3 uploader to upload files to AWS S3. Initially, we might implement it like this:

type S3Uploader struct {
    client *s3.Client
}

func NewS3Uploader(client *s3.Client) *S3Uploader {
    return &S3Uploader{client: client}
}

func (s *S3Uploader) Upload(ctx context.Context, bucketName, objectKey string, data []byte) error {
    _, err := s.client.PutObject(ctx, &s3.PutObjectInput{
        Bucket: aws.String(bucketName),
        Key:    aws.String(objectKey),
        Body:   bytes.NewReader(data),
    })

    return err
}
Enter fullscreen mode Exit fullscreen mode

In this example, the client field in the S3Uploader struct is type *s3.Client, which means the S3Uploader struct is directly dependent on the s3.Client.
Let's visualize this with a diagram:
unit-testing-without-interfaceWhile this implementation works fine during development, it can pose challenges when we're writing unit tests. For unit testing, we typically want to avoid depending on external services like S3. Instead, we'd prefer to use a mock that simulates the behavior of the S3 client.

This is where interfaces come to the rescue.

We can define an S3Manager interface that includes a PutObject method:

type S3Manager interface {
    PutObject(ctx context.Context, params *s3.PutObjectInput, optFns ...func(*s3.Options)) (*s3.PutObjectOutput, error)
}
Enter fullscreen mode Exit fullscreen mode

Note that the PutObject method has the same signature as the PutObject method in s3.Client.

Now, we can use this S3Manager interface as the type for the client field in our S3Uploader struct:

type S3Uploader struct {
    client S3Manager
}
Enter fullscreen mode Exit fullscreen mode

Next, we'll modify the NewS3Uploader function to accept the S3Manager interface instead of the concrete s3.Client:

func NewS3Uploader(client S3Manager) *S3Uploader {
    return &S3Uploader{client: client}
}
Enter fullscreen mode Exit fullscreen mode

With this implementation, we can pass any type that has a PutObject method to the NewS3Uploader function.

For testing purposes, we can create a mock object that implements the S3Manager interface:

type MockS3Manager struct{}

func (m *MockS3Manager) PutObject(ctx context.Context, params *s3.PutObjectInput, optFns ...func(*s3.Options)) (*s3.PutObjectOutput, error) {
    // mocking logic
    return &s3.PutObjectOutput{}, nil
}
Enter fullscreen mode Exit fullscreen mode

We can then pass this MockS3Manager to the NewS3Uploader function when writing unit testing.

mockUploader := NewS3Uploader(&MockS3Manager{})
Enter fullscreen mode Exit fullscreen mode

This approach allows us to test the Upload method easily without actually interacting with the S3 service.

After using the interface, our diagram looks like this:
unit-testing-with-interfaceIn this new structure, the S3Uploader struct depends on the S3Manager interface. Both s3.Client and MockS3Manager implement the S3Manager interface. This allows us to use s3.Client for the real S3 service and MockS3Manager for mocking during unit tests.

As you might have noticed, this is also an excellent example of polymorphism in action.

Note: You can find the complete code in this repository.

 

Decoupling

In software design, it's recommended to decouple dependencies between modules. Decoupling means making the dependencies between modules as loose as possible. It helps us develop software in a more flexible way.

To use an analogy, we can think of a middleman sitting between two modules:
decoupling-exampleIn this case, Module A depends on the middleman, instead of directly depending on Module B.

You might wonder, what's the benefit of doing this?
Let's look at an example.

Imagine we're building a web application that takes an ID as a parameter to get a user's name. In this application, we have two packages: handler and service.
The handler package is responsible for handling HTTP requests and responses.
The service package is responsible for retrieving the user's name from the database.

Let's first look at the code for the handler package:

package handler

type Handler struct {
    // we'll implement MySQLService later
    service service.MySQLService
}

func (h *Handler) GetUserName(w http.ResponseWriter, r *http.Request) {
    id := r.URL.Query().Get("id")

    if !isValidID(id) {
        http.Error(w, "Invalid user ID", http.StatusBadRequest)
        return
    }

    userName, err := h.service.GetUserName(id)
    if err != nil {
        http.Error(w, fmt.Sprintf("Error retrieving user name: %v", err), http.StatusInternalServerError)
        return
    }

    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(userName)
}
Enter fullscreen mode Exit fullscreen mode

This code is straightforward. The Handler struct has a service field. For now, it depends on the MySQLService struct, which we'll implement later. It uses h.service.GetUserName(id) to get the user's name. The handler package's job is to handle HTTP requests and responses, as well as validation.

Now, let's look at the service package:

package service

type MySQLService struct {
    sql *sql.DB
}

func NewMySQLService(sql *sql.DB) *MySQLService {
    return &MySQLService{sql: sql}
}

func (s *MySQLService) GetUserName(id string) (string, error) {
    // get user name from database
}
Enter fullscreen mode Exit fullscreen mode

Here, the MySQLService struct has an sql field, and it retrieves the user's name from the database.

In this implementation, the Handler struct is directly dependent on the MySQLService struct:
decoupling-without-interfaceThis might not seem like a big deal at first, but if we want to switch to a different database, we'd have to modify the Handler struct to remove the dependency on the MySQLService struct and create a new struct for the new database.
decoupling-change-without-interfaceThis violates the principle of decoupling. Typically, changes in one package should not affect other packages.

 

To fix this problem, we can use an interface.
We can define a Service interface that has a GetUserName method:

type Service interface {
    GetUserName(id string) (string, error)
}
Enter fullscreen mode Exit fullscreen mode

We can use this Service interface as the type of the service field in the Handler struct:

package handler

type Service interface {
    GetUserName(id string) (string, error)
}

type Handler struct {
    service Service // now it depends on the Service interface
}

func (h *Handler) GetUserName(w http.ResponseWriter, r *http.Request) {
    // same as before

    // Get the user from the service
    user, err := h.service.GetUserName(id)
    if err != nil {
        http.Error(w, "Error retrieving user: "+err.Error(), http.StatusInternalServerError)
        return
    }

    // same as before
}
Enter fullscreen mode Exit fullscreen mode

In this implementation, the Handler struct is no longer dependent on the MySQLService struct. Instead, it depends on the Service interface:
decoupling-with-interfaceIn this design, the Service interface acts as a middleman between Handler and MySQLService.
For Handler, it now depends on behavior, rather than a concrete implementation. It doesn't need to know the details of the Service interface, such as what database it uses. It only needs to know that the Service has a GetUserName method.

When we need to switch to a different database, we can just simply create a new struct that implements the Service interface without changing the Handler struct.
decoupling-change-with-interface

When designing code structure, we should always depend on behavior rather than implementation.
It's better to depend on something that has the behavior you need, rather than depending on a concrete implementation.

Note: You can find the complete code in this repository.

 

Working With the Standard Library

As you gain more experience with Go, you'll find that interfaces are everywhere in the standard library.
Let's use the error interface as an example.

In Go, error is simply an interface with one method, Error() string:

type error interface {
    Error() string
}
Enter fullscreen mode Exit fullscreen mode

This means that any type with an Error method matching this signature implements the error interface. We can leverage this feature to create our own custom error types.

Suppose we have a function to log error messages:

func LogError(err error) {
    log.Fatal(fmt.Errorf("received error: %w", err))
}
Enter fullscreen mode Exit fullscreen mode

While this is a simple example, in practice, the LogError function might include additional logic, such as adding metadata to the error message or sending it to a remote logging service.

Now, let's define two custom error types, OrderError and PaymentDeclinedError. These have different fields, and we want to log them differently:

// OrderError represents a general error related to orders
type OrderError struct {
    OrderID string
    Message string
}

func (e OrderError) Error() string {
    return fmt.Sprintf("Order %s: %s", e.OrderID, e.Message)
}

// PaymentDeclinedError represents a payment failure
type PaymentDeclinedError struct {
    OrderID string
    Reason  string
}

func (e PaymentDeclinedError) Error() string {
    return fmt.Sprintf("Payment declined for order %s: %s", e.OrderID, e.Reason)
}
Enter fullscreen mode Exit fullscreen mode

Because both OrderError and PaymentDeclinedError have an Error method with the same signature as the error interface, they both implement this interface. Consequently, we can use them as arguments to the LogError function:

LogError(OrderError{OrderID: "123", Message: "Order not found"})
LogError(PaymentDeclinedError{OrderID: "123", Reason: "Insufficient funds"})
Enter fullscreen mode Exit fullscreen mode

This is another excellent example of polymorphism in Go: one interface, multiple implementations. The LogError function can work with any type that implements the error interface, allowing for flexible and extensible error handling in your Go programs.

Note: You can find the complete code in this repository.

 

Summary

In this article, we've explored the concept of interfaces in Go, starting with a simple analogy and gradually moving to more complex examples.

Key takeaways about Go interfaces:

  • They are all about abstraction
  • They are defined as a set of method signatures
  • They define behavior without specifying implementation details
  • They are implemented implicitly (no explicit declaration needed)

I hope this article has helped you gain a better understanding of interfaces in Go.

 

Reference

 

As I'm not an experienced Go developer, I welcome any feedback. If you've noticed any mistakes or have suggestions for improvement, please leave a comment. Your feedback is greatly appreciated and will help enhance this resource for others.

Top comments (0)