Back-end9 minute read

Well-structured Logic: A Golang OOP Tutorial

Can Golang be object-oriented? Go is post-OOP but can still leverage concepts like binding functions to types (aka classes), constructors, subtyping, polymorphism, dependency injection, and testing with mocks.


Toptalauthors are vetted experts in their fields and write on topics in which they have demonstrated experience. All of our content is peer reviewed and validated by Toptal experts in the same field.

Can Golang be object-oriented? Go is post-OOP but can still leverage concepts like binding functions to types (aka classes), constructors, subtyping, polymorphism, dependency injection, and testing with mocks.


Toptalauthors are vetted experts in their fields and write on topics in which they have demonstrated experience. All of our content is peer reviewed and validated by Toptal experts in the same field.
Leonhard Holz
Verified Expert in Engineering

Leonhard’s been a professional developer for 15 years. A Go expert, he loves the language for its simplicity, performance, and productivity.

PREVIOUSLY AT

Deutsche Telekom AG
Share

Is Go object-oriented? Can it be? Go (or “Golang”) is a post-OOP programming language that borrows its structure (packages, types, functions) from the Algol/Pascal/Modula language family. Nevertheless, in Go, object-oriented patterns are still useful for structuring a program in a clear and understandable way. This Golang tutorial will take a simple example and demonstrate how to apply the concepts of binding functions to types (aka classes), constructors, subtyping, polymorphism, dependency injection, and testing with mocks.

Case Study in Golang OOP: Reading the Manufacturer Code from a Vehicle Identification Number (VIN)

The unique vehicle identification number of every car includes—beside a “running” (i.e., serial) number—information about the car, such as the manufacturer, the producing factory, the car model, and if it is driven from the left- or right-hand side.

A function to determine the manufacturer code might look like this:

package vin

func Manufacturer(vin string) string {

  manufacturer := vin[: 3]
  // if the last digit of the manufacturer ID is a 9
  // the digits 12 to 14 are the second part of the ID
  if manufacturer[2] == '9' {
    manufacturer += vin[11: 14]
  }

  return manufacturer
}

And here is a test that proves that an example VIN works:

package vin_test

import (
  "vin-stages/1"
  "testing"
)

const testVIN = "W09000051T2123456"

func TestVIN_Manufacturer(t *testing.T) {

  manufacturer := vin.Manufacturer(testVIN)
  if manufacturer != "W09123" {
    t.Errorf("unexpected manufacturer %s for VIN %s", manufacturer, testVIN)
  }
}

So this function works correctly when given the right input, but it has some problems:

  • There is no guarantee that the input string is a VIN.
  • For strings shorter than three characters, the function causes a panic.
  • The optional second part of the ID is a feature of European VINs only. The function would return wrong IDs for US cars having a 9 as the third digit of the manufacturer code.

To solve these problems, we’ll refactor it using object-oriented patterns.

Go OOP: Binding Functions to a Type

The first refactoring is to make VINs their own type and bind the Manufacturer() function to it. This makes the purpose of the function clearer and prevents thoughtless usage.

package vin

type VIN string

func (v VIN) Manufacturer() string {

  manufacturer := v[: 3]
  if manufacturer[2] == '9' {
    manufacturer += v[11: 14]
  }

  return string(manufacturer)
}

We then adapt the test and introduce the problem of invalid VINs:

package vin_test

import(
  "vin-stages/2"
  "testing"
)

const (
  validVIN   = vin.VIN("W0L000051T2123456")
  invalidVIN = vin.VIN("W0")
)

func TestVIN_Manufacturer(t * testing.T) {

  manufacturer := validVIN.Manufacturer()
  if manufacturer != "W0L" {
    t.Errorf("unexpected manufacturer %s for VIN %s", manufacturer, validVIN)
  }

  invalidVIN.Manufacturer() // panic!
}

The last line was inserted to demonstrate how to trigger a panic while using the Manufacturer() function. Outside of a test, this would crash the running program.

OOP in Golang: Using Constructors

To avoid the panic when handling an invalid VIN, it’s possible to add validity checks to the Manufacturer() function itself. The disadvantages are that the checks would be done on every call to the Manufacturer() function, and that an error return value would have to be introduced, which would make it impossible to use the return value directly without an intermediate variable (e.g., as a map key).

A more elegant way is to put the validity checks in a constructor for the VIN type, so that the Manufacturer() function is called for valid VINs only and does not need checks and error handling:

package vin

import "fmt"

type VIN string

