Classic OOP: A Minor Bug, Five Days, and the Real Problem of Dependency
A minor bug that takes five days to fix can teach more about OOP than another Dog, Cat, or Car example. The deeper issue is how software organizes dependency.
August 20, 2026 · 19 min read

Part 3 Road to Pure Solution Architect
A very small bug, and a developer asks for five days
In a tech talk about OOP, I started with a situation almost every experienced developer has seen. A PM comes over and says the team has found a minor bug. From the requirement, it looks like a local change in one module. I open the history to see who wrote the code and discover that the author, about a month earlier, was me.
Knowing I wrote it should be good news. I have the context, understand the original intention, and do not need to reverse engineer somebody else's design. But after looking at the surrounding structure, I already know this is not the kind of bug I can safely fix in a few hours. I tell the PM that I need about five days, with a little buffer in case something unexpected appears.
Five days for a tiny bug already sounds wrong. It turns out that five days are not even the worst part.
The next morning I change the function that actually contains the bug. The fix is only a few lines, but the code no longer builds because another module depends on what I changed. I move to that module and adjust it. Then another module needs to change. A bug that belongs to one function gradually becomes a chain of edits across modules that, from a business perspective, should have nothing to do with the original requirement.
By the end of the fifth day, the PM asks whether the bug is done. I still cannot confidently say yes.
This is a classic symptom of Rigidity. A system is rigid when a small change cannot remain local. Changing one module forces us to change several others just to keep the system building and running.
The story continues.
Eventually I deploy the fix. The original function works. The following morning, while I am sitting down with a coffee, Teams notifications start appearing. Another team says yesterday's change broke one of their functions. I never intended to modify their business behavior, but while following the cascade of dependencies I had to touch one of their modules. The tests I knew about passed, yet an assumption buried in that team's business logic was violated.
That is Fragility. A system is fragile when a change in one area can break another area in a way the developer could not reasonably predict.
At this point the PM does not want to grant another five days. He proposes what sounds like a safer shortcut: Truong, a developer in another team, already has a function implementing almost exactly the same business behavior. Instead of continuing to repair the old code, take Truong's implementation and reuse it.
I open Truong's module. The business logic is indeed what I need, but the function depends on a vector database my module does not use. It also calls one small function from another very large module. To move this code, I cannot move only the business capability; I have to drag the database, the large module, and the dependencies behind them.
That is Non-reusability. The code contains useful logic, but it cannot be reused because it is too tightly attached to unrelated details.
The same minor bug has now exposed three symptoms:
One small change
|
+--> many modules must change -> Rigidity
|
+--> unrelated behavior gets broken -> Fragility
|
+--> useful code cannot be reused -> Non-reusability
Only after reaching this point in the talk did I return to the question of what OOP actually is. Starting with Dog, Cat, or Car makes it easy to miss the architectural problem that object orientation can help us address.
Original talk: Are You Sure You Really Know OOP?
The root cause is not the number of lines
A two-thousand-line function is hard to read. Duplication increases maintenance cost. Poor naming increases cognitive load. But even if every function in this story were short and beautifully formatted, the system could still be rigid, fragile, and difficult to reuse.
The deeper issue is dependency.
My module does not exist in isolation. It depends on other modules, and other modules depend on it in ways that were never intentionally designed. Dependency itself is not a defect; real systems require components to collaborate. The question is whether those dependencies point in directions we actually want.
We can simplify the story into three levels:
High-level Module
|
v
Middle-level Module
|
v
Low-level Module
At runtime, the high-level module calls the middle-level module, which calls the low-level module. That is ordinary flow of control.
If source-code dependency follows exactly the same direction:
High-level Module
|
| import
v
Middle-level Module
|
| import
v
Low-level Module
the low-level module becomes a direct dependency of the chain above it. A change near the bottom can trigger rebuilding, retesting, and sometimes source changes across the layers above. In a small monolith this may mean a few extra seconds of compilation. In a large system with separate modules, owners, and release cycles, it becomes an architectural constraint.
The minor bug took five days not because the developer did not know how to repair a function. The difficult part was that the function lived inside a dependency network where change was no longer local.
That is a much more useful place to start talking about OOP.
OOP is not the art of turning the real world into classes
A common introductory explanation says that OOP “models the real world in code”: a Car is an object, a Dog inherits from Animal, and a BankAccount has properties and methods. This is useful for teaching syntax, and it has some historical grounding. Simula was developed by Dahl and Nygaard for simulation, so representing entities in a simulated system as objects is genuinely close to the origins of object-oriented programming.
But historical origin should not be mistaken for the final definition.
In the bug story, what we need is not a class diagram that mirrors reality more accurately. We need module boundaries that prevent business policy from being tied directly to databases, frameworks, and other implementation details.
Real codebases contain many abstractions that correspond to no meaningful physical object:
Repository
RetryPolicy
UnitOfWork
MessageDispatcher
CacheProvider
AuthorizationPolicy
SearchStore
They exist because software needs boundaries around responsibilities and dependencies.
I therefore see OOP primarily as a way of organizing software around object boundaries and protocols, where dynamic polymorphism gives us a mechanism to control source-code dependencies while preserving the runtime control flow the system needs.
The classic concepts—encapsulation, inheritance, and polymorphism—become much more interesting when placed inside that problem.
Encapsulation: when a module exposes only its contract, a bug has fewer paths to escape
Return to the original bug. One reason a change propagates across module boundaries is that callers know too much about the callee's implementation. They may know the concrete type, data structure, internal state, or a sequence of operations that should have remained an implementation decision.
Once an implementation detail becomes knowledge in the caller, it becomes another dependency.
Encapsulation attempts to cut those dependencies down. A caller should know the contract required for collaboration; representation and internal decisions should remain inside the boundary.
OOP did not invent encapsulation. C could provide extremely strict representation hiding long before private became a familiar keyword.
Strong encapsulation with .h and .c in C
Suppose the low-level module in our bug story manages a Store. Clients need a few operations but should not care whether the store uses SQL, a vector index, or another internal structure.
Filename: store.h
#ifndef STORE_H
#define STORE_H
typedef struct Store Store;
Store* store_create(void);
void store_destroy(Store* store);
int store_save(Store* store, const char* key, const char* value);
const char* store_find(Store* store, const char* key);
#endif
The header publishes only the contract. Store is an incomplete type: the client knows the type exists but cannot see its representation.
The implementation stays elsewhere.
Filename: store.c
#include "store.h"
#include <stdlib.h>
struct Store {
void* internal_index;
unsigned long version;
};
Store* store_create(void)
{
Store* store = malloc(sizeof(Store));
if (!store) {
return NULL;
}
store->internal_index = NULL;
store->version = 0;
return store;
}
int store_save(Store* store, const char* key, const char* value)
{
if (!store || !key || !value) {
return 0;
}
/* implementation detail */
store->version++;
return 1;
}
const char* store_find(Store* store, const char* key)
{
/* implementation detail */
return NULL;
}
void store_destroy(Store* store)
{
free(store);
}
The consumer sees only the header.
Filename: feature.c
#include "store.h"
void execute(Store* store)
{
store_save(store, "order-1", "completed");
// Cannot access the representation:
// store->internal_index
// store->version
// sizeof(Store)
}
The client is not merely “forbidden” from using the fields; it does not see their declarations at all. At the source-representation level, this is an exceptionally strong form of encapsulation.
OO languages make the same discipline more convenient:
public sealed class Store
{
private object internalIndex;
private long version;
public void Save(string key, string value)
{
// ...
}
}
The developer does not have to create the same manual header/implementation boundary for every object. The language and compiler provide public, private, and protected.
That convenience comes with a subtle distinction. In traditional C++, private members still appear in the class declaration. The C++ standard specifies that access control does not prevent a member from participating in name lookup; it prevents unauthorized code from using it. With an opaque C type, the client does not even know what the representation contains.
If “perfect encapsulation” means hiding representation completely from client source, the opaque .h/.c technique in C can be stricter than an ordinary class declaration.
OOP did not make encapsulation stronger in every possible sense. It made encapsulation more convenient, more closely integrated with the object model, and easier to apply consistently.
In the bug story, the practical value is straightforward: if the middle-level module exposes only a narrow contract and keeps representation private, an internal implementation change has fewer reasons to force callers to change.
Encapsulation does not remove dependency. It narrows what the caller is allowed to depend on.
Inheritance: the wrong kind of reuse can create more of the dependency that caused the problem
When the PM loses patience, he asks us to reuse Truong's code. This is exactly where developers can be tempted to reach for inheritance: if two modules share behavior, create a base class and reuse the implementation.
Inheritance can be appropriate when the types have a stable substitutable relationship. But if the real goal is only “get this code into my module,” inheritance can deepen dependency rather than reduce it.
Suppose both modules require search behavior:
BaseSearchModule
├── MyModule
└── TruongModule
If BaseSearchModule begins to contain vector-database state, lifecycle rules, caching assumptions, and helper methods specific to Truong's use case, my module is no longer reusing one algorithm. It inherits a collection of assumptions.
That is precisely the sort of coupling behind the non-reusability in our story.
C++ multiple inheritance shows that inheritance is not a free relationship
C++ allows a class to derive from more than one base:
class Searchable {
public:
virtual void search() = 0;
};
class Cacheable {
public:
virtual void cache() = 0;
};
class FeatureModule : public Searchable, public Cacheable {
public:
void search() override { /* ... */ }
void cache() override { /* ... */ }
};
This is powerful because one object can participate in multiple type hierarchies. Complexity becomes obvious when two branches share the same base.
class Module {
public:
int version;
};
class SearchModule : public Module {};
class CacheModule : public Module {};
class FeatureModule : public SearchModule, public CacheModule {};
The hierarchy forms the familiar diamond:
Module
/ \
SearchModule CacheModule
\ /
FeatureModule
With ordinary inheritance, a FeatureModule contains two Module subobjects:
FeatureModule
├── SearchModule
│ └── Module
└── CacheModule
└── Module
feature.version is ambiguous because there are two versions.
C++ supports virtual inheritance when those branches should share one base:
class SearchModule : virtual public Module {};
class CacheModule : virtual public Module {};
class FeatureModule : public SearchModule, public CacheModule {};
The most-derived object then contains one shared Module base subobject.
C++ solves the problem, but developers must reason about object layout, construction order, ambiguity, and virtual-base semantics. Multiple inheritance is not inherently wrong. It simply makes visible that inheritance carries state, implementation, and lifecycle dependencies that are much deeper than an arrow on a class diagram suggests.
Java and C# keep one class hierarchy while allowing multiple protocols
Java and C# made a different trade-off: a class has one direct base class, while it can implement multiple interfaces.
Applied to Truong's module, instead of forcing my module to inherit his implementation, both modules can satisfy a protocol:
public interface ISearchStore
{
Task<Result> Search(Query query);
}
public sealed class VectorSearchStore : ISearchStore
{
public Task<Result> Search(Query query)
{
// vector database implementation
}
}
public sealed class SqlSearchStore : ISearchStore
{
public Task<Result> Search(Query query)
{
// SQL implementation
}
}
A class can implement several capabilities:
public sealed class FeatureModule :
ISearchable,
ICacheable,
IAuditable
{
// ...
}
Traditional interfaces do not carry instance state the way concrete base classes do. Java interface fields are public static final; C# interfaces do not contain instance fields. Multiple interface inheritance therefore allows multiple contracts without creating duplicated base-object state like class-based diamond inheritance can.
Modern Java and C# interfaces can provide default behavior to varying degrees, so behavioral conflicts can still occur. Their language rules resolve those conflicts at compile time or require an explicit implementation, rather than constructing a multiple-instance-state graph.
The trade-off can be summarized as:
Multiple class inheritance
= contract + state + implementation relationships
Multiple interfaces
= multiple contracts,
while instance state remains with the class
In our bug story, reusing Truong's capability does not require inheriting Truong's module. What we actually want is to separate the capability we need from the implementation details we do not want to inherit.
That naturally leads to polymorphism.
Polymorphism is where dependency direction starts to change
The most important part of the talk was not that a Dog and a Cat can override Speak(). Dynamic polymorphism becomes much more valuable when runtime control flow can move in one direction while source-code dependency points in another.
Return to Truong's module.
The dependency may currently look like this:
My Feature
|
v
Truong Search Module
|
+--> Vector Database
|
+--> Large Shared Module
If I call TruongSearchModule directly, reusing it means accepting all the dependencies it brings.
Instead, put a protocol on the high-level side:
public interface ISearchStore
{
Task<Result> Search(Query query);
}
The business module depends only on that abstraction:
public sealed class FeatureService
{
private readonly ISearchStore searchStore;
public FeatureService(ISearchStore searchStore)
{
this.searchStore = searchStore;
}
public Task<Result> Execute(Query query)
{
return searchStore.Search(query);
}
}
The vector implementation lives on the detail side:
public sealed class VectorSearchStore : ISearchStore
{
public Task<Result> Search(Query query)
{
// call vector database
}
}
My module can use a different implementation:
public sealed class SqlSearchStore : ISearchStore
{
public Task<Result> Search(Query query)
{
// call SQL
}
}
Runtime control still moves from business code to the implementation:
FeatureService
|
| runtime call
v
SqlSearchStore
But source dependency is organized differently:
FeatureService
|
v
ISearchStore
^
|
SqlSearchStore
The low-level implementation depends on the protocol required by the high-level module, rather than the high-level module importing the concrete implementation.
That is Dependency Inversion.
Dynamic polymorphism allows the implementation to be selected at runtime. FeatureService does not need to know whether it is running with SqlSearchStore, VectorSearchStore, an in-memory test double, or an implementation that did not even exist when the business module was written.
The important point is not that an interface makes the code look cleaner. The interface creates a boundary at which dependency direction can change.
If that boundary had existed from the beginning, how would the minor bug behave?
No architecture guarantees that every minor bug takes ten minutes. Business rules can be complex, production data can be corrupted, and distributed systems create failure modes that are genuinely hard to reproduce. OOP and Dependency Inversion do not remove software complexity.
They change how far a modification is allowed to propagate.
Suppose the original bug lives inside SqlSearchStore. If the high-level module depends only on ISearchStore, an implementation detail inside the SQL adapter can change without modifying the business module as long as the contract remains stable.
Before
Business
|
v
Concrete Module
|
v
Database
Change Database Detail
|
+--> Business may need changes
+--> Other modules may need changes
+--> Reuse drags the database along
After
Business
|
v
Protocol
^
|
Concrete Module
|
v
Database
Change Database Detail
|
+--> remains inside the concrete boundary
Rigidity is reduced because a low-level change does not automatically cascade into high-level source code. Fragility is reduced because callers do not rely on the implementation's internal details, and a smaller contract gives us a clearer target for tests. Non-reusability is reduced because the business capability depends on a protocol, so we can replace the adapter instead of carrying an entire technology stack from the original environment.
I deliberately say “reduced.” An abstraction is not magic. If the contract's semantics change, callers and implementations may still have to change. If the interface is too broad, coupling has merely moved from a concrete class into a bad abstraction. If an implementation violates the behavioral contract, runtime behavior can still break.
Good OOP does not make dependency disappear. It makes dependency more intentional.
A more balanced definition of OOP
If we say only that “OOP is about dependency management,” the definition is very useful architecturally but slightly too narrow. It leaves out what distinguishes an object model from a set of callbacks or function pointers.
A more balanced definition I use is:
Object-oriented programming is a way of organizing software into objects with encapsulated identity, state, and behavior that collaborate through protocols; dynamic polymorphism allows implementations to be selected at runtime while the caller depends only on an abstraction, enabling substitution and dependency inversion.
Put back into the bug story, each part becomes concrete.
Identity
A runtime object is a specific receiver with its own lifecycle and ownership. Two implementations of the same protocol are not therefore the same object.
State
An object can own the state required for its responsibility. VectorSearchStore may hold a connection, index handle, or cache state; SqlSearchStore can own different state.
Behavior
State is not merely a data bag for callers to manipulate. The object provides behavior associated with its responsibility.
Encapsulation
Callers do not need to know the internal index, connection strategy, or data layout. Those decisions stay behind the object's boundary.
Protocol
FeatureService collaborates through ISearchStore. It knows the capability it needs, not the concrete implementation.
Dynamic polymorphism
At runtime, ISearchStore can resolve to VectorSearchStore, SqlSearchStore, or another implementation.
Substitution
If the implementations satisfy the same semantic contract, one can replace another without high-level policy knowing the concrete type.
Dependency inversion
Because high-level policy owns the need for the protocol and low-level details implement it, source dependency no longer has to follow runtime control flow.
These ideas form one coherent story rather than a collection of unrelated keywords.
Encapsulation, inheritance, and polymorphism do not carry equal architectural weight
Introductory material often presents the classic concepts side by side as equivalent OOP features. Viewed through the minor-bug story, their roles differ substantially.
Encapsulation helps reduce how many implementation decisions leak across a boundary. It reduces knowledge coupling.
Inheritance can build a useful type hierarchy and enable substitution, but it can also create a strong dependency on base implementation. It therefore deserves care, especially when the real purpose is simply code reuse.
Polymorphism—particularly dynamic polymorphism through a stable protocol—allows us to separate runtime behavior from compile-time dependency. This leads directly to plugin architectures, test seams, substitution, and Dependency Inversion.
A compact view is:
Encapsulation
-> caller knows less about implementation
Inheritance
-> creates a type relationship,
but also a dependency on the base
Polymorphism
-> caller invokes behavior without knowing concrete receiver
Dependency Inversion
-> high-level policy no longer imports low-level detail
This is why polymorphism becomes much more significant at the architecture level than the simple idea of overriding methods.
An interface can also become a bad dependency
After understanding Dependency Inversion, another dangerous reflex is to create an interface for every class.
IUserService
UserService
IOrderService
OrderService
IInvoiceService
InvoiceService
If an interface and its implementation always change together, live in the same module, have no alternative implementations, create no useful test seam, and protect no meaningful volatility boundary, we have added indirection rather than architecture.
In the bug story, ISearchStore has a reason to exist because the business capability and storage technology change for different reasons. A vector database, SQL database, or vendor can change while the use case remains the same.
An abstraction earns its place when it separates things with different reasons to change.
Business policy
more stable
|
v
Protocol
^
|
Database / Framework / Vendor
more volatile
This is also a better way to understand the Single Responsibility Principle. “A class should do one thing” is memorable but imprecise. Responsibility is more useful when understood through reason to change. Code that changes for the same actor or business reason tends to belong together; code that changes for different reasons should often be separated so those changes do not drag each other along.
Again, we end up talking about dependency.
OOP does not own dependency inversion
If dependency inversion is the goal, OOP is not the only way to achieve it.
C has function pointers:
typedef int (*SearchFn)(const char* query);
int execute_feature(const char* query, SearchFn search)
{
return search(query);
}
Functional programming has higher-order functions:
type Search = (query: Query) => Promise<Result>;
async function executeFeature(query: Query, search: Search) {
return search(query);
}
In both cases, high-level code receives behavior from the outside instead of hard-coding a concrete implementation.
What OOP makes convenient is the combination of identity, state, behavior, encapsulation, protocols, and dynamic dispatch inside an object model directly supported by the language, compiler, and runtime. Developers do not have to build the whole discipline manually with function pointers, conventions, or dispatch tables.
I therefore do not treat Dependency Inversion as exclusive to OOP. I treat the use of dynamic polymorphism to control dependencies as one of OOP's strongest architectural capabilities.
The “four pillars” are a good starting point, not the end
Encapsulation, abstraction, inheritance, and polymorphism remain a useful introduction. The problem begins when we can recite those four words and assume we have understood OOP.
The five-day minor bug exposes another layer.
Encapsulation
is not merely private;
it limits what callers are allowed to know
Inheritance
is not merely "is-a";
it is a dependency relationship with a cost
Polymorphism
is not merely many objects sharing one method;
it selects behavior at runtime through a stable protocol
OOP
is not merely turning nouns into classes;
it organizes objects and dependencies around meaningful boundaries
Seen this way, concepts that sound introductory connect directly to software architecture.
A small bug should be allowed to remain a small bug
I still prefer the PM's bug story to another example involving Dog and Animal, because it puts OOP inside a problem developers experience every day.
The PM does not care whether the code contains all four pillars. The business does not pay for an elegant class diagram. What they experience is a tiny bug taking five days, the fix breaking another team, and existing code being impossible to reuse because it drags an entire technology stack with it.
That is the cost of dependency.
Good architecture does not promise that every change will be easy. It tries to ensure that a change touches only the parts that genuinely share the same reason to change.
From that perspective, I no longer see OOP as a technique for modeling the real world through classes. I see it as a way to organize software so objects own state and behavior, boundaries hide implementation decisions, collaborators communicate through protocols, and dynamic polymorphism lets the runtime select an implementation while high-level source code depends only on an abstraction.
Encapsulation
keeps details inside the boundary
Protocols
limit what collaborators need to know
Dynamic polymorphism
separates caller from concrete implementation
Substitution
makes implementations replaceable
Dependency inversion
protects high-level policy from low-level details
If the system in our story had been organized around these boundaries, the PM could still have handed us a difficult bug. But at least a bug inside one module would not automatically become a reason to modify half the system.
That is the part of classic OOP worth understanding beyond another definition.
References
- Are You Sure You Really Know OOP? — Tech Talk
- Computer History Museum — Simula, 1965
- WG14 C specification material — incomplete structure types
- C++ Draft Standard — Member Access Control
- C++ Draft Standard — Multiple Base Classes
- Bjarne Stroustrup — C++ FAQ on Object-Oriented Programming
- Java Language Specification — Classes
- Java Language Specification — Interfaces
- C# Language Specification — Classes
- C# Language Specification — Interfaces
Was this article helpful?
Comments (0)
No published comments yet.
Be the first to share your perspective.

Zi
With more than 11 years of experience as a software engineer, I specialize in consulting on and designing robust enterprise systems. I am passionate about programming and software development, and I have mastered industry best practices and developed innovative solutions that improve operational efficiency. As a consultant, I am committed to understanding each client's unique needs and goals and developing tailored strategies to address their specific challenges. I would welcome the opportunity to contribute my expertise as a knowledgeable and proactive partner in helping your enterprise thrive.
Solution Architect