Skip to content

SOLID

Five design principles for writing maintainable object-oriented code. Each principle addresses a specific way classes and dependencies can become hard to change or test.

S — Single Responsibility

A class should have one reason to change. If a class handles both business logic and database access, a change to the database schema forces you to touch business logic code — and vice versa.

Split responsibilities into separate classes. Each one changes only when its own concern changes.

O — Open/Closed

A class should be open for extension but closed for modification. Adding new behavior should not require editing existing, working code — only adding new code.

Achieved through interfaces and inheritance: you add a new implementation rather than modifying the existing one.

L — Liskov Substitution

Any implementation of an interface should be substitutable for any other without breaking the program. If you swap UserRepository for InMemoryUserRepository, everything that depends on IUserRepository should continue to work correctly.

In practice: don't implement an interface partially, and don't override behavior in a way that violates the contract the interface implies.

I — Interface Segregation

Don't force a class to implement methods it doesn't need. Prefer several small, focused interfaces over one large general-purpose one.

If an interface has 10 methods but most implementations only use 3, split it.

D — Dependency Inversion

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

In practice: a class should declare a dependency on an interface, not on a concrete implementation. The concrete class is wired in from outside (via dependency injection), not instantiated internally.

UserService  →  IUserRepository  (interface — the contract)
               UserRepository    (implementation — the detail)

UserService never imports UserRepository directly. It only knows about IUserRepository. This means the implementation can be swapped without touching UserService.

.NET:

// The contract
public interface IUserRepository {
    Task<User?> GetByIdAsync(int id);
    Task AddAsync(User user);
}

// The implementation
public class UserRepository : IUserRepository {
    public async Task<User?> GetByIdAsync(int id) { /* SQL query */ }
    public async Task AddAsync(User user) { /* SQL insert */ }
}

// Wired together at the entry point
builder.Services.AddScoped<IUserRepository, UserRepository>();

Spring Boot:

// The contract — Spring generates the implementation at runtime
public interface UserRepository extends JpaRepository<User, Long> { }

// Depends only on the interface
@Service
public class UserService {
    public UserService(UserRepository userRepository) { ... }
}

The benefit is swappability: swap in an in-memory implementation for tests, or a different database later, without touching any other class. Everything depends on the interface, so nothing else needs to change.


Clean Architecture applies DIP at the layer level: the Domain defines interfaces, Infrastructure implements them. See Clean Architecture.