Understanding SOLID Principles in Go: From Basics to Real-World Magic

Understanding SOLID Principles in Go: From Basics to Real-World Magic

September 8, 2025 · 8 min read

If you’ve ever felt like your code is turning into a tangled mess of spaghetti, you’re not alone. That’s where SOLID principles come in – they’re like the superhero guidelines for writing clean, maintainable, and scalable software. Coined by Robert C. Martin (Uncle Bob), SOLID is an acronym for five key principles that help you design better object-oriented code.

We’ll dive into each SOLID principle using examples in Go (Golang), starting from the basics and ramping up to more advanced scenarios. I’ll explain when to use them, when to skip them, and wrap it up with a real-life application to show how they all tie together. By the end, you’ll have a crystal-clear grasp and be ready to SOLID-ify your own projects. Let’s get started!

What Are SOLID Principles?

Before we jump in, a quick overview: SOLID stands for:

These aren’t strict rules but rather best practices for object-oriented design. They promote flexibility, reduce bugs, and make your code easier to test and extend. Go isn’t purely object-oriented like Java or C++, but it supports interfaces, structs, and composition, making SOLID applicable and powerful here too.

Single Responsibility Principle (SRP)

The Basics

SRP says a class (or in Go, a struct or module) should have only one reason to change – meaning it should do one job well. Think of it like a Swiss Army knife: sure, it’s handy, but if one tool breaks, the whole thing suffers. Instead, give each tool its own handle!

Basic Example in Go: Imagine a User struct that handles both data storage and email sending. That’s two responsibilities!

// Bad: Violates SRP
type User struct {
    Name  string
    Email string
}

func (u *User) SaveToDB() {
    // Save to database logic
}

func (u *User) SendEmail() {
    // Email sending logic
}

Fix it by splitting:

// Good: Adheres to SRP
type User struct {
    Name  string
    Email string
}

type UserRepository struct{}

func (r *UserRepository) Save(user User) {
    // Save to database logic
}

type EmailService struct{}

func (e *EmailService) Send(email string, message string) {
    // Email sending logic
}

Now, if email logic changes, it doesn’t affect saving users.

Advanced Insights

In larger systems, SRP extends to packages or microservices. For instance, in a web app, separate concerns like authentication, logging, and business logic into different packages. Advanced use might involve event-driven architectures where each handler has a single event to process.

Best Use Cases:

When to Avoid:

Open-Closed Principle (OCP)

The Basics

OCP means your code should be open for extension but closed for modification. In other words, add new features without changing existing code. It’s like building with Lego: snap on new pieces without rebuilding the base.

Basic Example in Go: A shape calculator that only handles circles and squares – but what if we add triangles? Without OCP, you’d modify the calculator every time.

// Bad: Violates OCP
func Area(shape string, width float64, height float64) float64 {
    if shape == "square" {
        return width * width
    } else if shape == "rectangle" {
        return width * height
    }
    // Adding triangle? Modify this function!
    return 0
}

Better: Use an interface.

// Good: Adheres to OCP
type Shape interface {
    Area() float64
}

type Square struct {
    Side float64
}

func (s Square) Area() float64 {
    return s.Side * s.Side
}

type Rectangle struct {
    Width, Height float64
}

func (r Rectangle) Area() float64 {
    return r.Width * r.Height
}

// To add Triangle? Just implement Shape – no change to existing code!
type Triangle struct {
    Base, Height float64
}

func (t Triangle) Area() float64 {
    return 0.5 * t.Base * t.Height
}

Advanced Insights

In advanced scenarios, use factories or dependency injection to dynamically load extensions (e.g., plugins). In Go, leverage interfaces with generics (Go 1.18+) for type-safe extensions.

Best Use Cases:

When to Avoid:

Liskov Substitution Principle (LSP)

The Basics

LSP states that subclasses should be substitutable for their base classes without breaking the program. If it looks like a duck and quacks like a duck… it better swim like one too!

Basic Example in Go:

Go uses interfaces implicitly. Say we have a Bird interface.

type Bird interface {
    Fly() string
}

type Sparrow struct{}

func (s Sparrow) Fly() string {
    return "Sparrow flying high!"
}

type Penguin struct{}  // Penguins don't fly!

