Skip to main content
  1. Articles/

Golang Tutorial: Generics

·3344 words·16 mins· loading · loading ·
Marko Milojevic
Author
Marko Milojevic
Software engineer and architect. Golang and LLM enthusiast. Awful chess player, gym rat, harmonica newbie and cat lover.
Generics in Golang - This article is part of a series.
Part 1: This Article

How often do we encounter significant changes in our preferred programming language? Some languages undergo frequent updates, while others remain traditional and stable. Go falls into the latter category, known for its consistency. “This is not the Go way!” is a phrase that often comes to mind. Most Go releases have focused on refining its existing principles. However, a major shift is on the horizon. The Go team has announced that Generics in Go are becoming a reality, moving beyond mere discussion and into implementation.

Brace yourselves, a revolution is coming.

What are Generics?
#

Generics allow us to parameterize types when defining interfaces, functions, and structs.

Generics is not a new concept. It has been used since the first version of Ada, through templates in C++, to its modern implementations in Java and C#. To illustrate without delving into complex definitions, let’s examine a practical example. Instead of having multiple Max or Min functions like this:

Without Generics

func MaxInt(a, b int) int {
	// some code
}

func MaxFloat64(a, b float64) float64 {
	// some code
}

func MaxByte(a, b byte) byte {
	// some code
}

we can declare now only one method, like this:

With Generics

func Max[T constraints.Ordered](a, b T) T {
	// some code
}

Wait, what just happened? Instead of defining a method for each type in Go, we utilized Generics. We used a generic type, parameter T, as an argument for the method. With this minor adjustment, we can support all orderable types. The parameter T can represent any type that satisfies the Ordered constraint (we will discuss constraints later). Initially, we need to specify what kind of type T is. Next, we determine where we want to use this parameterized type. In this case, we’ve specified that both input arguments and the output should be of type T. If we execute the method by defining T as an integer, then everything here will be an integer:

Execute Generic Function

func main() {
	fmt.Println(Max[int](1, 2))
}
//
// this code behaves exactly like method:
// Max(a, b int) int

And it doesn’t stop there. We can provide as many parameterized types as we need and assign them to different input and output arguments as desired:

Execute some complex Generic Function

func Do[R any, S any, T any](a R, b S) T {
	// some code
}

func main() {
	fmt.Println(Do[int, uint, float64](1, 2))
}
//
// this code behaves exactly like method:
// Do(a int, b uint) float64

Here we have three parameters: R, S, and T. As we can see from the any constraint (which behaves like interface{}), those types can be, well, anything. So, up to this point, we should have a clear understanding of what generics are and how we use them in Go. Let’s now focus on more exciting consequences.

Speed, give me what I need
#

Generics in Go are not the same as reflection.

Before delving into complex examples, it’s essential to check the benchmark scores for generics. Logically, we do not expect performance similar to reflection because if that were the case, we would not need generics at all. Generics are not in any way comparable to reflection and were never intended to be. If anything, generics are an alternative for code generation in some use cases. Our expectation is that code based on generics should have similar benchmark results as code executed in a more traditional way. So, let’s examine a basic case:

A Generic Function for Benchmark

package main

import (
	"constraints"
	"fmt"
)

type Number interface {
	constraints.Integer | constraints.Float
}

func Transform[S Number, T Number](input []S) []T {
	output := make([]T, 0, len(input))
	for _, v := range input {
		output = append(output, T(v))
	}
	return output
}

func main() {
	fmt.Printf("%#v", Transform[int, float64]([]int{1, 2, 3, 6}))
}
//
//
// Out:
// []float64{1, 2, 3, 6}

Here are small methods for transforming one Number type to another. Number is our constraint, built on the Integer and the Float constraints from the Go standard library (we will cover this topic later). Number can be any numerical type in Go, from any derivative of int to uint, float, and so on. The Transform methods expect a slice with the first parametrized numerical type S as the slice’s base and transform it into a slice with the second parametrized type T as the slice’s base. In short, if we want to transform a slice of ints into a slice of floats, we would call this method as we do in the main function. The non-generics alternative for our function would be a method that expects a slice of ints and returns a slice of floats. So, that is what we will test in our benchmark:

Benchmark

func BenchmarkGenerics(b *testing.B) {
	for i := 0; i < b.N; i++ {
		Transform[int, float64]([]int{1, 2, 3, 6})
	}
}

func TransformClassic(input []int) []float64 {
	output := make([]float64, 0, len(input))
	for _, v := range input {
		output = append(output, float64(v))
	}
	return output
}