// it is debatable if this func should be named New or NewVIN
// but NewVIN is better for greping and leaves room for other
// NewXY funcs in the same package
func NewVIN(code string)(VIN, error) {

  if len(code) != 17 {
    return "", fmt.Errorf("invalid VIN %s: more or less than 17 characters", code)
  }

  // ... check for disallowed characters ...

  return VIN(code), nil
}

func (v VIN) Manufacturer() string {

  manufacturer := v[: 3]
  if manufacturer[2] == '9' {
    manufacturer += v[11: 14]
  }

  return string(manufacturer)
}

Of course, we add a test for the NewVIN function. Invalid VINs are now rejected by the constructor:

package vin_test

import (
  "vin-stages/3"
  "testing"
)

const (
  validVIN = "W0L000051T2123456"
  invalidVIN = "W0"
)

func TestVIN_New(t *testing.T) {

  _, err := vin.NewVIN(validVIN)
  if err != nil {
    t.Errorf("creating valid VIN returned an error: %s", err.Error())
  }

  _, err = vin.NewVIN(invalidVIN)
  if err == nil {
    t.Error("creating invalid VIN did not return an error")
  }
}

func TestVIN_Manufacturer(t *testing.T) {

  testVIN, _ := vin.NewVIN(validVIN)
  manufacturer := testVIN.Manufacturer()
  if manufacturer != "W0L" {
    t.Errorf("unexpected manufacturer %s for VIN %s", manufacturer, testVIN)
  }
}

The test for the Manufacturer() function can now omit testing an invalid VIN since it already would have been rejected by the NewVIN constructor.

Go OOP Pitfall: Polymorphism the Wrong Way

Next, we want to differentiate between European and non-European VINs. One approach would be to extend the VIN type to a struct and store whether the VIN is European or not, enhancing the constructor accordingly:

type VIN struct {
  code string
  european bool
}

func NewVIN(code string, european bool)(*VIN, error) {

  // ... checks ...

  return &VIN { code, european }, nil
}

The more elegant solution is to create a subtype of VIN for European VINs. Here, the flag is implicitly stored in the type information, and the Manufacturer() function for non-European VINs becomes nice and concise:

package vin

import "fmt"

type VIN string

func NewVIN(code string)(VIN, error) {

  if len(code) != 17 {
    return "", fmt.Errorf("invalid VIN %s: more or less than 17 characters", code)
  }

  // ... check for disallowed characters ...

  return VIN(code), nil
}

func (v VIN) Manufacturer() string {

  return string(v[: 3])
}

type EUVIN VIN

func NewEUVIN(code string)(EUVIN, error) {

  // call super constructor
  v, err := NewVIN(code)

  // and cast to subtype
  return EUVIN(v), err
}

func (v EUVIN) Manufacturer() string {

  // call manufacturer on supertype
  manufacturer := VIN(v).Manufacturer()

  // add EU specific postfix if appropriate
  if manufacturer[2] == '9' {
    manufacturer += string(v[11: 14])
  }

  return manufacturer
}

In OOP languages like Java, we would expect the subtype EUVIN to be usable in every place where the VIN type is specified. Unfortunately, this does not work in Golang OOP.

package vin_test

import (
  "vin-stages/4"
  "testing"
)

const euSmallVIN = "W09000051T2123456"

// this works!
func TestVIN_EU_SmallManufacturer(t *testing.T) {

  testVIN, _ := vin.NewEUVIN(euSmallVIN)
  manufacturer := testVIN.Manufacturer()
  if manufacturer != "W09123" {
    t.Errorf("unexpected manufacturer %s for VIN %s", manufacturer, testVIN)
  }
}

// this fails with an error
func TestVIN_EU_SmallManufacturer_Polymorphism(t *testing.T) {

  var testVINs[] vin.VIN
  testVIN, _ := vin.NewEUVIN(euSmallVIN)
  // having to cast testVIN already hints something is odd
  testVINs = append(testVINs, vin.VIN(testVIN))

  for _, vin := range testVINs {
    manufacturer := vin.Manufacturer()
    if manufacturer != "W09123" {
      t.Errorf("unexpected manufacturer %s for VIN %s", manufacturer, testVIN)
    }
  }
}

This behavior can be explained by the deliberate choice of the Go development team to not support dynamic binding for non-interface types. It enables the compiler to know which function will be called at compile time and avoids the overhead of dynamic method dispatch. This choice also discourages the use of inheritance as a general composition pattern. Instead, interfaces are the way to go (pardon the pun).

Golang OOP Success: Polymorphism the Right Way

The Go compiler treats a type as an implementation of an interface when it implements the declared functions (duck typing). Therefore, to make use of polymorphism, the VIN type is converted to an interface that is implemented by a general and a European VIN type. Note that it is not necessary for the European VIN type to be a subtype of the general one.

