Skip to content

Basic OOP and SOLID principles, and how I had to unlearn them to really learn them

@developers
@developers 13 min read
Share

Every object-oriented programming developer knows SOLID and basic OOP principles. They frequently come up in job interviews, so it’s something you’re almost required to memorize when you’re fresh out of university and are just starting your career search. I was no different — back then, you could wake me up in the middle of the night and I’d still be able to recite the rules, definitions, and even provide a few textbook examples.

Then came the experience. The business kept pushing for more features, and suddenly the priority shifted to delivering working solutions rather than writing “pretty” code. You may have even worked with that one developer — the one who had just finished reading Uncle Bob or some other OOP book — and who filled every merge request with comments about following the rules to the letter. But after a while, even they gave up, realizing that in the real world, things don’t always fit neatly into theory.

And so you kept gathering experience. Years flew by, and instead of writing hundreds of lines of code, you had already written thousands. Projects kept growing, and with them came the realization of just how messy things could get.

Encapsulation

At some point, you’re asked to add a new feature: from now on, the system needs to track in the database whether an entity was just created or later modified. Sounds like a simple task, right?

You start thinking about the code:

namespace Playground;

public class User
{
    public string FirstName { get; private set; }

    public string LastName { get; private set; }

    public DateTime BirthDate { get; private set; }

    public bool IsModified { get; private set; } // you add that

    public User(string firstName, string lastName, DateTime birthDate)
    {
        FirstName = firstName;
        LastName = lastName;
        BirthDate = birthDate;
    }

    public void Modify(string? firstName = null, string? lastName = null, DateTime? birthDate = null)
    {
        if (firstName != null)
        {
            FirstName = firstName;
            IsModified = true; // add that to edit method
        }

        if (lastName != null)
        {
            LastName = lastName;
            IsModified = true; // add that to edit method
        }

        if (birthDate != null)
        {
            BirthDate = birthDate.Value;
            IsModified = true; // add that to edit method
        }
    }

    public override string ToString()
    {
        return "User: " + FirstName + " " + LastName + "Born in: " + BirthDate + " and " +
               (IsModified ? "was modified" : "wasn't modified");
    }
}

You write a couple of basic tests:

var user = new User("First", "Last", DateTime.UtcNow.AddYears(-30));

Console.WriteLine(user.ToString());

user.Modify(lastName: "LastName2");

Console.WriteLine(user.ToString());

// User: First LastBorn in: 8/29/1995 4:09:23 PM and wasn't modified
// User: First LastName2Born in: 8/29/1995 4:09:23 PM and was modified

At first, you are not too worried about how many times it’s being used. After all, the User object is responsible for its own creation and its own modifications—so it feels like everything is covered. 👍

Until it isn’t…

You open the code again, but instead of finding what you expected, you see the class that isn’t really encapsulated at all.

namespace Playground;

public class User
{
    public string FirstName { get; set; }

    public string LastName { get; set; }

    public DateTime BirthDate { get; set; }

    public bool IsModified { get; set; } // you add that

    public User()
    {
    }

    public override string ToString()
    {
        return "User: " + FirstName + " " + LastName + "Born in: " + BirthDate + " and " +
               (IsModified ? "was modified" : "wasn't modified");
    }
}

Each property ends up being set in multiple ways. Sometimes the entity is read from the database, sometimes it’s created through the constructor. Other times properties are assigned because the entity has just been created — or because it has just been edited.

What were once a few clean lines of code now requires you to monitor every single set across the class. And with that comes the very real chance of either missing something or analyzing it incorrectly.

Instead of a simple change that takes just a couple of minutes, you now have to wade through lots of different scenarios. You’re constantly switching context, depending on where in the code you are and why a certain setter is being used. Along the way, you’re likely to introduce a few bugs — because analyzing dozens of setters is anything but straightforward. What should have been quick and easy ends up taking far too much time.

Interface hell

This one is interesting. With encapsulation, the problem was that it wasn’t used often enough. With abstractions, it’s the complete opposite. Suddenly, every single class that gets created also comes with its own interface.

Abstraction is supposed to be about hiding the details — making helper methods private and exposing only what’s important to the consumer of the class.

Then comes the S in SOLID: the Single Responsibility Principle (SRP). We are often told our classes should follow it, but what does that really mean in practice?

If I have User and Role entities, does that mean I should split the responsibility simply because they’re different entities?

public abstract class UserRepository
{
    public abstract void AddUser(User user);
    public abstract void EditUser(int id, User user);
    public abstract void DeleteUser(int id);
}

