How to Avoid Premature Abstraction in Go

Avoid premature abstraction in Go by implementing concrete logic first and only extracting interfaces when duplication occurs across multiple use cases.

The copy-paste trap

You are building a service that sends notifications. First, you write an email sender. It connects to an SMTP server, formats the payload, and returns an error if the network drops. It works. Two weeks later, you need SMS. You copy the email function, rename it, swap the SMTP client for an HTTP POST to a telecom API, and tweak the JSON structure. Three weeks later, you need push notifications. You copy again. Now you have three nearly identical functions. Changing the retry logic means editing three places. You feel the urge to build a Notifier interface, a factory, and a generic dispatch loop. You pause. That pause is where good Go code is born.

What premature abstraction actually costs

Abstraction is not a virtue. It is a tax you pay to handle variation. Every interface you define adds a layer of indirection. Every generic type adds compile-time complexity. When you build the abstraction before you know the variation, you pay the tax twice. You pay once to build the wrong shape. You pay again to tear it down and replace it with the right one. Go makes this expensive by design. The language does not hide the cost. You see it in the call stack. You feel it in the test suite.

Think of abstraction like furniture. You do not buy a modular sofa system on day one. You buy a couch. When you move and need a chaise, you buy one. When you host guests and need a sleeper, you add it. You only commit to the modular system when you actually need the pieces to rearrange. Code works the same way. Concrete types are cheap. Interfaces are contracts. Sign the contract when you have two parties who need it.

Build the concrete thing first. Let duplication force the abstraction. Trust the signal over the guess.

Start with a struct

Go favors composition over inheritance and concrete types over abstract ones. The idiomatic path begins with a plain struct and a method attached to it. You define the data, you attach the behavior, and you ship it.

Here is the simplest notification sender:

// EmailSender handles outbound email delivery.
type EmailSender struct {
	// SMTPHost holds the mail server address.
	SMTPHost string
	// APIKey holds the authentication token.
	APIKey string
}

// Send delivers a message to the recipient address.
func (s *EmailSender) Send(to string, body string) error {
	// Validate the recipient format before network calls.
	if len(to) == 0 {
		return fmt.Errorf("missing recipient address")
	}
	// Open a connection to the SMTP server.
	// Send the envelope and payload.
	// Return nil on success or wrap the network error.
	return nil
}

The struct holds the configuration. The method holds the logic. The receiver is a pointer because the struct might grow, and passing a pointer avoids copying the fields on every call. The receiver name s matches the type EmailSender. Go convention favors one or two letter receiver names that hint at the type. You do not write (this *EmailSender) or (self *EmailSender). You write (s *EmailSender).

Public names start with a capital letter. Private start lowercase. There are no public or private keywords. The compiler enforces visibility through capitalization alone. This keeps the API surface explicit and predictable.

Start concrete. Ship the struct. Refactor when the duplication hurts.

How the compiler and runtime handle it

When you call sender.Send("user@example.com", "Hello"), the compiler resolves the method directly. There is no vtable lookup. There is no interface dispatch. The call compiles to a direct function call with the receiver pointer passed as the first argument. The layout is predictable. The optimizer can inline the function if it is small enough. The code runs at the speed of a plain function call.

You get the full power of the type system without the overhead of indirection. If you change the struct fields, the compiler catches every call site that depends on them. If you rename the method, the compiler rejects the program with undefined: Send at every usage. The feedback loop is immediate. You do not need a test suite to tell you that you broke the signature. The build does it for you.

Go's type system is static and explicit. When you work with concrete types, the compiler knows the exact memory layout. It knows the exact method set. It can eliminate bounds checks, unroll loops, and allocate on the stack when escape analysis permits. You get performance for free because you gave the compiler enough information to optimize.

Keep the compiler happy. Give it concrete types it can reason about.

The moment duplication forces a change

You ship the email sender. It works. Then you add SMS. You copy the struct, rename it to SSender, swap the SMTP logic for an HTTP POST to a telecom API, and adjust the payload format. The validation logic is identical. The retry logic is identical. The logging is identical. You now have two structs with three identical methods and one divergent method.

This is the signal. Duplication across two concrete types is the threshold where abstraction pays for itself. You extract the shared behavior into an interface. You do not guess what the interface should look like. You let the duplication tell you.

Here is the refactored shape:

// Notifier defines the contract for any delivery channel.
type Notifier interface {
	// Send delivers a message to the target address.
	Send(to string, body string) error
}

// SendNotification routes the message through the provided channel.
func SendNotification(n Notifier, to string, body string) error {
	// Log the outbound attempt before touching the network.
	// Execute the delivery and capture the error.
	err := n.Send(to, body)
	// Wrap the error with context so the caller knows which channel failed.
	if err != nil {
		return fmt.Errorf("notification delivery failed: %w", err)
	}
	return nil
}

The interface lives in the same package as the callers, not in a separate interfaces package. Go convention places interfaces where they are consumed, not where they are implemented. The SendNotification function accepts the interface. It does not care whether the underlying type is EmailSender, SSender, or a mock for tests. The concrete structs implement the interface implicitly. You do not write implements Notifier. You just satisfy the method set.

This follows the most common Go style mantra: accept interfaces, return structs. Functions take the minimal contract they need. They return concrete types so the caller knows exactly what they got. The boundary between abstraction and implementation stays clean.

Let duplication draw the line. Extract only when the pattern repeats.

Pitfalls of over-engineering

The temptation to abstract early usually comes from other languages. Java developers reach for abstract base classes. C++ developers reach for templates. Go developers sometimes reach for interfaces on day one. The result is the same. You build a shape that fits nothing.

When you define an interface too early, you lock yourself into a method signature that you do not fully understand. You add a Close() method because you think you will need it. You add a Config() method because you think you will need it. Six months later, only one implementation uses Close(). The other three panic when you call it. The interface becomes a burden. You spend more time managing the contract than shipping features.

The compiler will not save you from bad design. It will only tell you that the types match. If you pass a nil interface to a function, the program panics at runtime with interface conversion: interface is nil. If you forget to implement a method, the compiler rejects the program with cannot use type as type in argument. The errors are clear, but they do not tell you whether the abstraction is wrong. Only time and duplication tell you that.

Another trap is the factory pattern obsession. Go does not need a NewNotifier() function that returns an interface. You construct the concrete type directly. SendNotification(&EmailSender{...}, to, body) is clearer than SendNotification(NewNotifier("email"), to, body). The concrete type tells you exactly what is happening. The factory hides it. You also avoid the boilerplate of string-based type switching, which defeats the purpose of static typing.

Testing does not require interfaces. You can test concrete structs by injecting dependencies through the struct fields. You can test functions by passing mock implementations that satisfy the interface. You do not need to redesign your architecture to make tests possible. Go's implicit interface satisfaction makes mocking trivial. You write a struct with the required methods, pass it in, and verify the behavior.

Do not design for tests. Design for clarity. Tests will follow.

When to extract and when to leave it

You do not need a rigid rule. You need a pattern. Follow the duplication signal.

Use a concrete struct when you have a single implementation and the logic is straightforward. Use a concrete struct when the configuration fits in a few fields and the behavior does not vary. Use an interface when two or more types share the same method signature and you want to swap them at runtime. Use an interface when you need to inject a mock for testing and the concrete type depends on external services. Use a function type when the behavior is stateless and you only need to pass a callback. Use composition when you want to reuse a piece of behavior without declaring a contract. Use plain sequential code when you do not need abstraction: the simplest thing that works is usually the right thing.

Where to go next