package vin

import "fmt"

type VIN interface {
  Manufacturer() string
}

type vin string

func NewVIN(code string)(vin, error) {

  if len(code) != 17 {
    return "", fmt.Errorf("invalid VIN %s: more or less than 17 characters", code)
  }

  // ... check for disallowed characters ...

  return vin(code), nil
}

func (v vin) Manufacturer() string {

  return string(v[: 3])
}

type vinEU vin

func NewEUVIN(code string)(vinEU, error) {

  // call super constructor
  v, err := NewVIN(code)

  // and cast to own type
  return vinEU(v), err
}

func (v vinEU) Manufacturer() string {

  // call manufacturer on supertype
  manufacturer := vin(v).Manufacturer()

  // add EU specific postfix if appropriate
  if manufacturer[2] == '9' {
    manufacturer += string(v[11: 14])
  }

  return manufacturer
}

The polymorphism test now passes with a slight modification:

// this works!
func TestVIN_EU_SmallManufacturer_Polymorphism(t *testing.T) {

  var testVINs[] vin.VIN
  testVIN, _ := vin.NewEUVIN(euSmallVIN)
  // now there is no need to cast!
  testVINs = append(testVINs, testVIN)

  for _, vin := range testVINs {
    manufacturer := vin.Manufacturer()
    if manufacturer != "W09123" {
      t.Errorf("unexpected manufacturer %s for VIN %s", manufacturer, testVIN)
    }
  }
}

In fact, both VIN types can now be used in every place that specifies the VIN interface, as both types comply with the VIN interface definition.

Object-oriented Golang: How to Use Dependency Injection

Last but not least, we need to decide if a VIN is European or not. Let’s suppose we have found an external API that gives us this information, and we’ve built a client for it:

package vin

type VINAPIClient struct {
  apiURL string
  apiKey string
  // ... internals go here ...
}

func NewVINAPIClient(apiURL, apiKey string) *VINAPIClient {

  return &VINAPIClient {apiURL, apiKey}
}

func (client *VINAPIClient) IsEuropean(code string) bool {

  // calls external API and returns correct value
  return true
}

We also have constructed a service that handles VINs and, notably, can create them:

package vin

type VINService struct {
  client *VINAPIClient
}

type VINServiceConfig struct {
  APIURL string
  APIKey string
  // more configuration values
}

func NewVINService(config *VINServiceConfig) *VINService {

  // use config to create the API client
  apiClient := NewVINAPIClient(config.APIURL, config.APIKey)

  return &VINService {apiClient}
}

func (s *VINService) CreateFromCode(code string)(VIN, error) {

  if s.client.IsEuropean(code) {
    return NewEUVIN(code)
  }

  return NewVIN(code)
}

This works fine as the modified test shows:

func TestVIN_EU_SmallManufacturer(t *testing.T) {

  service := vin.NewVINService( & vin.VINServiceConfig {})
  testVIN, _ := service.CreateFromCode(euSmallVIN)

  manufacturer := testVIN.Manufacturer()
  if manufacturer != "W09123" {
    t.Errorf("unexpected manufacturer %s for VIN %s", manufacturer, testVIN)
  }
}

The only issue here is that the test requires a live connection to the external API. This is unfortunate, as the API could be offline or just not reachable. Also, calling an external API takes time and may cost money.

As the result of the API call is known, it should be possible to replace it with a mock. Unfortunately, in the code above, the VINService itself creates the API client, so there is no easy way to replace it. To make this possible, the API client dependency should be injected into the VINService. That is, it should be created before calling the VINService constructor.

The Golang OOP guideline here is that no constructor should call another constructor. If this is thoroughly applied, every singleton used in an application will be created at the topmost level. Typically, this will be a bootstrapping function that creates all needed objects by calling their constructors in the appropriate order, choosing a suitable implementation for the intended functionality of the program.

The first step is to make the VINAPIClient an interface:

package vin

type VINAPIClient interface {
  IsEuropean(code string) bool
}

type vinAPIClient struct {
  apiURL string
  apiKey string
  // .. internals go here ...
}

func NewVINAPIClient(apiURL, apiKey string) *VINAPIClient {

  return &vinAPIClient {apiURL, apiKey}
}

func (client *VINAPIClient) IsEuropean(code string) bool {

  // calls external API and returns something more useful
  return true
}

Then, the new client can be injected into the VINService:

package vin

type VINService struct {
  client VINAPIClient
}

type VINServiceConfig struct {
  // more configuration values
}