public abstract class RoleRepository
{
    public abstract void AddRole(Role user);
    public abstract void EditRole(int id, Role user);
    public abstract void DeleteRole(int id);
}

Or maybe the split should happen because of the type of operation instead? For example, should the process of creating a User be handled by one abstraction, updating a User by another, and reading a User by yet another one?

public abstract class RepositoryCreator
{
    public abstract void AddUser(User user);
    public abstract void AddRole(Role user);
}

public abstract class RepositoryEditor
{
    public abstract void EditUser(int id, User user);
    public abstract void EditRole(int id, Role user);
}

For example, I once ended up with something like this:

public abstract class Repository<T> where T : class
{
    public abstract void Add(T entity);
    public abstract void Edit(int id, T entity);
    public abstract void Delete(int id);
}

And that brings me to another principle:

I — Interface Segregation Principle (ISP)Clients shouldn’t be forced to depend on methods they don’t use.

In practice, our HTTP layer already models this separation. Distinct endpoints for distinct actions are exposed:

POST   /user
PUT    /user
DELETE /user
POST   /role
PUT    /role
DELETE /role

No matter how we inject classes behind them, each endpoint typically uses exactly one operation. The only truly reusable bit in a simple CRUD case is GET, which is shared by all of them plus the GET endpoint itself.

So… does that mean we must create a separate interface for every single method to “comply” with ISP? IUserCreatorIUserUpdaterIUserDeleterIRoleCreator, and so on—just because the rule exists? 😏

D — Dependency Inversion Principle (DIP)

There are a couple of use cases here. Rumor has it that somewhere on this planet, a developer managed to register a different implementation of their repository — one that connected to a completely different database — without doing a major refactor. Legendary stuff.

Now, let’s exclude EF Core from this story, because in EF Core you can already achieve this kind of switch simply by changing the registration. That doesn’t really count.

SQL Server → .UseSqlServer(...)

SQLite → .UseSqlite(...)

InMemory → .UseInMemoryDatabase(...)

Cosmos DB → .UseCosmos(...)

The second most common use case is testing. If you want to both write a proper unit test and isolate part of the application, you often end up mocking behavior behind an interface. But that’s another story

So how do we deal with all these complaints, and how do we stop overthinking while building modern microservice APIs?

One practical approach is to lean on the CQRS pattern. Each endpoint gets its own dedicated command or query, which can be reused in other parts of the system if needed (for example, a query used in a GET endpoint and in some background operation). And, more importantly: don’t create an interface unless you actually need one.

If you’re building a class that connects to an external dependency, then yes — mock it in your service tests. But for everything else, treat the class as a black box and test it through real use cases. It’s usually simpler, more realistic, and avoids drowning in the sea of pointless abstractions.

Object shared properties

Last but not least, let’s take a look at our entities themselves. In many systems, we want some entities to be audited — to track who created and last modified them. On top of that, some should also be soft deletable, meaning they aren’t really removed from the database but are instead flagged as deleted while still existing in storage.

Let’s start with some basic code, keeping the logic directly inside the entities:

public class Role
{
    private Role()
    {
        // for EF
    }

    public Role(string name)
    {
        Name = name;
    }

    [Key]
    public int Id { get; private set; }

    public string Name { get; private set; }

    public bool IsDeleted { get; private set; }

    public void Delete()
    {
        IsDeleted = true;
    }
}

public class User
{
    private User()
    {
        // for EF
    }

    public User(string firstName, string lastName, DateTime dateOfBirth, string createdBy)
    {
        var now = DateTime.UtcNow;
        FirstName = firstName;
        LastName = lastName;
        DateOfBirth = dateOfBirth;
        CreatedBy = createdBy;
        ModifiedBy = createdBy;
        CreatedAt = now;
        ModifiedAt = now;
    }

    public void Update(string firstName, string modifiedBy)
    {
        FirstName = firstName;
        ModifiedBy = modifiedBy;
        ModifiedAt = DateTime.UtcNow;
    }

    public void Delete(string modifiedBy)
    {
        IsDeleted = true;
        ModifiedAt = DateTime.UtcNow;
        ModifiedBy = modifiedBy;
    }

    public int Id { get; set; }

    public string FirstName { get; private set; }

    public string LastName { get; private set; }

    public DateTime DateOfBirth { get; private set; }

    public string CreatedBy { get; protected set; }

    public string ModifiedBy { get; protected set; }

    public DateTime CreatedAt { get; protected set; }

    public DateTime ModifiedAt { get; protected set; }