func BenchmarkClassic(b *testing.B) {
	for i := 0; i < b.N; i++ {
		TransformClassic([]int{1, 2, 3, 6})
	}
}
//
//
// Out:
// goos: darwin
// goarch: amd64
// pkg: test/generics
// cpu: Intel(R) Core(TM) i5-1038NG7 CPU @ 2.00GHz
//
// first run:
// BenchmarkGenerics
// BenchmarkGenerics-8   	38454709	        31.80 ns/op
// BenchmarkClassic
// BenchmarkClassic-8    	36445143	        34.83 ns/op
// PASS
//
// second run:
// BenchmarkGenerics
// BenchmarkGenerics-8   	34619782	        33.48 ns/op
// BenchmarkClassic
// BenchmarkClassic-8    	36784915	        31.78 ns/op
// PASS
//
// third run:
// BenchmarkGenerics
// BenchmarkGenerics-8   	36157389	        33.38 ns/op
// BenchmarkClassic
// BenchmarkClassic-8    	37115414	        32.30 ns/op
// PASS

No surprises here. The execution time is practically the same for both methods, so using generics does not impact the performance of our application. But are there any repercussions for structs? Let’s try that. Now, we will use structs and attach methods to them. The task will be the same — converting one slice into another:

Another Benchmark

func BenchmarkGenerics(b *testing.B) {
	for i := 0; i < b.N; i++ {
		Transform[int, float64]([]int{1, 2, 3, 6})
	}
}

func TransformClassic(input []int) []float64 {
	output := make([]float64, 0, len(input))
	for _, v := range input {
		output = append(output, float64(v))
	}
	return output
}

func BenchmarkClassic(b *testing.B) {
	for i := 0; i < b.N; i++ {
		TransformClassic([]int{1, 2, 3, 6})
	}
}
//
//
// Out:
// goos: darwin
// goarch: amd64
// pkg: test/generics
// cpu: Intel(R) Core(TM) i5-1038NG7 CPU @ 2.00GHz
//
// first run:
// BenchmarkGenerics
// BenchmarkGenerics-8   	38454709	        31.80 ns/op
// BenchmarkClassic
// BenchmarkClassic-8    	36445143	        34.83 ns/op
// PASS
//
// second run:
// BenchmarkGenerics
// BenchmarkGenerics-8   	34619782	        33.48 ns/op
// BenchmarkClassic
// BenchmarkClassic-8    	36784915	        31.78 ns/op
// PASS
//
// third run:
// BenchmarkGenerics
// BenchmarkGenerics-8   	36157389	        33.38 ns/op
// BenchmarkClassic
// BenchmarkClassic-8    	37115414	        32.30 ns/op
// PASS

Again, no surprises. Using generics or the classic implementation does not have any impact on the performance of the Go code. Yes, it is true that we did not test too complex cases, but if there were a significant difference, we would have already noticed it. So, we are safe to proceed.

Constraints
#

If we want to test more complex examples, simply adding any parametrized type and running the application is not enough. If we decide to create a simple example with some variables without any complex calculations, we will not need to add anything special:

A simple Generic Function

func Max[T interface{}](a, b T) (T, T) {
	return a, b
}

func main() {
	fmt.Println(Max(1, 2))
	fmt.Println(Max(3.0, 2.0))
}
//
//
// Out:
// 1 2
// 3 2

If we want to test more complex examples, simply adding any parameterized type and running the application is not enough. Suppose we decide to create a simple example with some variables without any complex calculations. In that case, we will not need to add anything special, except that our method Max does not calculate the maximum value of its inputs but returns them both. There is nothing strange in the example above.

To achieve this, we use a parameterized type T, defined as interface{}. In this example, we should not view interface{} as a type but as a constraint. We use constraints to define rules for our parameterized types and provide the Go compiler with some context on what to expect. To reiterate, we do not use interface{} here as a type but as a constraint. We define rules for the parameterized type, and in this case, that type must support whatever interface{} does. So, practically, we could also use the any constraint here. (To be honest, in all the examples, I have preferred interface{} instead of any, to respect the “good old days”.)

During compile-time, the compiler can take a constraint and use it to check if the parameterized type supports operators and methods that we want to execute in the following code. As the compiler does most of the optimization at runtime (and therefore, we do not impact runtime, as we could see in the benchmark), it allows only the operators and functions defined for particular constraints. So, to understand the importance of constraints, let us finish implementing the Max method and try to compare the a and b variables:

Failed execution of Generic Function