func NewVINService(config *VINServiceConfig, apiClient VINAPIClient) *VINService {

  // apiClient is created elsewhere and injected here
  return &VINService {apiClient}
}

func (s *VINService) CreateFromCode(code string)(VIN, error) {

  if s.client.IsEuropean(code) {
    return NewEUVIN(code)
  }

  return NewVIN(code)
}

With that, it’s now possible to use an API client mock for the test. Besides avoiding calls to an external API during tests, the mock can also act as a probe to gather data about API usage. In the example below, we just check if the IsEuropean function is actually called.

package vin_test

import (
  "vin-stages/5"
  "testing"
)

const euSmallVIN = "W09000051T2123456"

type mockAPIClient struct {
  apiCalls int
}

func NewMockAPIClient() *mockAPIClient {

  return &mockAPIClient {}
}

func (client *mockAPIClient) IsEuropean(code string) bool {

  client.apiCalls++
  return true
}

func TestVIN_EU_SmallManufacturer(t *testing.T) {

  apiClient := NewMockAPIClient()
  service := vin.NewVINService( & vin.VINServiceConfig {}, apiClient)
  testVIN, _ := service.CreateFromCode(euSmallVIN)

  manufacturer := testVIN.Manufacturer()
  if manufacturer != "W09123" {
    t.Errorf("unexpected manufacturer %s for VIN %s", manufacturer, testVIN)
  }

  if apiClient.apiCalls != 1 {
    t.Errorf("unexpected number of API calls: %d", apiClient.apiCalls)
  }
}

This test passes, since our IsEuropean probe runs once during the call to CreateFromCode.

Object-oriented Programming in Go: A Winning Combination (When Done Right)

Critics might say, “Why not use Java if you’re doing OOP anyway?” Well, because you get all the other nifty advantages of Go while avoiding a resource-hungry VM/JIT, darned frameworks with annotation voodoo, exception handling, and coffee breaks while running tests (the latter might be a problem for some).

With the example above, it’s clear how doing object-oriented programming in Go can produce better-understandable and faster-running code compared to a plain, imperative implementation. Although Go is not meant to be an OOP language, it provides the tools necessary to structure an application in an object-oriented fashion. Together with grouping functionalities in packages, OOP in Golang can be leveraged to provide reusable modules as building blocks for large applications.


Google Cloud Partner badge.

As a Google Cloud Partner, Toptal’s Google-certified experts are available to companies on demand for their most important projects.

Understanding the basics

  • What is Golang used for?

    Golang (or simply “Go”) is a general-purpose language that is suitable for developing complex system tools and APIs. With automatic memory management, a static type system, built-in concurrency, and a rich, web-oriented runtime library, it is especially useful for distributed systems and cloud services.

  • What is Golang written in?

    Golang and its runtime packages are all written in Go. Until Golang 1.5, which was released in 2015, the compiler and parts of the runtime were written in C.

  • Is Golang object-oriented?

    The building blocks of Golang are types, functions, and packages—in contrast to classes in object-oriented languages like Java. However, three of the four concepts of OOP (encapsulation, abstraction, and polymorphism) are available, and the missing type hierarchy is replaced by interfaces and duck typing.

  • Is Golang worth learning?

    Golang is a memory-managed language with powerful primitives for common data structures and concurrent programming. It compiles directly to machine code, thus offering C-like performance and resource efficiency. Its fast compilation and included testing facilities are a boost to developer productivity.

  • Does Golang have a future?

    Golang is happily evolving and has recently celebrated its 10th anniversary. There are currently about 1 million active Golang developers worldwide using Golang for all sorts of projects. Also, Golang 2.0 is in the making and will bring exciting new features to Gophers (i.e., the wider Go developer community.)

  • How was Golang developed?

    Go was developed by Google to replace the in-house use of Python, C++, and other system languages. First released in 2009, the language core is updated every six months while keeping the programming interface stable. Go is open-source and has a rich community that actively participates in its development.

Hire a Toptal expert on this topic.
Hire Now
Leonhard Holz

Leonhard Holz

Verified Expert in Engineering

Berlin, Germany

Member since July 19, 2019

About the author

Leonhard’s been a professional developer for 15 years. A Go expert, he loves the language for its simplicity, performance, and productivity.

authors are vetted experts in their fields and write on topics in which they have demonstrated experience. All of our content is peer reviewed and validated by Toptal experts in the same field.

PREVIOUSLY AT

Deutsche Telekom AG

World-class articles, delivered weekly.

Subscription implies consent to our privacy policy

World-class articles, delivered weekly.

Subscription implies consent to our privacy policy

Join the Toptal® community.