A Tutorial on Object-Oriented Programming in Golang with a Focus on Well-Structured Logic

Can Go, a programming language known as “Golang,” be considered object-oriented? While Go is classified as a post-OOP language, drawing its structure from the Algol/Pascal/Modula family, object-oriented principles remain valuable for organizing programs effectively. This tutorial explores how to implement OOP concepts in Go, including binding functions to types (similar to classes), constructors, subtyping, polymorphism, dependency injection, and testing with mocks, using a practical example.

Deciphering Vehicle Identification Numbers (VINs) with Golang OOP

Each car’s unique vehicle identification number holds information beyond a serial number, including details about the manufacturer, factory, model, and steering wheel position.

Consider a function designed to extract the manufacturer code:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
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
}

A test case confirms its functionality:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
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)
  }
}

Despite working correctly with valid input, this function presents several issues:

  • Lack of input validation to ensure the provided string is a VIN.
  • Potential panic for strings shorter than three characters.
  • Inaccuracy for US car VINs due to the function assuming an optional second part of the ID, a feature specific to European VINs.

These problems can be addressed through refactoring using object-oriented patterns.

Go OOP: Associating Functions with Types

The initial refactoring step involves creating a dedicated VIN type and binding the Manufacturer() function to it, enhancing clarity and preventing unintended usage.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
package vin

type VIN string

func (v VIN) Manufacturer() string {

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

  return string(manufacturer)
}

The test case is then adjusted to accommodate the new type and demonstrate the issue of invalid VINs:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
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 inclusion of the last line showcases how a panic can occur with the Manufacturer() function, potentially leading to program crashes outside a testing environment.

Implementing Constructors in Golang OOP

To prevent panic situations caused by invalid VINs, one option is to incorporate validity checks directly within the Manufacturer() function. However, this approach necessitates checks with every function call and introduces error handling that complicates direct usage of the return value.

A more elegant solution is to implement a constructor for the VIN type, ensuring that the Manufacturer() function is invoked solely for valid VINs, eliminating the need for redundant checks and error management.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
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)
}

A corresponding test for the NewVIN function is added, verifying the rejection of invalid VINs:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
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)
  }
}

With the constructor in place, the test for the Manufacturer() function can omit testing invalid VINs.

Go OOP Pitfall: Misusing Polymorphism

To differentiate between European and non-European VINs, one approach could involve extending the VIN type into a struct and storing a flag indicating its origin, modifying the constructor accordingly:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
type VIN struct {
  code string
  european bool
}

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

  // ... checks ...

  return &VIN { code, european }, nil
}

A more refined approach is to create a subtype of VIN specifically for European VINs, implicitly storing the type information and simplifying the Manufacturer() function for non-European VINs:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
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, the EUVIN subtype would seamlessly work wherever the VIN type is expected. However, this behavior differs in Golang OOP.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
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 discrepancy arises from Go’s design choice to prioritize compile-time function resolution over dynamic binding for non-interface types, promoting efficiency but discouraging inheritance as a primary composition pattern in favor of interfaces.

Golang OOP Success: Employing Polymorphism Effectively

Go identifies a type as an interface implementation if it implements the defined functions (duck typing). To leverage polymorphism, the VIN type is transformed into an interface implemented by both general and European VIN types. It’s worth noting that the European VIN type need not be a subtype of the general one.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
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
}

With a minor adjustment, the polymorphism test now succeeds:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// 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)
    }
  }
}

Both VIN types can now be used interchangeably wherever the VIN interface is specified, ensuring compatibility.

Utilizing Dependency Injection in Object-oriented Golang

To determine a VIN’s origin, let’s assume an external API provides this information, and we have developed a client for it:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
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
}

Additionally, a service handles VIN creation and management:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
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 implementation functions correctly, as demonstrated by the modified test:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
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)
  }
}

However, the test relies on a live connection to the external API, introducing potential issues related to availability, latency, and cost.

Since the API call’s result is predetermined, replacing it with a mock becomes desirable. Unfortunately, the current code structure prevents easy replacement as the VINService directly creates the API client. To address this, dependency injection can be employed, whereby the API client is injected into the VINService instead of being internally instantiated.

A key principle in Golang OOP is to avoid constructor chaining. By adhering to this principle, all singletons used within an application are initialized at the top level, typically within a bootstrapping function responsible for creating necessary objects and establishing dependencies.

The first step involves defining the VINAPIClient as an interface:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
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
}

Subsequently, the client can be injected into the VINService:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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)
}

This modification enables the use of a API client mock during testing, avoiding external API calls and providing insights into API usage through probing. The example below demonstrates how to verify if the IsEuropean function is invoked.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
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)
  }
}

The test passes successfully, confirming the execution of the IsEuropean probe during the CreateFromCode call.

Achieving Success with Object-oriented Programming in Go

While some might question the use of Go for OOP, Go offers distinct advantages such as avoiding resource-intensive VMs/JITs, complex frameworks, and slow test execution times.

The provided example illustrates how object-oriented principles in Go can lead to more comprehensible and efficient code compared to a purely imperative approach. Despite not being strictly an OOP language, Go provides the necessary tools to structure applications in an object-oriented manner. By combining this with package-based functionality grouping, Go enables the creation of reusable modules for building robust applications.


Google Cloud Partner badge.

As a Google Cloud Partner, Toptal connects businesses with top-tier, Google-certified experts on demand for their most critical projects.

Licensed under CC BY-NC-SA 4.0