func Max[T any](a, b T) T {
	if a > b {
		return a
	}
	return b
}

func main() {
	fmt.Println(Max(1, 2))
	fmt.Println(Max(3.0, 2.0))
}
//
//
// Out:
// ./main.go:6:5: invalid operation: cannot compare a > b (operator > not defined on T)

When we attempt to run the application, we encounter an error — “operator > not defined on T.” Since we defined the T type as any, the final type can be, well, anything. At this point, the compiler does not know how to handle the > operator.

To resolve this issue, we must define the parameterized type T as a constraint that allows such an operator. Fortunately, thanks to the Go team, we have the Constraints package, which includes such a constraint. The constraint we want to use is called Ordered, and after making this adjustment, our code works perfectly:

Ordered Constraint

func Max[T constraints.Ordered](a, b T) T {
	if a > b {
		return a
	}
	return b
}

func main() {
	fmt.Println(fmt.Sprintf("%T", Max(1, 2)))
	fmt.Println(Max(1, 2))

	fmt.Println(fmt.Sprintf("%T", Max(3.0, 2.0)))
	fmt.Println(Max(3.0, 2.0))

	fmt.Println(fmt.Sprintf("%T", Max[int](1, 2)))
	fmt.Println(Max[int](1, 2))

	fmt.Println(fmt.Sprintf("%T", Max[int64](1, 2)))
	fmt.Println(Max[int64](1, 2))

	fmt.Println(fmt.Sprintf("%T", Max[float64](3.0, 2.0)))
	fmt.Println(Max[float64](3.0, 2.0))

	fmt.Println(fmt.Sprintf("%T", Max[float32](3.0, 2.0)))
	fmt.Println(Max[float32](3.0, 2.0))
}
//
//
// Out:
// int --> Max(1, 2)
// 2
// float64 --> Max(3.0, 2.0)
// 3
// int --> Max[int](1, 2)
// 2
// int64 --> Max[int64](1, 2)
// 2
// float64 --> Max[float64](3.0, 2.0)
// 3
// float32 --> Max[float32](3.0, 2.0)
// 3

By using the Ordered constraint, we achieved the desired result. One interesting aspect of this example is how the compiler interprets the final type T, depending on the values we pass to the method. Without specifying the actual type in square brackets, as shown in the first two cases, the compiler can deduce the type used for the arguments — in the case of Go, this would be int and float64.

However, if we intend to use types other than the default ones, such as int64 or float32, we should explicitly provide these types in square brackets. This way, we give the compiler precise information about what to expect. If we wish, we can extend the functionality of the Max function to support finding the maximum value within an array:

Zero values

func Max[T constraints.Ordered](a []T) (T, error) {
	if len(a) == 0 {
		return T(0), errors.New("empty array")
	}
	max := a[0]

	for i := 1; i < len(a); i++ {
		if a[i] > max {
			max = a[i]
		}
	}

	return max, nil
}

func main() {
	fmt.Println(Max([]string{}))
	fmt.Println(Max([]string{"z", "a", "f"}))
	fmt.Println(Max([]int{1, 2, 5, 3}))
	fmt.Println(Max([]float32{4.0, 5.0, 2.0}))
	fmt.Println(Max([]float32{}))
}
//
//
// Out:
//  empty array
// z <nil>
// 5 <nil>
// 5 <nil>
// 0 empty array

In this example, two interesting points emerge:

  1. After defining type T within square brackets, we can use it in various ways within the function signature, whether as a simple type, a slice type, or even as part of a map.

  2. To return the zero value of a specific type, we can use T(0). The Go compiler is intelligent enough to convert the zero value into the desired type, such as an empty string in the first case.

We’ve also seen how constraints work when comparing values of a certain type. With the Ordered constraint, we can use any operator defined on integers, floats, and strings. However, if we wish to use the == operator exclusively, we can utilize the new reserved word comparable, a unique constraint that only supports this operator and nothing else:

Comparable Constraint

func Equal[T comparable](a, b T) bool {
	return a == b
}

func Dummy[T any](a, b T) (T, T) {
	return a, b
}

func main() {
	fmt.Println(Equal("a", "b"))
	fmt.Println(Equal("a", "a"))
	fmt.Println(Equal(1, 2))
	fmt.Println(Equal(1, 1))
	fmt.Println(Dummy(5, 6))
	fmt.Println(Dummy("e", "f"))
}
//
//
// Out:
// false
// true
// false
// true
// 5 6
// e f

