← Back to the section

The acronym SOLID stands for five principles of class design formulated by Robert Martin. They are often explained with examples about geometric shapes, and they can feel like theory for theory's sake. In practice the principles are answers to concrete pains: "why is this code so hard to change?", "why is one class edited by several teams at once?", "why does swapping a library break half the system?".

Let's go through each principle from scratch: what the problem is, how it shows up in code, and how to fix it.

SRP — single responsibility

A class should have one reason to change.

Imagine a service that has grown into a "junk drawer" over time:

type OrderService struct{}

func (s *OrderService) CreateOrder(req CreateOrderRequest) (OrderDTO, error) { /* 120 lines */ }
func (s *OrderService) CancelOrder(id OrderID) (OrderDTO, error)             { /* 80 lines */ }
func (s *OrderService) SearchOrders(filter OrderFilter) ([]OrderDTO, error)  { /* 60 lines */ }
func (s *OrderService) ExportOrders(filter OrderFilter) ([]byte, error)      { /* 90 lines */ }
func (s *OrderService) RecalculateStatistics() error                         { /* 70 lines */ }

When the order creation rule changes — we edit this class. When the export format changes — this class again. When the statistics algorithm changes — it again. The class has several unrelated reasons to change, and any edit risks touching the rest.

SRP says: a class should have one reason to change. In practice this means one class solves one task.

type CreateOrderHandler struct {
	orders  OrderRepository
	pricing PricingPolicy
}

func NewCreateOrderHandler(orders OrderRepository, pricing PricingPolicy) *CreateOrderHandler {
	return &CreateOrderHandler{orders: orders, pricing: pricing}
}

func (h *CreateOrderHandler) Handle(cmd CreateOrderCommand) (OrderDTO, error) {
	order, err := NewOrder(cmd.CustomerID, cmd.Lines, h.pricing)
	if err != nil {
		return OrderDTO{}, err
	}
	if err := h.orders.Save(order); err != nil {
		return OrderDTO{}, err
	}
	return OrderDTOFrom(order), nil
}

This class changes only when the order creation rule changes. Sending an email to the customer is a different responsibility, and it lives in a separate event handler.

A good sign of an SRP violation is when a class contains the word "and" in its spoken description: "it creates an order and sends an email and recalculates statistics".

OCP — open for extension, closed for modification

System behavior is extended by adding code, not by editing existing code.

A typical picture of a violation is a switch or a chain of if that you have to add a branch to every time a new variant appears:

func discount(order Order) decimal.Decimal {
	switch order.Customer.Type {
	case CustomerVIP:
		return order.Total().Mul(decimal.NewFromFloat(0.10))
	case CustomerEmployee:
		return order.Total().Mul(decimal.NewFromFloat(0.20))
	default:
		return decimal.Zero
	}
}

A new customer type means editing this method. And all the other switch statements over the same attribute elsewhere in the code.

The solution is to declare an extension point (an interface) and add new behavior with a new class, without touching the existing one:

type DiscountPolicy interface {
	Supports(customer Customer) bool
	Discount(order Order) decimal.Decimal
}

type DiscountCalculator struct {
	policies []DiscountPolicy
}

func (c *DiscountCalculator) Discount(order Order) decimal.Decimal {
	for _, policy := range c.policies {
		if policy.Supports(order.Customer) {
			return policy.Discount(order)
		}
	}
	return decimal.Zero
}

A new discount type means a new type implementing DiscountPolicy. The existing code is not touched.

A caveat: OCP does not mean "create interfaces everywhere just in case". If there are knowingly only two variants and no new ones are expected — a switch is more honest. The principle applies where extension is genuinely expected.

LSP — Liskov substitution principle

An implementation can be replaced by any other implementation of the same contract — and the calling code won't notice the difference.

A violation usually looks like embedding for the sake of code reuse, where the wrapper breaks the expectations of the original contract:

type CachedProductRepository struct {
	*SQLProductRepository

	cache sync.Map
}

func (r *CachedProductRepository) FindByID(id ProductID) (*Product, error) {
	if cached, ok := r.cache.Load(id); ok {
		return cached.(*Product), nil
	}
	product, err := r.SQLProductRepository.FindByID(id)
	if err != nil {
		return nil, err
	}
	r.cache.Store(id, product)
	return product, nil
}

func (r *CachedProductRepository) Delete(id ProductID) error {
	panic("cache does not support deletion")
}

The type calls itself a repository, but Delete panics. Code that worked with the original type breaks with the "specialized" one — that is exactly the LSP violation: the wrapper narrowed the contract.

