Golang Tutorial - Interface (Lesson 21)
Interfaces are one of the most foundational and powerful concepts in Go (Golang) and software engineering in general. They provide a structural contract that allows developers to write modular, decoupled, testable, and highly scalable code. This chapter explores the motivation behind interfaces, how Go implements them implicitly without explicit keywords, and how they enable key architectural patterns like Dependency Injection and the Dependency Inversion Principle.
1. Why Do We Need Interfaces? The Core Problem Statement
In software development, applications constantly interact with external services, databases, third-party APIs, and vendor integrations. A classic real-world example is integrating a Payment Gateway into an application.
When building an initial prototype, developers often tightly couple business logic directly to a single specific vendor (e.g., Razorpay). However, real-world application requirements evolve rapidly:
- A business decision might require switching vendors (e.g., moving from Razorpay to Stripe).
- The application might need to support multiple payment gateways simultaneously based on user location or preference.
- Quality assurance requires unit testing business logic without triggering real third-party API calls or spending actual money.
Without interfaces, accommodating these simple requirements forces developers to modify existing, working source code—violating software engineering best practices and introducing bugs.
2. Naive Approach: Tight Coupling to a Concrete Gateway
Let's examine the initial, naive implementation of a payment system in Go. Here, the core Payment struct is tightly coupled to a concrete payment provider struct (Razorpay).
package main
import "fmt"
// Razorpay concrete implementation
type Razorpay struct{}
func (r Razorpay) Pay(amount float32) {
fmt.Printf("Making payment using Razorpay: $%.2f\n", amount)
}
// Payment service tightly coupled to Razorpay
type Payment struct{}
func (p Payment) MakePayment(amount float32) {
// Directly instantiating and calling Razorpay inside business logic
razorpayGateway := Razorpay{}
razorpayGateway.Pay(amount)
}
func main() {
paymentService := Payment{}
paymentService.MakePayment(100.00)
}
Flow Breakdown of the Naive Approach
Key Drawbacks of this Approach
- Hardcoded Dependencies:
MakePaymentknows exactly which provider it is calling and how to construct it. - Zero Flexibility: Switching to another gateway requires opening
MakePaymentand altering internal logic. - Untestable Code: You cannot mock the payment call during automated test runs.
3. Requirements Shift: Adding New Gateways & Code Fragility
Consider what happens when business requirements change and you must switch from Razorpay to Stripe.
The Modifying Code Anti-Pattern
To integrate Stripe under the naive design, you must write a new Stripe struct and edit the core MakePayment method:
package main
import "fmt"
type Razorpay struct{}
func (r Razorpay) Pay(amount float32) {
fmt.Printf("Making payment using Razorpay: $%.2f\n", amount)
}
// New Stripe Gateway implementation
type Stripe struct{}
func (s Stripe) Pay(amount float32) {
fmt.Printf("Making payment using Stripe: $%.2f\n", amount)
}
type Payment struct{}
func (p Payment) MakePayment(amount float32) {
// OLD CODE (Modified/Commented out to swap vendors):
// razorpayGateway := Razorpay{}
// razorpayGateway.Pay(amount)
// NEW CODE: Required modifying existing core method logic
stripeGateway := Stripe{}
stripeGateway.Pay(amount)
}
func main() {
paymentService := Payment{}
paymentService.MakePayment(100.00)
}
Warning: Modifying existing, thoroughly tested methods whenever external requirements change introduces high operational risk. Production bugs frequently occur when existing code is edited rather than extended.
4. Refactoring Attempt: Direct Struct Composition
To avoid hardcoding vendor instantiations inside MakePayment, developers often attempt to inject the gateway dependency as a field inside the Payment struct.
package main
import "fmt"
type Stripe struct{}
func (s Stripe) Pay(amount float32) {
fmt.Printf("Making payment using Stripe: $%.2f\n", amount)
}
// Attempting to store the dependency as a field
type Payment struct {
Gateway Stripe // Hardcoded concrete struct dependency
}
func (p Payment) MakePayment(amount float32) {
p.Gateway.Pay(amount)
}
func main() {
stripeGateway := Stripe{}
paymentService := Payment{Gateway: stripeGateway}
paymentService.MakePayment(100.00)
}
Why Struct Composition Alone Fails Here
If the business decides to switch back to Razorpay, you must manually change the struct definition of Payment:
type Payment struct {
Gateway Razorpay // Still coupled to a single concrete struct type!
}
While constructor/field injection improves instantiation locality, the system remains strictly tied to a single concrete type.
5. The Unit Testing Dilemma: Why Concrete Types Prevent Mocking
Automated testing is critical in production systems. When writing unit tests for core business logic, you must never trigger real financial transactions against third-party production endpoints.
Suppose you want to test Payment.MakePayment using a fake testing gateway (FakePayment):
type FakePayment struct{}
func (f FakePayment) Pay(amount float32) {
fmt.Printf("Making payment using Fake Gateway for testing: $%.2f\n", amount)
}
If the Payment struct defines Gateway as Stripe or Razorpay, passing FakePayment results in a compile-time type error:
cannot use FakePayment{} (type FakePayment) as type Stripe in field value
Because Payment expects a concrete struct type, it refuses any mock implementation.
6. The Core Mental Model: What is an Interface in Go?
Real-Life Analogy
The Universal Electrical Wall Socket Think of an Interface like a standard wall power outlet. The wall outlet specifies a clear contract: "If your appliance has a matching plug and accepts 120V/240V power, I will supply electricity."
The outlet does not care whether you plug in a microwave, a smartphone charger, a laptop adapter, or a vacuum cleaner. As long as the device meets the plug contract, it works seamlessly.
In Go, an Interface acts as that universal wall socket, establishing a set of method signatures that any struct can plug into.
Interface Definition Syntax
An interface defines a behavioral contract. It lists method signatures without providing their concrete implementations.
type PaymentGateway interface {
Pay(amount float32)
}
By substituting concrete types with the PaymentGateway interface inside our core application, Payment no longer depends on how a payment is executed—only that the provider fulfills the Pay(amount float32) contract.
7. Go's Key Architectural Twist: Implicit Interface Implementation
In traditional object-oriented programming languages (such as Java, C#, or PHP), a class must explicitly declare that it implements an interface using an explicit keyword:
// Java example (Explicit implementation)
public class Razorpay implements PaymentGateway { ... }
The Go Philosophy: Duck Typing at Compile Time
Go does NOT have an implements keyword. Instead, Go interfaces are implemented implicitly.
If a struct defines methods whose signatures match all methods declared by an interface, the Go compiler automatically recognizes that the struct satisfies that interface.
Conceptually: "If it walks like a duck and quacks like a duck, it is a duck." In Go: "If a struct has a
Pay(float32)method, it is aPaymentGateway."
8. Refactoring with Interfaces: Decoupling and Dependency Injection
Let's apply interfaces to solve our architectural design issues completely.
Step 1: Define the Contract
Define the PaymentGateway interface (often named with -er suffixes in Go, such as Paymenter or Payer, though domain-specific names like PaymentGateway are common for clarity):
type PaymentGateway interface {
Pay(amount float32)
}
Step 2: Depend on Abstractions, Not Concretions
Update the Payment struct to hold the PaymentGateway interface type instead of a concrete struct.
type Payment struct {
Gateway PaymentGateway // Accepts ANY struct satisfying PaymentGateway
}
func (p Payment) MakePayment(amount float32) {
// Calls Pay() on whatever gateway was injected
p.Gateway.Pay(amount)
}
9. Extending the System: Seamless Multi-Gateway & Mock Testing Support
Here is the complete refactored program demonstrating how multiple providers (Razorpay, Stripe, PayPal) and test mocks (FakePayment) seamlessly plug into the exact same application code without modifying Payment or MakePayment.
package main
import "fmt"
// 1. Define the Interface Contract
type PaymentGateway interface {
Pay(amount float32)
}
// 2. Concrete Provider 1: Razorpay
type Razorpay struct{}
func (r Razorpay) Pay(amount float32) {
fmt.Printf("[Razorpay API] Processed payment of $%.2f\n", amount)
}
// 3. Concrete Provider 2: Stripe
type Stripe struct{}
func (s Stripe) Pay(amount float32) {
fmt.Printf("[Stripe API] Processed payment of $%.2f\n", amount)
}
// 4. Concrete Provider 3: PayPal
type PayPal struct{}
func (p PayPal) Pay(amount float32) {
fmt.Printf("[PayPal API] Processed payment of $%.2f\n", amount)
}
// 5. Test Mock Provider: FakePayment
type FakePayment struct{}
func (f FakePayment) Pay(amount float32) {
fmt.Printf("[MOCK] Unit Test Payment of $%.2f succeeded!\n", amount)
}
// 6. High-Level Core Business Module
type Payment struct {
Gateway PaymentGateway
}
func (p Payment) MakePayment(amount float32) {
p.Gateway.Pay(amount)
}
func main() {
fmt.Println("=== Scenario 1: Using Razorpay ===")
razorpayService := Payment{Gateway: Razorpay{}}
razorpayService.MakePayment(100.00)
fmt.Println("\n=== Scenario 2: Swapping to Stripe ===")
stripeService := Payment{Gateway: Stripe{}}
stripeService.MakePayment(250.50)
fmt.Println("\n=== Scenario 3: Swapping to PayPal ===")
payPalService := Payment{Gateway: PayPal{}}
payPalService.MakePayment(89.99)
fmt.Println("\n=== Scenario 4: Automated Unit Testing with Fake Mock ===")
testService := Payment{Gateway: FakePayment{}}
testService.MakePayment(15.00)
}
Execution Output
=== Scenario 1: Using Razorpay ===
[Razorpay API] Processed payment of $100.00
=== Scenario 2: Swapping to Stripe ===
[Stripe API] Processed payment of $250.50
=== Scenario 3: Swapping to PayPal ===
[PayPal API] Processed payment of $89.99
=== Scenario 4: Automated Unit Testing with Fake Mock ===
[MOCK] Unit Test Payment of $15.00 succeeded!
10. Architectural Deep Dive: SOLID Principles & Dependency Inversion
By introducing an interface, our Go code satisfies core software engineering principles:
A. Open/Closed Principle (OCP)
Definition: Software entities should be open for extension, but closed for modification.
- Violation (Before Interfaces): Every time a new payment vendor was introduced, the core
MakePaymentmethod had to be modified. - Adherence (After Interfaces): We can add 50 new payment gateways (extending the software) without touching a single line of the core
Paymentstruct orMakePaymentmethod (closed for modification).
B. Dependency Inversion Principle (DIP)
Definition: High-level modules should not depend on low-level modules. Both should depend on abstractions.
11. Interface Evolution: Adding New Methods to Contracts
As application scope grows, interface contracts may need to expand. For example, adding refund capabilities to our payment processing system.
Expanding the Interface
Let's add a Refund method to the PaymentGateway interface:
type PaymentGateway interface {
Pay(amount float32)
Refund(amount float32, account string)
}
Compiler Enforcement
Once Refund is added to PaymentGateway, any concrete struct passed into Payment.Gateway must implement both Pay and Refund.
If PayPal lacks the Refund method, the Go compiler generates a strict build error:
cannot use PayPal{} (type PayPal) as type PaymentGateway in field value:
PayPal does not implement PaymentGateway (missing Refund method)
Correctly Updating Concrete Types
To satisfy the updated interface contract, each provider struct must implement the newly added signature:
func (p PayPal) Refund(amount float32, account string) {
fmt.Printf("[PayPal API] Refunded $%.2f to account %s\n", amount, account)
}
12. Structural Comparison: Naive vs. Interface Approach
Summary and Key Takeaways
- Core Purpose of Interfaces: Interfaces define behavioral contracts in Go, specifying what methods a type must have without prescribing how they are implemented.
- Implicit Implementation: Go does not use an
implementskeyword. A struct implicitly satisfies an interface simply by defining matching method signatures. - Open/Closed Principle: Interfaces allow systems to be easily extended with new behaviors (e.g., adding Stripe, PayPal, or mock gateways) without modifying existing core application code.
- Dependency Inversion: By accepting an interface type inside a struct field or function parameter, high-level business logic depends on clean abstractions rather than concrete vendor packages.
- Streamlined Unit Testing: Interfaces enable passing lightweight fake/mock implementations into production code during test runs, preventing accidental calls to external production APIs.
- Strict Compiler Verification: If an interface contract expands (e.g., adding a
Refundmethod), the Go compiler automatically enforces that all injected types implement every required method signature.