SOLID #
Five letters that separate code that grows healthily from code that gets harder to change every day. SOLID is a set of design principles introduced by Robert C. Martin (Uncle Bob) — not rigid rules, but guidance that helps answer one fundamental question: how do you write code that’s easy to understand, easy to test, and resilient to change? Although they come from the classic OOP world, these principles are highly relevant to Go and Dart because both support interfaces, composition, and dependency injection. This guide covers all five principles in depth — each with a concrete anti-pattern, the correct solution, and why it matters — ending with how the five principles work together, when SOLID should be applied, and when it shouldn’t.
Why SOLID Matters #
Without consistent design principles, a growing codebase tends to head in one direction: harder and harder to change. Small changes in one place have big effects in unexpected places. Unit tests need a real database setup. Adding a new feature means modifying old, working code. New engineers take a long time to understand the flow — not because the business domain is complex, but because the code doesn’t reflect a clear structure.
SOLID attacks these problems from the design side, not the tooling or framework side:
Problem without SOLID → Solution with SOLID
──────────────────────────────── ────────────────────────────────────
One class does everything SRP: one responsibility,
one reason to change
Adding a feature = modifying old code OCP: extend with new code,
don't modify existing code
Subtypes break parent behavior LSP: implementations can be
substituted without surprises
and without defensive checks
Huge interfaces force unwanted ISP: small, focused interfaces,
implementations clients only depend on
what they use
High-level modules depend on DIP: depend on abstractions,
implementation details not concrete implementations
Together, these five principles form a design foundation that lets a system grow without degrading its quality. Let’s go through them one by one.
S — Single Responsibility Principle #
A module should have one, and only one, reason to change.
SRP isn’t about “one method per class” or “classes must be small”. It’s about cohesion — everything inside a unit must serve one clearly defined purpose. If two different reasons could force the class to change, that’s a sign SRP is violated.
An easy way to spot an SRP violation: write the sentence “Class X is responsible for…” — if that sentence needs the word “and” to connect two different things, SRP is most likely violated.
// ANTI-PATTERN: OrderService does too many things
// If the email format changes, OrderService must change.
// If the database schema changes, OrderService must change.
// If the discount business rule changes, OrderService must change.
// Three reasons to change = three responsibilities = SRP violated.
type OrderService struct{}
func (s *OrderService) CreateOrder(order Order) error {
// Business logic
if order.Total < 0 {
return errors.New("invalid total")
}
// Database logic — the repository's responsibility, not the service's
db.Exec("INSERT INTO orders VALUES (?)", order)
// Email logic — the notification service's responsibility
smtp.Send(order.UserEmail, "Order confirmed: "+order.ID)
// PDF logic — the report service's responsibility
pdf.Generate(order)
return nil
}
// CORRECT: each component has one clear responsibility
type OrderService struct {
repo OrderRepository // responsibility: data persistence
notifier Notifier // responsibility: notifications
reporter Reporter // responsibility: generating reports
}
func (s *OrderService) CreateOrder(ctx context.Context, order Order) error {
if err := validateOrder(order); err != nil { // validation: the service's responsibility
return err
}
if err := s.repo.Save(ctx, order); err != nil {
return err
}
go s.notifier.NotifyOrderCreated(order) // async, doesn't block the main flow
go s.reporter.GenerateReceipt(order) // async, doesn't block the main flow
return nil
}
Notice the difference: in the correct version, if the email format changes, only Notifier needs to be modified. If the database schema changes, only OrderRepository is affected. OrderService itself only needs to change if the order-creation business logic changes — that’s its only responsibility.
flowchart TD
OS["OrderService\n(business logic)"]
R["OrderRepository\n(persistence)"]
N["Notifier\n(notifications)"]
RP["Reporter\n(reports)"]
OS -->|save| R
OS -->|notify| N
OS -->|generate| RP
style OS fill:#4C9BE8,color:#fff
style R fill:#5CB85C,color:#fff
style N fill:#F0AD4E,color:#fff
style RP fill:#D9534F,color:#fffSRP also applies at the function level, not just classes. A function that validates, transforms data, and logs all in one linear block is an SRP violation at micro scale.
O — Open/Closed Principle #
Software entities should be open for extension, but closed for modification.
OCP is about designing code so adding new behavior doesn’t require changing existing, already-tested code. This is achieved by defining abstractions (interfaces) and adding new implementations — not modifying the old ones.
The most common OCP violation appears as a switch-case or if-else that keeps growing every time there’s a new requirement:
// ANTI-PATTERN: every new discount type forces a modification to this function
// Adding a "Premium" type means changing code already running in production.
// Every change here risks breaking existing discount types.
func CalculateDiscount(userType string, price float64) float64 {
switch userType {
case "VIP":
return price * 0.8
case "Member":
return price * 0.9
case "Premium": // ← a new addition modifies old code
return price * 0.85
case "Corporate": // ← a new case every sprint
return price * 0.75
default:
return price
}
}
// CORRECT: adding a new discount = adding a new struct,
// without touching already-running code
type DiscountStrategy interface {
Apply(price float64) float64
Label() string
}
type VIPDiscount struct{}
func (d VIPDiscount) Apply(price float64) float64 { return price * 0.8 }
func (d VIPDiscount) Label() string { return "VIP (20% off)" }
type MemberDiscount struct{}
func (d MemberDiscount) Apply(price float64) float64 { return price * 0.9 }
func (d MemberDiscount) Label() string { return "Member (10% off)" }
type PremiumDiscount struct{}
func (d PremiumDiscount) Apply(price float64) float64 { return price * 0.85 }
func (d PremiumDiscount) Label() string { return "Premium (15% off)" }
type CorporateDiscount struct{}
func (d CorporateDiscount) Apply(price float64) float64 { return price * 0.75 }
func (d CorporateDiscount) Label() string { return "Corporate (25% off)" }
// PriceCalculator never needs to change,
// even if there are 10 new discount types
type PriceCalculator struct{}
func (c *PriceCalculator) Calculate(price float64, discount DiscountStrategy) float64 {
if discount == nil {
return price
}
return discount.Apply(price)
}
OCP synergizes strongly with Dependency Injection. When dependencies are injected as interfaces, swapping implementations (extending) doesn’t require changes in the code that uses them (closed for modification). This is also what makes the strategy pattern, decorator pattern, and plugin systems work well.
flowchart LR
PC["PriceCalculator\n(closed for modification)"]
DS["DiscountStrategy\n(interface)"]
V["VIPDiscount"]
M["MemberDiscount"]
P["PremiumDiscount"]
C["CorporateDiscount\n(new addition)"]
PC -->|depends on| DS
V -->|implements| DS
M -->|implements| DS
P -->|implements| DS
C -->|implements| DS
style DS fill:#4C9BE8,color:#fff
style PC fill:#5CB85C,color:#fff
style C fill:#F0AD4E,color:#fffThis principle doesn’t mean we can never modify old code. What’s avoided is change triggered by adding new cases — if you have to open calculator.go every time there’s a new user type, that’s a signal OCP is violated.
L — Liskov Substitution Principle #
Derived objects must be able to replace their parent objects without breaking the program’s correctness.
LSP ensures that when code interacts with an interface, all its implementations behave according to the same contract. No implementation throws unexpected exceptions, returns out-of-contract values, or changes the semantics of inherited methods.
LSP violations are often not immediately visible. The symptom is code using an interface being forced to do type assertions or concrete type checks to operate correctly:
// ANTI-PATTERN: Penguin implements Bird but violates its contract
// Code calling bird.Fly() must do defensive programming
type Bird interface {
Fly() error
}
type Eagle struct{}
func (e Eagle) Fly() error { return nil } // ✓ can fly
type Penguin struct{}
func (p Penguin) Fly() error {
return errors.New("penguins cannot fly") // ← violates the Bird contract
// Callers using the Bird interface don't expect this
}
// The result: all code using Bird is forced to be defensive:
func makeAllFly(birds []Bird) {
for _, bird := range birds {
if err := bird.Fly(); err != nil {
// "Might be a penguin" — the classic sign of an LSP violation
log.Printf("bird cannot fly: %v", err)
}
}
}
The solution isn’t making Penguin fly — it’s redesigning the interface hierarchy to better reflect actual capabilities:
// CORRECT: separate interfaces by actual capability
type Animal interface {
Eat()
Move()
}
type FlyingAnimal interface {
Animal
Fly() // only for those that can really fly — this contract is guaranteed
}
type SwimmingAnimal interface {
Animal
Swim() // only for those that can really swim — this contract is guaranteed
}
type Eagle struct{}
func (e Eagle) Eat() {}
func (e Eagle) Move() {}
func (e Eagle) Fly() {} // ✓ Eagle implements FlyingAnimal
type Penguin struct{}
func (p Penguin) Eat() {}
func (p Penguin) Move() {}
func (p Penguin) Swim() {} // ✓ Penguin implements SwimmingAnimal, not FlyingAnimal
// Code using FlyingAnimal can call Fly() without any defensive checks
func makeAllFly(flyers []FlyingAnimal) {
for _, f := range flyers {
f.Fly() // guaranteed to work — every implementor can really fly
}
}
// Code using SwimmingAnimal doesn't know about flying at all
func makeAllSwim(swimmers []SwimmingAnimal) {
for _, s := range swimmers {
s.Swim() // guaranteed to work
}
}
flowchart TD
A["Animal\n(Eat, Move)"]
FA["FlyingAnimal\n(Animal + Fly)"]
SA["SwimmingAnimal\n(Animal + Swim)"]
E["Eagle\n✓ FlyingAnimal"]
P["Penguin\n✓ SwimmingAnimal"]
D["Duck\n✓ FlyingAnimal + SwimmingAnimal"]
FA -->|embeds| A
SA -->|embeds| A
E -->|implements| FA
P -->|implements| SA
D -->|implements| FA
D -->|implements| SA
style A fill:#4C9BE8,color:#fff
style FA fill:#5CB85C,color:#fff
style SA fill:#F0AD4E,color:#fffLSP also applies outside classic inheritance. In Go, which uses implicit interfaces, the same principle holds: every struct claiming to implement an interface must truly fulfill all the promises that interface makes — including implicit promises like “this method won’t panic” or “this method won’t return nil unless documented to”.
A sign LSP is violated: If you need to add type assertions, type switches, or switch v := x.(type) inside code that should work polymorphically with an interface — that’s a strong signal the interface hierarchy doesn’t satisfy LSP. Code using an interface shouldn’t need to know the concrete type behind it.I — Interface Segregation Principle #
Don’t force clients to depend on interfaces they don’t use.
ISP encourages small, focused interfaces. In Go, this principle is very idiomatic because Go supports implicit interface implementation — you can define interfaces on the consumer side, exactly as large as needed, without the library having to know about them.
An ISP violation is easiest to recognize: a struct implementing a big interface where some methods must be left empty or panicked because they’re simply irrelevant:
// ANTI-PATTERN: an interface too large forces irrelevant implementations
type Storage interface {
Save(data []byte) error
Load(id string) ([]byte, error)
Delete(id string) error
List(prefix string) ([]string, error)
GetMetadata(id string) (Metadata, error)
SetTTL(id string, ttl time.Duration) error
Flush() error
}
// ReadOnlyCache only needs Load, but is forced to implement 7 methods
// If this interface changes (e.g. adds Compress()), ReadOnlyCache is affected
// even though the change is completely irrelevant to it
type ReadOnlyCache struct{}
func (r *ReadOnlyCache) Save(data []byte) error { return errors.New("read only") }
func (r *ReadOnlyCache) Load(id string) ([]byte, error) { /* real implementation */ return nil, nil }
func (r *ReadOnlyCache) Delete(id string) error { return errors.New("read only") }
func (r *ReadOnlyCache) List(prefix string) ([]string, error) { return nil, errors.New("not supported") }
func (r *ReadOnlyCache) GetMetadata(id string) (Metadata, error) { return Metadata{}, nil }
func (r *ReadOnlyCache) SetTTL(id string, ttl time.Duration) error { return errors.New("not supported") }
func (r *ReadOnlyCache) Flush() error { return nil }
// CORRECT: small interfaces, matching each consumer's needs
type DataLoader interface {
Load(id string) ([]byte, error)
}
type DataSaver interface {
Save(data []byte) error
}
type DataDeleter interface {
Delete(id string) error
}
type TTLSetter interface {
SetTTL(id string, ttl time.Duration) error
}
// Compose larger interfaces from smaller ones when truly needed
type ReadWriteStorage interface {
DataLoader
DataSaver
}
type FullStorage interface {
DataLoader
DataSaver
DataDeleter
TTLSetter
}
// Each consumer only depends on what it needs
type ReadOnlyService struct {
loader DataLoader // only this interface — minimal and focused
}
type WriteService struct {
saver DataSaver
}
type CacheService struct {
storage ReadWriteStorage
ttl TTLSetter
}
In Go, ideal interfaces often contain only 1–3 methods. The Go standard library is the best example: io.Reader (one Read method), io.Writer (one Write method), io.Closer (one Close method), fmt.Stringer (one String method). Small interfaces are easier to implement, easier to mock in tests, and more flexible to compose.
flowchart TD
DL["DataLoader\n(Load)"]
DS["DataSaver\n(Save)"]
DD["DataDeleter\n(Delete)"]
TL["TTLSetter\n(SetTTL)"]
RWS["ReadWriteStorage\n(DataLoader + DataSaver)"]
FS["FullStorage\n(all)"]
ROS["ReadOnlyService\n→ needs DataLoader only"]
WS["WriteService\n→ needs DataSaver only"]
CS["CacheService\n→ needs ReadWriteStorage + TTLSetter"]
RWS -->|embeds| DL
RWS -->|embeds| DS
FS -->|embeds| RWS
FS -->|embeds| DD
FS -->|embeds| TL
ROS -->|depends on| DL
WS -->|depends on| DS
CS -->|depends on| RWS
CS -->|depends on| TL
style DL fill:#4C9BE8,color:#fff
style DS fill:#4C9BE8,color:#fff
style DD fill:#4C9BE8,color:#fff
style TL fill:#4C9BE8,color:#fff
style RWS fill:#5CB85C,color:#fff
style FS fill:#F0AD4E,color:#fffISP also directly impacts unit test quality. Mocks of small interfaces are much easier to write and understand than mocks of large ones. When you have to implement Flush() and GetMetadata() just to test a function that only needs Load() — that’s a signal ISP is violated.
D — Dependency Inversion Principle #
High-level modules should not depend on low-level modules. Both should depend on abstractions.
DIP is the principle with the most direct impact on testability. When high-level modules (business logic) depend on concrete implementations (databases, HTTP clients, email servers), you can’t test the business logic without setting up real infrastructure. This is what makes unit tests “need a database” or “need an internet connection” — a sign DIP is violated.
// ANTI-PATTERN: UserService depends directly on PostgresRepository
// No way to unit test without a real database
type PostgresUserRepository struct {
db *sql.DB
}
func (r *PostgresUserRepository) FindActive() ([]*User, error) {
rows, _ := r.db.Query("SELECT * FROM users WHERE active = true")
// ... scan rows
return users, nil
}
type UserService struct {
repo *PostgresUserRepository // ← concrete type, not an interface
// Permanently bound to Postgres. Can't test without a database.
}
func (s *UserService) GetActiveUsers() ([]*User, error) {
return s.repo.FindActive()
}
// Test: impossible without a real database — or needs a heavy mock library
func TestGetActiveUsers(t *testing.T) {
// How to inject a fake repo? Can't — the field is a concrete type.
service := &UserService{repo: ???}
}
// CORRECT: UserService depends on an interface, not a concrete implementation
type UserRepository interface {
FindActive(ctx context.Context) ([]*User, error)
Save(ctx context.Context, user *User) error
FindByID(ctx context.Context, id string) (*User, error)
}
type UserService struct {
repo UserRepository // ← interface, doesn't know the implementation
}
func NewUserService(repo UserRepository) *UserService {
return &UserService{repo: repo}
}
func (s *UserService) GetActiveUsers(ctx context.Context) ([]*User, error) {
return s.repo.FindActive(ctx)
}
// Production: inject PostgresUserRepository
func main() {
db := connectToPostgres()
repo := postgres.NewUserRepository(db)
service := NewUserService(repo) // ← inject the concrete type at the application edge
}
// Test: inject FakeUserRepository — no database needed at all
type fakeUserRepository struct {
users []*User
}
func (f *fakeUserRepository) FindActive(_ context.Context) ([]*User, error) {
active := []*User{}
for _, u := range f.users {
if u.Active {
active = append(active, u)
}
}
return active, nil
}
func (f *fakeUserRepository) Save(_ context.Context, u *User) error {
f.users = append(f.users, u)
return nil
}
func (f *fakeUserRepository) FindByID(_ context.Context, id string) (*User, error) {
for _, u := range f.users {
if u.ID == id {
return u, nil
}
}
return nil, errors.New("not found")
}
func TestGetActiveUsers(t *testing.T) {
repo := &fakeUserRepository{
users: []*User{
{ID: "1", Active: true},
{ID: "2", Active: false},
{ID: "3", Active: true},
},
}
service := NewUserService(repo) // inject the fake without a database
users, err := service.GetActiveUsers(context.Background())
assert.NoError(t, err)
assert.Len(t, users, 2) // only the active ones
}
DIP applies in all languages, including Dart:
// ANTI-PATTERN: NotificationService depends on Firebase directly
type NotificationService struct{}
// Bound to Firebase — can't test without a Firebase connection
func (s *NotificationService) Notify(userId, message string) error {
return firebaseMessaging.Send(RemoteMessage{
Token: userId,
Data: map[string]string{"message": message},
})
}
// CORRECT: depends on an abstraction
type NotificationRepository interface {
Send(userId, message string) error
}
type NotificationService struct {
repo NotificationRepository
}
// Constructor injection — the dependency comes from outside
func NewNotificationService(repo NotificationRepository) *NotificationService {
return &NotificationService{repo: repo}
}
func (s *NotificationService) NotifyUser(userId, event string) error {
message := s.buildMessage(event)
return s.repo.Send(userId, message)
}
func (s *NotificationService) buildMessage(event string) string {
// business logic: format the message
return "Event occurred: " + event
}
// Production
type FirebaseNotificationRepository struct{}
func (r *FirebaseNotificationRepository) Send(userId, message string) error {
return firebaseMessaging.Send(RemoteMessage{
Token: userId,
Data: map[string]string{"message": message},
})
}
// Testing — no Firebase, no connection needed
type Notification struct {
UserID string
Message string
}
type FakeNotificationRepository struct {
sent []Notification
}
func (f *FakeNotificationRepository) Send(userId, message string) error {
f.sent = append(f.sent, Notification{UserID: userId, Message: message})
return nil
}
func TestNotifyUser(t *testing.T) {
fake := &FakeNotificationRepository{}
service := NewNotificationService(fake)
service.NotifyUser("user-123", "order_created")
if len(fake.sent) != 1 || fake.sent[0].UserID != "user-123" {
t.Fatalf("unexpected sent messages: %v", fake.sent)
}
}
sequenceDiagram
participant Main as main() / DI Container
participant Service as UserService
participant IRepo as UserRepository (interface)
participant PgRepo as PostgresUserRepository
participant FakeRepo as fakeUserRepository
Main->>PgRepo: instantiate (production)
Main->>Service: NewUserService(pgRepo)
Service->>IRepo: FindActive(ctx)
IRepo-->>PgRepo: dispatch to the concrete implementation
PgRepo-->>Service: []*User
Note over Main,FakeRepo: During testing:
Main->>FakeRepo: instantiate (testing)
Main->>Service: NewUserService(fakeRepo)
Service->>IRepo: FindActive(ctx)
IRepo-->>FakeRepo: dispatch to the fake
FakeRepo-->>Service: []*User (from memory)DIP doesn’t mean every function needs its own interface. What needs abstraction is the boundary between modules — especially the boundary between business logic and infrastructure (database, cache, HTTP, email, storage). Internal details with no reason to be swapped can stay concrete.
SOLID Working Together #
The five principles don’t stand alone — they reinforce each other. Applying one without the others often makes a design feel odd or inconsistent.
flowchart TD
SRP["SRP\nUserService only\nhandles user business logic"]
OCP["OCP\nAdding new user types\nuses interfaces,\nnot switch-cases"]
ISP["ISP\nUserRepository is small,\nonly the methods\nthe service needs"]
DIP["DIP\nUserService depends on\nthe UserRepository interface,\nnot Postgres"]
LSP["LSP\nAll UserRepository implementations\nbehave consistently\nwith the contract"]
RESULT["Final Result\n• Unit tests without a database\n• Add new implementations without changing the service\n• Every component can be developed independently"]
SRP -->|encourages| OCP
OCP -->|requires| ISP
ISP -->|enables| DIP
DIP -->|produces| LSP
SRP & OCP & ISP & DIP & LSP --> RESULT
style RESULT fill:#5CB85C,color:#fff
style SRP fill:#4C9BE8,color:#fff
style OCP fill:#4C9BE8,color:#fff
style ISP fill:#4C9BE8,color:#fff
style DIP fill:#4C9BE8,color:#fff
style LSP fill:#4C9BE8,color:#fffImagine a system applying all the principles consistently:
UserServicehas one responsibility: orchestrating user business logic (SRP)- When a new user type appears, you add a new struct implementing the
UserTypeinterface — without touchingUserService(OCP) - The
UserRepositoryinterface is small, with only the methodsUserServiceactually uses (ISP) UserServicedepends on theUserRepositoryinterface, so it can be tested without a database (DIP)- All
UserRepositoryimplementations — Postgres, MySQL, or fakes — behave consistently (LSP)
The result: adding a new feature to this system only requires adding new code, not modifying old code. Unit tests run fast without infrastructure. Every component can be developed and deployed independently.
When Not to Force SOLID #
SOLID is guidance, not dogma. There are situations where over-application actually destroys simplicity and makes code harder to understand, not easier.
APPLY SOLID when:
✓ The system will grow and need long-term maintenance
✓ Multiple engineers work on the same codebase
✓ Testability is a priority
✓ The same behavior needs to be swappable (storage, notification, payment gateway)
✓ There's more than one possible implementation for an abstraction
RECONSIDER when:
✗ One-off scripts or quick prototypes that won't be maintained
✗ Small applications that won't grow and are built by one person
✗ Interfaces for things that have only one implementation and will never change
✗ Abstractions not needed right now — this violates YAGNI
✗ Very small codebases where abstraction overhead outweighs the benefit
Signs of SOLID over-application to watch out for:
- Interfaces with only one implementation that are never mocked in tests
- Abstraction layers existing only because “might be useful later” — without a real use case
- Overly generic names that don’t reflect the domain (e.g.
Processor,Manager,Handlerwithout context) NewService(repo Repository, notifier Notifier, reporter Reporter, auditor Auditor, logger Logger)— constructor injection that’s too deep can be a sign SRP is violated at a higher level
Premature abstraction is more dangerous than no abstraction. A wrong abstraction is too expensive to refactor because it’s already spread everywhere. Better to start with a clear concrete implementation, then extract an interface when there’s a real need for substitution or testing.
Anti-Patterns at a Glance #
Here’s a visual summary of all SOLID violations to avoid:
// ✗ SRP: one struct doing too many things
type GodService struct{} // handles user, order, payment, notif, report all at once
// ✗ OCP: modification needed every time behavior is added
func process(eventType string) {
if eventType == "order" { /* ... */ } else if eventType == "payment" { /* ... */ }
// Every new case = risk of breaking existing cases
}
// ✗ LSP: an implementation that doesn't fulfill the promised interface contract
func (s *ReadOnlyStorage) Save(data []byte) error {
panic("not supported") // ← violates the DataSaver contract
}
// ✗ ISP: a big interface forcing irrelevant implementations
type MegaRepository interface {
Read()
Write()
Delete()
Archive()
Export()
Import()
Compress()
Encrypt()
// A struct that only needs Read is forced to implement 7 other methods
}
// ✗ DIP: depending directly on concrete implementations
type OrderService struct {
db *MySQLDatabase // can't be swapped to Postgres or mocked
cache *RedisCache // can't be tested without Redis running
}
SOLID Review Checklist #
SINGLE RESPONSIBILITY:
□ Every struct/class has one responsibility describable
without the word "and"
□ Database logic lives in repositories, not services
□ Notifications live in a notification service, not a business service
□ No "GodObject" or "UtilService" holding everything
OPEN/CLOSED:
□ Adding new behavior doesn't require modifying existing files
□ No switch-cases or nested if-elses growing every sprint
□ Extension happens through new interfaces or new structs
LISKOV SUBSTITUTION:
□ No interface implementation panics or returns errors
outside what's documented
□ Code using an interface doesn't need type assertions
to work correctly
□ All interface implementations can be substituted without changing
program behavior
INTERFACE SEGREGATION:
□ Interfaces don't have methods irrelevant to any of their consumers
□ No interface implementation has methods that panic or
are left empty because they "don't apply"
□ Go interfaces ideally have 1–3 methods per interface
DEPENDENCY INVERSION:
□ Business logic depends on interfaces, not concrete
database, HTTP client, or external service types
□ Dependencies injected through constructors, not instantiated
inside functions
□ Unit tests don't need database, Redis, or external service connections
□ No global variables holding concrete implementations
Summary #
- SRP — one module, one reason to change: separate business logic, database, notifications, and reporting into different components. The test: a responsibility description must not contain the word “and”.
- OCP — open for extension, closed for modification: use interfaces so new behavior can be added without modifying existing, tested code. Switch-cases that keep growing are a signal OCP is violated.
- LSP — implementations must fulfill the interface contract: don’t create implementations that panic or return errors beyond what’s promised. Code using an interface must not need type assertions to work.
- ISP — small, focused interfaces: in Go, interfaces with 1–3 methods are the right idiom — easier to implement and easier to mock. Don’t force structs to implement irrelevant methods.
- DIP — depend on abstractions, not implementations: inject dependencies as interfaces through constructors so business logic can be tested without a database or real infrastructure.
- SOLID reinforces itself: SRP encourages small components → OCP encourages interfaces → ISP keeps interfaces small → DIP makes everything testable → LSP ensures safe substitution.
- Not dogma: don’t over-engineer with unneeded abstractions. An interface with one implementation that’s never swapped adds no value. Apply SOLID where it provides real benefit: growing systems, multi-engineer teams, and testability needs.