In the example above, we can observe the proper usage of the comparable constraint. It’s worth noting that the compiler can infer the actual types even without the need to strictly define them within square brackets.

An interesting point to highlight in this example is that we used the same letter, T, for both parameterized types in two different methods, Equal and Dummy. It’s important to understand that each T type is defined within the scope of its respective method (or struct and its methods), and these T types do not refer to the same type outside of their respective scopes. This means that you can use the same letter T in different methods, and the types will remain independent of each other.

Custom constraints
#

Creating custom constraints in Go is straightforward. You can define a constraint as any type, but using an interface is often the best choice. Here’s how you can do it:

Custom Constraint

type Greeter interface {
	Greet()
}

func Greetings[T Greeter](t T) {
	t.Greet()
}

type EnglishGreeter struct{}

func (g EnglishGreeter) Greet() {
	fmt.Println("Hello!")
}

type GermanGreeter struct{}

func (g GermanGreeter) Greet() {
	fmt.Println("Hallo!")
}

func main() {
	Greetings(EnglishGreeter{})
	Greetings(GermanGreeter{})
}

//
//
// Out:
// Hello!
// Hallo!

We’ve created an interface called Greeter to use it as a constraint in the Greetings method. While we could use a Greeter variable directly instead of generics, we’ve used generics here for demonstration purposes.

Type sets
#

Every type has an associated type set. The type set of an ordinary non-interface type T consists of just T itself, represented as the set {T}. In the case of an interface type (for this discussion, we are focusing solely on ordinary interface types without type lists), the type set comprises all types that declare all the methods of that interface.

The definition mentioned above comes from a proposal regarding type sets, and it has already been incorporated into the Go source code. This significant change has opened up new possibilities for us. Notably, our interface types can now embed primitive types such as int, float64, and byte, not limited to other interfaces. This enhancement allows us to define more versatile constraints. Let’s explore the following example:

Custom Comparable Constraint as a Type Set

type Comparable interface {
	~int | float64 | rune
}

func Compare[T Comparable](a, b T) bool {
	return a == b
}

type customInt int

func main() {
	fmt.Println(Compare(1, 2))
	fmt.Println(Compare(customInt(1), customInt(1)))
	fmt.Println(Compare('a', 'a'))
	fmt.Println(Compare(1.0, 2.0))
}

//
//
// Out:
// false
// true
// true
// false

We’ve defined our Comparable constraint, and it might appear a bit unusual, doesn’t it? The new approach with type sets in Go now allows us to create an interface that represents a union of types. To specify a union between two types, we simply include them within the interface and use the | operator to separate them. In our example, the Comparable interface constitutes a union of types: rune, float64, and indeed, int. However, int is designated here as an approximation element.

As demonstrated in the proposal for type sets, the type set of an approximation element T encompasses not just type T itself but also all types whose underlying type is T. Consequently, by employing the ~int approximation element, we can supply variables of our customInt type to the Compare method. You’ll notice that we’ve defined customInt as a custom type with int as its underlying type. If we neglect to include the ~ operator, the compiler will issue an error, preventing the execution of the application. This represents a notable advancement in our understanding of these concepts.

How far can we go?
#

We have the freedom to go wherever we please. Seriously, this feature has revolutionized the language. I mean, new code is constantly emerging, and this could have a significant impact on packages that rely on code generation, such as Ent. Starting from the standard library, I can already envision many codes being refactored in future versions to incorporate generics. Generics could even pave the way for the development of an ORM, similar to what we’re accustomed to seeing in Doctrine, for instance.

Let’s take a model from the Gorm package as an example:

Gorm Example

type ProductGorm struct {
	gorm.Model
	Name  string
	Price uint
}

type UserGorm struct {
	gorm.Model
	FirstName string
	LastName  string
}

Imagine that we want to implement the Repository pattern in Go for both models (ProductGorm and UserGorm). With the current stable version of Go, we can only choose one of the following solutions:

  1. Write two separate Repository structs.
  2. Write a code generator that uses a template to create those two Repository structs.
  3. Decide not to use the Repository pattern.

Now, with generics, the horizon of opportunities has shifted towards a more flexible approach, and we can do something like this:

Gorm and Generics

type Repository[T any] struct {
	db *gorm.DB
}

func (r *Repository[T]) Create(t T) error {
	return r.db.Create(&t).Error
}

func (r *Repository[T]) Get(id uint) (*T, error) {
	var t T
	err := r.db.Where("id = ?", id).First(&t).Error
	return &t, err
}