The correct form is not embedding with method shadowing but explicit composition. The new type implements the same interface and honestly fulfills its entire contract:

type CachingProductRepository struct {
	delegate ProductRepository
	cache    Cache
}

func (r *CachingProductRepository) FindByID(id ProductID) (*Product, error) {
	if product, ok := r.cache.Get(id); ok {
		return product, nil
	}
	product, err := r.delegate.FindByID(id)
	if err != nil {
		return nil, err
	}
	r.cache.Put(id, product)
	return product, nil
}

func (r *CachingProductRepository) Delete(id ProductID) error {
	if err := r.delegate.Delete(id); err != nil { // deletion is performed
		return err
	}
	r.cache.Evict(id) // and the cache is invalidated
	return nil
}

Now CachingProductRepository can be substituted for any other ProductRepository without surprises.

A rule of thumb: if you see panic("not supported") in a method the type is obliged to fulfill by its interface — LSP is almost certainly violated.

ISP — interface segregation

A client should not depend on methods it does not use.

Interfaces have a tendency to grow: first there was Save and FindByID, then FindForListing was added, then FindForExport, then ArchiveOlderThan:

type OrderStorage interface {
	Save(order *Order) error
	FindByID(id OrderID) (*Order, error)
	FindForListing(filter OrderFilter, page Pagination) ([]OrderListRow, error)
	FindForExport(from, to time.Time) ([]OrderExportRow, error)
	ArchiveOlderThan(date time.Time) error
}

A command handler uses two methods out of five but depends on all of them. Changing the signature of the export method forces it to be recompiled too. In tests you have to stub out all five methods, even though only two are needed.

The solution is to split by consumers, not by table:

// for commands — only what is needed
type OrderRepository interface {
	Save(order *Order) error
	FindByID(id OrderID) (*Order, error)
}

// for reads and reports — separately
type OrderViewRepository interface {
	FindForListing(filter OrderFilter, page Pagination) ([]OrderListRow, error)
	FindForExport(from, to time.Time) ([]OrderExportRow, error)
}

A single implementation can implement both interfaces — that's fine. What matters is that each consumer depends only on the methods it actually uses.

DIP — dependency inversion

High-level modules do not depend on low-level modules. Both depend on abstractions.

In plain words: business logic should not directly know about concrete tools (a database, a mail server, an external API). Otherwise, when you swap the tool, you have to touch the business logic.

A typical violation is a domain model that knows about a concrete notification transport:

func (o *Order) Cancel(mail *SMTPMailSender) {
	o.status = StatusCancelled
	mail.Send(o.customer.Email, "Order cancelled")
}

The domain model depends on SMTPMailSender — a concrete infrastructure detail. Want to switch to push notifications — you'll have to change Order. Want to test cancellation without a real SMTP server — it's hard.

Inversion: the domain declares what it needs (an interface), and the infrastructure decides how to implement it:

// the interface is declared next to the domain and speaks the domain's language
type NotificationPort interface {
	OrderCancelled(order *Order)
}

// the implementation lives in the infrastructure layer
type SMTPNotificationAdapter struct {
	mailer *SMTPMailer
}

func (a *SMTPNotificationAdapter) OrderCancelled(order *Order) {
	a.mailer.Send(buildMessage(order))
}

Order no longer knows anything about SMTP. In a test NotificationPort is easily replaced by a stub. Changing the transport means a new adapter, and the domain is not touched.

Note: the dependency goes from SMTPNotificationAdapter to NotificationPort (which lives next to the domain), not the other way around. The direction of the dependency is inverted relative to the direction of the call — hence the name "inversion".

In short

  • SRP: a class has one reason to change. If a class does "both this and that" — it's time to split it.
  • OCP: new behavior is added with a new class, not by editing the existing one. An interface as the extension point.
  • LSP: an implementation replaces the original without surprises. A panic in a method that implements an interface is a red flag.
  • ISP: an interface contains only what a particular consumer needs. A big interface is cut into several narrow ones.
  • DIP: business logic depends on interfaces, not on concrete classes. The direction of dependencies is toward the domain, not away from it.

The principles work together: a class with a single responsibility (SRP) depends on a narrow interface (ISP) declared in the domain (DIP), whose implementations are interchangeable (LSP), and new behavior variants are added with new classes (OCP).

  • GoF patterns — concrete techniques that implement the ideas of SOLID in practice.
  • GRASP by example — principles of distributing responsibility between classes.
  • Hexagonal architecture — DIP and ISP taken all the way to the module structure.