    public bool IsDeleted { get; private set; }
}

And here’s how we handle it in code:

var services = new ServiceCollection();
services.AddDbContext<DemoDbContext>(options =>
    options.UseInMemoryDatabase("MyInMemoryDb"));

var provider = services.BuildServiceProvider();

var testRole = new Role("myRole");
var testUser = new User("myFirstName", "myLastName", DateTime.UtcNow.AddYears(-30), "creator@mail.com");
using var db = provider.GetRequiredService<DemoDbContext>();

// Adding entity
db.Users.Add(testUser);
db.Roles.Add(testRole);
db.SaveChanges();
Console.WriteLine($"User post creation: modified: {testUser.ModifiedAt}, isDeleted: {testUser.IsDeleted}, modifiedBy: {testUser.ModifiedBy}");
Console.WriteLine($"Role post creation: deleted: {testRole.IsDeleted}");

testUser.Update("newName", "updater@mail.com");
db.Users.Update(testUser);
db.SaveChanges();
Console.WriteLine($"User post update: modified: {testUser.ModifiedAt}, isDeleted: {testUser.IsDeleted}, modifiedBy: {testUser.ModifiedBy}");

testRole.Delete();
testUser.Delete("remover@gmail.com");
Console.WriteLine($"User post deletion: modified: {testUser.ModifiedAt}, isDeleted: {testUser.IsDeleted}, modifiedBy: {testUser.ModifiedBy}");
Console.WriteLine($"Role post deletion: deleted: {testRole.IsDeleted}");

Which gives us the following output:

User post creation: modified: 29/08/2025 19:40:43, isDeleted: False, modifiedBy: creator@mail.com
Role post creation: deleted: False
User post update: modified: 29/08/2025 19:40:43, isDeleted: False, modifiedBy: updater@mail.com
User post deletion: modified: 29/08/2025 19:40:43, isDeleted: True, modifiedBy: remover@gmail.com
Role post deletion: deleted: True

It works — but the problem is that this logic gets duplicated across every entity with these properties. And even if we manage to implement it everywhere, what happens when we would like to add another auditable field? Suddenly, we’re forced to touch dozens of places in the codebase. Technical debt keeps growing.

What should have been a small localized change turns into a widespread refactor, simply because the logic was spread out.

That’s where a couple of the other SOLID rules come in handy:

O — Open/Closed Principle (OCP): classes should be open for extension but closed for modification.

L — Liskov Substitution Principle (LSP): subclasses must be usable in place of their base classes.

And this is where two classic OOP tools enter the picture:

Inheritance → A class can derive from another, reusing and extending behavior.

Polymorphism → The same operation can behave differently depending on the object.

Let’s specify our objects a bit better:

public abstract class SoftDeletable
{
    public bool IsDeleted { get; protected set; }

    public void Delete()
    {
        IsDeleted = true;
    }
}

public abstract class Auditable : SoftDeletable
{
    public string CreatedBy { get; protected set; }

    public string ModifiedBy { get; protected set; }

    public DateTime CreatedAt { get; protected set; }

    public DateTime ModifiedAt { get; protected set; }

    public void Create(string createdBy)
    {
        var now = DateTime.UtcNow;
        CreatedBy = createdBy;
        ModifiedBy = createdBy;
        CreatedAt = now;
        ModifiedAt = now;
    }

    public void Modify(string modifiedBy)
    {
        ModifiedBy = modifiedBy;
        ModifiedAt = DateTime.UtcNow;
    }
}

public class Role : SoftDeletable
{
    private Role()
    {
        // for EF
    }

    public Role(string name)
    {
        Name = name;
    }

    [Key]
    public int Id { get; private set; }

    public string Name { get; private set; }
}

public class User : Auditable
{
    private User()
    {
        // for EF
    }

    public User(string firstName, string lastName, DateTime dateOfBirth)
    {
        FirstName = firstName;
        LastName = lastName;
        DateOfBirth = dateOfBirth;
    }

    public void Update(string firstName)
    {
        FirstName = firstName;
    }

    public int Id { get; set; }

    public string FirstName { get; private set; }

    public string LastName { get; private set; }

    public DateTime DateOfBirth { get; private set; }
}

Now, instead of keeping this logic directly in the entities, we can push it into the layer responsible for communicating with the database — in this case, our DbContext:

public class DemoDbContext : DbContext
{
    private readonly UserContext _userContext;

    public DemoDbContext(DbContextOptions<DemoDbContext> options, UserContext userContext)
        : base(options)
    {
        _userContext = userContext;
    }