func (p Penguin) Fly() string {
    panic("Penguins can't fly!")  // Breaks LSP if used as Bird
}

// Usage
func MakeBirdFly(b Bird) {
    fmt.Println(b.Fly())
}

// MakeBirdFly(Penguin{}) would panic – violation!

Fix: Separate interfaces or use composition.

type Flyer interface {
    Fly() string
}

type Sparrow struct{}

func (s Sparrow) Fly() string {
    return "Sparrow flying high!"
}

type Penguin struct{}  // No Fly method – can't substitute where Flyer is expected

Advanced Insights

In complex hierarchies, ensure behavioral contracts (pre/post-conditions) are maintained. Use Go’s type assertions or interfaces with methods that enforce invariants.

Best Use Cases:

When to Avoid:

Interface Segregation Principle (ISP)

The Basics

ISP advises many small, specific interfaces over one fat one. Clients shouldn’t be forced to depend on methods they don’t use – it’s like not making everyone order the full menu.

Basic Example in Go: A big Printer interface.

// Bad: Violates ISP
type Printer interface {
    Print()
    Scan()
    Fax()
}

type SimplePrinter struct{}  // Only prints, but must implement all!

func (sp SimplePrinter) Print() {}
func (sp SimplePrinter) Scan() { panic("Not supported") }
func (sp SimplePrinter) Fax()  { panic("Not supported") }

Split it:

// Good: Adheres to ISP
type Printer interface {
    Print()
}

type Scanner interface {
    Scan()
}

type Faxer interface {
    Fax()
}

type SimplePrinter struct{}

func (sp SimplePrinter) Print() {}  // Only what it needs

Advanced Insights

In microservices or APIs, design fine-grained interfaces for loose coupling. With Go generics, create composable interfaces for reusable components.

Best Use Cases:

When to Avoid:

Dependency Inversion Principle (DIP)

The Basics

DIP flips dependencies: high-level modules shouldn’t depend on low-level ones; both should depend on abstractions. Use interfaces to decouple!

Basic Example in Go: A Switch depending directly on Lamp.

// Bad: Violates DIP
type Lamp struct{}

func (l Lamp) TurnOn() {}

type Switch struct {
    lamp Lamp  // Tight coupling
}

func (s *Switch) Operate() {
    s.lamp.TurnOn()
}

Invert it:

// Good: Adheres to DIP
type Switchable interface {
    TurnOn()
}

type Lamp struct{}

func (l Lamp) TurnOn() {}

type Switch struct {
    device Switchable  // Depends on abstraction
}

func (s *Switch) Operate() {
    s.device.TurnOn()
}

// Now Switch can control any Switchable – fan, AC, etc.

Advanced Insights

Use dependency injection containers or factories for runtime wiring. In Go, pass interfaces via constructors for testable code.

Best Use Cases:

When to Avoid:

Balancing SOLID: Pros, Cons, and Trade-offs

Applying SOLID makes your code modular, testable, and scalable—perfect for growing projects. But remember, it’s not always all-or-nothing. Over-applying can lead to over-abstraction (YAGNI—You Ain’t Gonna Need It). Use them when complexity warrants it, like in enterprise software or teams.

Pros:

Cons:

When to avoid SOLID altogether: In prototypes, one-liners, or scripts where speed of development trumps design.

Real-Life Application: Building a Notification System in Go

Let’s tie it all together with a real-world example: a notification system for a web app (e.g., like Twitter/X notifications). Without SOLID, it might be a monolithic mess. With SOLID:

Code sketch:

type Sender interface {
    Send(msg string) error
}

type EmailSender struct{} // Implements Send

type SMSSender struct{} // Implements Send

type NotificationService struct {
    sender Sender // Injected via constructor.
}

func NewNotificationService(s Sender) *NotificationService {
    return &NotificationService{sender: s}
}

func (ns *NotificationService) Notify(user string, msg string) error {
    return ns.sender.Send(msg) // Flexible!
}

In action: For an e-commerce app, inject EmailSender for order confirmations. Later, extend to PushSender for mobile— no core changes. This keeps the system maintainable as it scales.

There you have it! SOLID isn’t magic, but applying it thoughtfully will make your Go code shine. Happy coding! 🚀

Share: X LinkedIn