So, we have our Repository struct with a parameterized type T, which can be anything. It’s worth noting that we defined T only in the Repository type definition, and we simply assigned its associated functions. In this example, we have only two methods, Create and Get, for demonstration purposes. To simplify our demonstration, let’s create two separate methods for initializing different Repositories:

Declare new Repositories

func NewProductRepository(db *gorm.DB) *Repository[ProductGorm] {
	db.AutoMigrate(&ProductGorm{})

	return &Repository[ProductGorm]{
		db: db,
	}
}

func NewUserRepository(db *gorm.DB) *Repository[UserGorm] {
	db.AutoMigrate(&UserGorm{})

	return &Repository[UserGorm]{
		db: db,
	}
}

These two methods return instances of Repositories with predefined types, essentially serving as shortcuts. Now, let’s conduct the final test of our small application:

Test new Repositories

func main() {
	db, err := gorm.Open(sqlite.Open("test.db"), &gorm.Config{})
	if err != nil {
		panic("failed to connect database")
	}

	productRepo := NewProductRepository(db)
	productRepo.Create(ProductGorm{
		Name:  "product",
		Price: 100,
	})

	fmt.Println(productRepo.Get(1))

	userRepo := NewUserRepository(db)
	userRepo.Create(UserGorm{
		FirstName: "first",
		LastName:  "last",
	})
	fmt.Println(userRepo.Get(1))
}
//
//
// Out:
// &{{1 2021-11-23 22:50:14.595342 +0100 +0100 2021-11-23 22:50:14.595342 +0100 +0100 {0001-01-01 00:00:00 +0000 UTC false}}  100} <nil>
// &{{1 2021-11-23 22:50:44.802705 +0100 +0100 2021-11-23 22:50:44.802705 +0100 +0100 {0001-01-01 00:00:00 +0000 UTC false}} first last} <nil>

And it works! One implementation for Repository that supports two models, all without the need for reflection or code generation. This is something I never thought I would see in Go.

Conclusion
#

There’s no doubt that Generics in Go are a monumental change. This change has the potential to significantly alter how Go is used and may lead to numerous refactors within the Go community in the near future. While I’ve been experimenting with generics on a daily basis, exploring their possibilities, I can’t wait to see them in the stable Go version. Viva la Revolution!

Useful Resources
#

Generics in Golang - This article is part of a series.
Part 1: This Article

Related

Practical SOLID in Golang: Dependency Inversion Principle

·2306 words·11 mins· loading · loading
Learning a new programming language is often a straightforward process. I often hear: “The first programming language you learn in a year. The second one in a month. The third one in a week, and then each next one in a day.” Saying that is an exaggeration, but it is not too distant from the truth in some cases. For example, jumping to a language relatively similar to the previous one, like Java and C#, can be a straightforward process. But sometimes, switching is tricky, even when we switch from one Object-Oriented language to another. Many features influence such transitions, like strong or weak types, if a language has interfaces, abstract classes, or classes at all. Some of those difficulties we experience immediately after switching, and we adopt a new approach. But some issues we experience later, during unit testing, for example. And then, we learn why The Dependency Inversion Principle is essential, especially in Go. When we do not respect The Dependency Inversion # High-level modules should not depend on low-level modules. Both should depend on abstractions. Abstractions should not depend on details. Details should depend on abstractions. Above is the definition of DIP as presented by Uncle Bob in his paper. There are also more details inside his blog. So, how can we understand this, especially in the context of Go? First, we should accept Abstraction as an object-oriented programming concept. We use this concept to expose essential behaviors and hide the details of their implementation. Second, what are high and low-level modules? In the context of Go, high-level modules are software components used at the top of the application, such as code used for presentation. It can also be code close to the top level, like code for business logic or some use-case components. It is essential to understand it as a layer that provides real business value to our application. On the other hand, low-level software components are mostly small code pieces that support the higher level. They hide technical details about different infrastructural integrations. For example, this could be a struct that contains the logic for retrieving data from the database, sending an SQS message, fetching a value from Redis, or sending an HTTP request to an external API. So, what does it look like when we break The Dependency Inversion Principle, and our high-level component depends on one low-level component?

Practical SOLID in Golang: Interface Segregation Principle