    public DbSet<Role> Roles { get; set; }

    public DbSet<User> Users { get; set; }

    public override int SaveChanges()
    {
        var user = _userContext.User;

        foreach (var entry in ChangeTracker.Entries())
        {
            if (entry.State == EntityState.Added && entry.Entity is Auditable audAdd)
            {
                audAdd.Create(user);
            }
            else if (entry.State == EntityState.Modified && entry.Entity is Auditable audMod)
            {
                audMod.Modify(user);
            }
            else if (entry.State == EntityState.Deleted && entry.Entity is SoftDeletable soft)
            {
                // convert hard delete -> soft delete
                entry.State = EntityState.Modified;
                soft.Delete();

                // if also auditable, refresh Modified*
                if (entry.Entity is Auditable audDel)
                    audDel.Modify(user);
            }
        }

        return base.SaveChanges();
    }
}

And finally, let’s test it:

var testContext = new UserContext
{
    User = "creator@mail.com"
};

var services = new ServiceCollection();

services.AddDbContext<DemoDbContext>(options =>
    options.UseInMemoryDatabase("MyInMemoryDb"));
services.AddSingleton<UserContext>(_ => testContext);

var provider = services.BuildServiceProvider();

var testRole = new Role("myRole");
var testUser = new User("myFirstName", "myLastName", DateTime.UtcNow.AddYears(-30));
using var db = provider.GetRequiredService<DemoDbContext>();

// Adding entity
db.Add(testUser);
db.Add(testRole);
db.SaveChanges();
Console.WriteLine($"User post creation: modified: {testUser.ModifiedAt}, isDeleted: {testUser.IsDeleted}, modifiedBy: {testUser.ModifiedBy}");
Console.WriteLine($"Role post creation: deleted: {testRole.IsDeleted}");
Console.WriteLine("--------------------");

testUser.Update("newName");
testContext.User = "updater@mail.com";
db.Update(testUser);
db.SaveChanges();
Console.WriteLine($"User post update: modified: {testUser.ModifiedAt}, isDeleted: {testUser.IsDeleted}, modifiedBy: {testUser.ModifiedBy}");
Console.WriteLine("--------------------");

testContext.User = "remover@mail.com";
db.Remove(testUser);
db.Remove(testRole);
db.SaveChanges();
Console.WriteLine($"User post deletion: modified: {testUser.ModifiedAt}, isDeleted: {testUser.IsDeleted}, modifiedBy: {testUser.ModifiedBy}");
Console.WriteLine($"Role post deletion: deleted: {testRole.IsDeleted}");
User post creation: modified: 29/08/2025 20:00:45, isDeleted: False, modifiedBy: creator@mail.com
Role post creation: deleted: False
--------------------
User post update: modified: 29/08/2025 20:00:45, isDeleted: False, modifiedBy: updater@mail.com
--------------------
User post deletion: modified: 29/08/2025 20:00:45, isDeleted: True, modifiedBy: remover@mail.com
Role post deletion: deleted: True

Just by moving the code into the layer that’s actually responsible for it, we end up with fully scalable logic that ensures consistent behavior across all entity types. Any new entity gets this functionality “for free,” and if we ever need to modify the behavior, there’s only one place in which it should be changed.

Not only does it mean a cleaner code — but also #profit! 🚀

Summary

To sum up, OOP seems easy enough to grasp at first — DoNoise() on a Cat returns “Meow!” while the same method on a Dog returns “Bark!”. Simple. But the real magic isn’t in playful examples—it’s in how you apply these principles to real business features and your daily work.

Anyone can recite the definitions and explain the differences. Applying them effectively in real-world projects is a completely different challenge.

Since programming is all about habits, you don’t need to completely change the way you code overnight. Instead, pay attention to what changes waste too much of your (and your team’s) time. Revisit the rules or books you studied in the past, but do it with a fresh pair of eyes — looking for answers to the problems you’re facing right now.

Coming from a problem to the solution is far more valuable than starting with someone else’s solution and trying to force it onto your problem.

Unlearn the rules, relearn the craft.

Share
@developers
@developers

About

Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.

Read more articles

Multithreaded Work Synchronization in C#/.NET — Part 2

Paweł Okrasa

14 min read

Multithreaded Work Synchronization in C#/.NET — Part 3

Paweł Okrasa

5 min read

Multithreaded Work Synchronization in C#/.NET — Part 4

Paweł Okrasa

13 min read

Contact us about your career

Your next chapter. Become a Jiter

Magdalena Dereszewska

Head of HR