·1722 words·9 mins· loading · loading
When beginners embark on their programming journey, the initial focus is typically on algorithms and adapting to a new way of thinking. After some time, they delve into Object-Oriented Programming (OOP). If this transition is delayed, it can be challenging to shift from a functional programming mindset. However, eventually, they embrace the use of objects and incorporate them into their code where necessary, sometimes even where they’re not needed. As they learn about abstractions and strive to make their code more reusable, they may overgeneralize, resulting in abstractions applied everywhere, which can hinder future development. At some point, they come to realize the importance of setting boundaries for excessive generalization. Fortunately, The Interface Segregation Principle has already provided a guideline for this, representing the “I” in the word SOLID. When we do not respect The Interface Segregation # Maintain small interfaces to prevent users from relying on unnecessary features. Uncle Bob introduced this principle, and you can find more details about it on his blog. This principle clearly states its requirement, perhaps better than any other SOLID principle. Its straightforward advice to keep interfaces as small as possible should not be interpreted as merely advocating one-method interfaces. Instead, we should consider the cohesion of features that an interface encompasses. Let’s analyze the code below: User interface type User interface { AddToShoppingCart(product Product) IsLoggedIn() bool Pay(money Money) error HasPremium() bool HasDiscountFor(product Product) bool // // some additional methods // } Let’s assume we want to create an application for shopping. One approach is to define an interface User, as demonstrated in the code example. This interface includes various features that a user can have. On our platform, a User can add a Product to the ShoppingCart, make a purchase, and receive discounts on specific Products. However, the challenge is that only specific types of Users can perform all of these actions. Guest struct type Guest struct { cart ShoppingCart // // some additional fields // } func (g *Guest) AddToShoppingCart(product Product) { g.cart.Add(product) } func (g *Guest) IsLoggedIn() bool { return false } func (g *Guest) Pay(Money) error { return errors.New("user is not logged in") } func (g *Guest) HasPremium() bool { return false } func (g *Guest) HasDiscountFor(Product) bool { return false } We have implemented this interface with three structs. The first one is the Guest struct, representing a user who is not logged in but can still add a Product to the ShoppingCart. The second implementation is the NormalCustomer, which can do everything a Guest can, plus make a purchase. The third implementation is the PremiumCustomer, which can use all features of our system.

Practical SOLID in Golang: Liskov Substitution Principle

·1880 words·9 mins· loading · loading
I’m not really a fan of reading. Often, when I do read, I find myself losing track of the text’s topic for the past few minutes. Many times, I’ll go through an entire chapter without really grasping what it was all about in the end. It can be frustrating when I’m trying to focus on the content, but I keep realizing I need to backtrack. That’s when I turn to various types of media to learn about a topic. The first time I encountered this reading issue was with the SOLID principle, specifically the Liskov Substitution Principle. Its definition was (and still is) too complicated for my taste, especially in its formal format. As you can guess, LSP represents the letter “L” in the word SOLID. It’s not difficult to understand, although a less mathematical definition would be appreciated. When we do not respect The Liskov Substitution # The first time we encountered this principle was in 1988, thanks to Barbara Liskov. Later, Uncle Bob shared his perspective on this topic in a paper and eventually included it as one of the SOLID principles. Let’s take a look at what it says: Let Φ(x) be a property provable about objects x of type T. Then Φ(y) should be true for objects y of type S where S is a subtype of T. Well, good luck with that definition. No, seriously, what kind of definition is this? Even as I write this article, I’m still struggling to fully grasp this definition, despite my fundamental understanding of LSP. Let’s give it another shot: If S is a subtype of T, then objects of type T in a program may be replaced with objects of type S without altering any of the desirable properties of that program. Okay, this is a bit clearer now. If ObjectA is an instance of ClassA, and ObjectB is an instance of ClassB, and ClassB is a subtype of ClassA – if we use ObjectB instead of ObjectA somewhere in the code, the application’s functionality must not break. We’re talking about classes and inheritance here, two concepts that aren’t prominent in Go. However, we can still apply this principle using interfaces and polymorphism. Wrong implementation of Update method type User struct { ID uuid.UUID // // some fields // } type UserRepository interface { Update(ctx context.Context, user User) error } type DBUserRepository struct { db *gorm.DB } func (r *DBUserRepository) Update(ctx context.Context, user User) error { return r.db.WithContext(ctx).Delete(user).Error } In this code example, we can see one that’s quite absurd and far from best practices. Instead of updating the User in the database, as the Update method claims, it actually deletes it. But that’s precisely the point here. We have an interface, UserRepository, followed by a struct, DBUserRepository. While this struct technically implements the interface, it completely diverges from what the interface is supposed to do. In fact, it breaks the functionality of the interface rather than fulfilling its expectations. This highlights the essence of the Liskov Substitution Principle (LSP) in Go: a struct must not violate the intended behavior of the interface.