Practice object-oriented programming concept interview questions grouped by experience level, from the four pillars to SOLID principles.
Junior (0-2 years)
OOP organizes code around objects, bundles of data and the behavior that operates on that data, rather than a sequence of functions operating on separate data structures. It solves the problem of large procedural codebases becoming tangled and hard to maintain, since related data and logic in OOP live together in one place instead of being scattered across many functions that all need to know that data's exact shape.
Encapsulation, bundling data and the methods that operate on it while controlling access to that data. Inheritance, letting a class reuse and extend another class's behavior. Polymorphism, letting different classes respond to the same method call in their own specific way. Abstraction, hiding implementation details behind a simpler interface.
Procedural programming structures a program as a sequence of functions operating on data that's often passed around separately from those functions. Object-oriented programming bundles data and the functions that operate on it together into objects, which tends to make it easier to model real-world entities and manage complexity as a codebase grows larger.
A class is a blueprint or template defining what properties and behaviors something will have. An object is an actual instance created from that class, with its own specific values for those properties. One class, like Car, can produce many distinct objects, each a separate car with its own color, model, and mileage.
Bundling related data and behavior together means a change to how something works is usually contained within one class, rather than requiring changes scattered across many separate functions that all touch the same data. This localization of change is a big part of what makes a large OOP codebase easier to reason about and modify safely over time.
An instance variable belongs to a specific object, and each instance of the class gets its own separate copy with its own value. A class variable belongs to the class itself, shared across every instance, so changing it through the class affects every object of that class at once.
A constructor is a special method automatically called when a new object is created, typically used to initialize that object's initial state, setting its starting field values. Most object-oriented languages call a class's constructor by default the moment an object is instantiated, without you needing to call it explicitly yourself.
A default constructor takes no arguments and typically initializes fields to some default value. A parameterized constructor accepts arguments, letting the caller specify initial values for an object's fields directly at the moment of creation, rather than needing separate setter calls afterward to configure it.
Method overloading means defining multiple methods with the same name but different parameter lists within the same class. It's called compile-time polymorphism because the compiler determines which specific overloaded version to call based on the arguments provided at the call site, resolved before the program actually runs, not while it's executing.
A static method belongs to the class itself and can be called without creating an instance of that class first. An instance method belongs to a specific object and requires an actual instance to call, since it typically operates on that instance's own specific data.
It refers to the current instance a method is operating on, letting a method access that specific object's own fields and other methods. It's particularly useful for distinguishing between a parameter and an instance field that happen to share the same name.
Each object gets its own separate copy of the class's instance variables, so changing one object's data has no effect on another object's data, even though they were both created from the exact same class blueprint. The class defines the shared structure and behavior. Each object holds its own independent state within that structure.
Encapsulation means bundling data and the methods that operate on it together, while restricting direct external access to that data, typically by making fields private and exposing controlled access through public methods instead. It matters because it lets a class validate or transform a value before it's actually set, and lets the internal implementation change later without breaking code elsewhere that depends on the class.
A public member can be accessed from anywhere, both inside and outside the class. A private member can only be accessed from within the class itself. A protected member can typically be accessed from within the class and from subclasses, but not from unrelated outside code, sitting as a middle ground between fully open and fully restricted access.
A getter returns a field's value, and a setter updates it, both going through a controlled method rather than direct field access. This lets a class validate a new value before accepting it, log when a value changes, or later change how that value is actually stored internally, all without needing to change how external code interacts with the class.
Since external code interacts with a class only through its defined public methods, the class's internal implementation can be changed freely, refactored, optimized, or fixed, as long as those public methods keep behaving the way callers expect. Without encapsulation, changing how a class stores its data internally could break every single piece of code that directly touched that data from outside.
Data hiding specifically refers to restricting direct access to an object's internal data, keeping it private and reachable only through the class's own methods. It's really the mechanism that encapsulation relies on to protect an object's internal state from being modified in unexpected, unvalidated ways from outside the class.
Technically yes, but such a class would be useless from outside itself, since nothing external could ever interact with it or retrieve any information from it at all. In practice, a class needs at least some public surface, even if minimal, for it to serve any purpose to code outside itself, which is exactly why encapsulation is about controlled access rather than total isolation.
Inheritance lets one class (a subclass or derived class) reuse and extend the fields and methods of another class (a superclass or base class). It models an is-a relationship, like a Dog is an Animal, letting the subclass automatically gain the superclass's behavior while adding or overriding its own specific behavior.
A parent class (also called a base or super class) is the class being inherited from, defining shared fields and methods. A child class (also called a derived or subclass) inherits from the parent, automatically gaining its members, and can add new members or override existing ones to behave differently.
Single inheritance, one class inheriting from one parent. Multiple inheritance, one class inheriting from more than one parent, supported directly by some languages but not others. Multilevel inheritance, a chain where a class inherits from a class that itself inherits from another. Hierarchical inheritance, several classes all inheriting from the same single parent.
Multiple inheritance of classes can create ambiguity when two parent classes each define a method with the same signature, leaving it unclear which version a subclass should actually use, a situation known as the diamond problem. Languages that avoid this restrict a class to a single parent, while still allowing a class to implement multiple interfaces, which don't carry the same ambiguity risk since interfaces traditionally held no implementation.
Method overriding happens when a subclass provides its own implementation of a method already defined in its parent class, using the same method signature. It's a core part of how inheritance enables customized behavior, letting a subclass inherit most of a parent's behavior while replacing just the specific pieces that need to work differently for that particular subclass.
An is-a relationship, modeled through inheritance, means one type is fundamentally a specialized kind of another, like a Car is a Vehicle. A has-a relationship, modeled through composition, means one object contains or uses another as part of its own makeup, like a Car has an Engine, without being a kind of Engine itself.
Polymorphism literally means many forms, and in programming it refers to a single interface or method name behaving differently depending on the actual object it's operating on. A shared method call, like draw(), can produce entirely different results depending on whether it's called on a Circle object or a Square object.
Compile-time polymorphism, achieved through method overloading, is resolved by the compiler before the program runs, based on the arguments in the method call. Runtime polymorphism, achieved through method overriding, is resolved while the program is actually executing, based on the actual type of the object a reference points to, not the type of the reference itself.
The program looks at the actual object's real type at the moment the method is called, not the declared type of the variable holding the reference, and calls that object's own specific overridden version of the method. This is what lets a single line of code, calling a method on an Animal reference, correctly run a Dog's specific implementation when the actual object underneath is a Dog.
A payment processing system that calls a generic processPayment() method on a Payment object, where the actual object could be a CreditCardPayment, a PayPalPayment, or a BankTransferPayment, each implementing that same method differently. The calling code doesn't need to know or care which specific payment type it's actually dealing with.
It lets you write code against a general interface or parent type, without needing to know or handle every specific subtype individually inside that code. Adding a brand new subtype later, a new payment method, a new shape, doesn't require modifying the existing code that calls the shared method, as long as the new subtype correctly implements that same shared interface.
Operator overloading lets you redefine what a standard operator, like +, does for objects of a custom class, so adding two custom Vector objects with + can be made to perform actual vector addition rather than the operator's default numeric meaning. It's a specific, narrower form of polymorphism, applied to operators rather than method names.
Duck typing means an object's suitability for an operation is determined by whether it actually has the methods or properties being used, not by its declared type or explicit inheritance from a common interface. It achieves a similar practical effect to polymorphism, different objects being usable interchangeably in the same code, without requiring a formal, explicit interface relationship the way statically typed languages typically do.
Abstraction means hiding unnecessary implementation detail and exposing only what's actually relevant to whoever's using something, like a car's steering wheel hiding the actual steering mechanism underneath. Encapsulation is the technical mechanism, bundling data with restricted access, often used to achieve that abstraction, but the two concepts describe different things: abstraction is about hiding complexity, encapsulation is about controlling access.
An abstract class can't be instantiated directly, it exists specifically to be subclassed. It can define abstract methods, ones with no implementation that subclasses are required to provide, alongside regular, fully implemented methods that subclasses inherit directly as-is.
An interface traditionally defines only method signatures with no implementation at all, purely a contract a class agrees to fulfill. An abstract class can mix abstract methods with fully implemented ones. A class can typically implement multiple interfaces at once, but can usually only extend one abstract class, since most languages don't support multiple inheritance of classes.
An abstract class fits well when several related classes share significant common implementation you want them to inherit directly, beyond a shared contract alone. An interface fits well when you just need to guarantee a shared contract across classes that might otherwise be completely unrelated, and especially when a class already needs to extend something else and can't afford to spend its one allowed parent class extension on that shared behavior.
Driving a car is a classic example. You interact with a simple interface, a steering wheel, pedals, a gear shift, without needing to understand the actual mechanical and electronic complexity of the engine, transmission, and braking system underneath. That underlying complexity is fully hidden behind a much simpler interface you actually interact with.
It lets you reason about a piece of a system at a higher level, in terms of what it does, without needing to hold every implementation detail of every other piece in your head at the same time. Without abstraction, understanding any single part of a large system would require understanding the full implementation detail of every other part it touches, which quickly becomes unmanageable at real scale.
Mid-Level (3-6 years)
The diamond problem arises when a class inherits from two parent classes that both, in turn, inherit from the same common ancestor, or that both directly define a method with the same signature, creating ambiguity about which version the subclass should actually use. It's called the diamond problem because a diagram of the class relationships forms a diamond shape.
Some languages, like Java, sidestep it entirely by disallowing multiple inheritance of classes, though a similar conflict can still occur with default methods across multiple interfaces, which Java resolves by forcing the implementing class to explicitly override the conflicting method itself. Other languages, like C++, allow multiple inheritance directly but require the developer to explicitly resolve any ambiguity themselves using specific syntax pointing to the intended parent.
Inheritance models an is-a relationship and reuses behavior by extending a base class. Composition models a has-a relationship, building a class out of other objects instead. Composition is generally favored when behavior needs to change at runtime, or when a deep inheritance hierarchy would become fragile and hard to reason about, since changing what an object is composed of is usually easier than restructuring an entire inheritance chain.
It states that an object of a subclass should be usable anywhere an object of its parent class is expected, without breaking the correctness of the program. Violating this principle, a subclass that technically extends a parent but behaves in a way that breaks callers' reasonable expectations of that parent, is a sign that the inheritance relationship itself was probably modeled incorrectly.
The classic example is a Square class inheriting from a Rectangle class. Mathematically a square is a special kind of rectangle, but if Rectangle allows setting width and height independently, a Square overriding those setters to keep both sides equal breaks the expectation that setting one dimension on a Rectangle leaves the other unaffected, violating substitutability even though the is-a relationship seems intuitively correct at first glance.
Dynamic dispatch means the specific method implementation that actually runs is determined at runtime, based on the object's actual type, rather than being fixed at compile time based on the reference's declared type. It's the underlying mechanism that makes runtime polymorphism possible, letting a single line of calling code correctly invoke different subclasses' own overridden behavior.
Static binding resolves which method to call at compile time, used for overloaded methods and non-virtual method calls. Dynamic binding resolves which method to call at runtime, based on the actual object type, used for overridden virtual methods. Runtime polymorphism specifically depends on dynamic binding to work correctly.
This is called upcasting, and it's safe because a subclass object genuinely is a valid instance of its parent type too, satisfying the is-a relationship. Calling an overridden method through that parent-typed variable still invokes the subclass's own specific overridden implementation, thanks to dynamic dispatch, not the parent's original version.
Downcasting converts a parent-typed reference back down to a more specific subclass type. It's riskier because the object the reference actually points to might not genuinely be an instance of that specific subclass at all, which typically throws a runtime error if the cast is attempted anyway, unlike upcasting, which is always guaranteed safe by the nature of the is-a relationship itself.
The Open/Closed Principle says code should be open for extension but closed for modification. Polymorphism enables this directly, since adding a new subclass implementing an existing shared interface extends a system's behavior without requiring any changes to the existing code that already calls methods on that shared interface.
It states that a class shouldn't be forced to implement methods it doesn't actually need, addressed by splitting a large, overly broad interface into several smaller, more focused ones. A class then implements only the specific smaller interfaces genuinely relevant to it, rather than being forced to provide meaningless implementations for methods it has no real use for.
A marker interface has no methods at all, and its entire purpose is to tag a class as having a particular property or capability, checked at runtime through a type check rather than through calling any actual method. Java's Serializable interface is a classic example, marking a class as eligible for serialization without requiring it to implement any specific method itself.
Nominal typing requires a class to explicitly declare that it implements a specific interface by name, the approach most mainstream OOP languages use. Structural typing instead considers a type to satisfy an interface automatically, purely based on whether it has the right shape, the right methods with the right signatures, regardless of whether it explicitly declared any relationship to that interface at all.
Many small interfaces give more flexibility and better adherence to the Interface Segregation Principle, since classes only implement exactly what they actually need. The trade-off is more types to define, track, and understand overall, which can add genuine cognitive overhead in a codebase that's already dealing with many other concepts, and going too far in this direction can make a codebase harder to navigate rather than easier.
If the duplication is small and the classes are likely to genuinely evolve independently of each other over time, some duplication is often fine, and even preferable to a premature, poorly-fitting abstraction. Once the same logic is duplicated across three or more places and genuinely needs to stay in sync as it changes, pulling it into a shared abstract class or a composed helper object usually pays off.
In everyday usage, abstraction just means the general idea of an abstract or simplified concept. As an OOP pillar specifically, it refers to the concrete technique of hiding a class's actual implementation details behind a well-defined, simpler public interface, achieved specifically through abstract classes and interfaces rather than being just a general design philosophy.
Single Responsibility Principle, which states a class should have exactly one reason to change, meaning it should be responsible for exactly one specific piece of functionality. A class handling both business logic and how that data gets saved to a database, for instance, has two separate reasons to change and would violate this principle.
Open/Closed Principle, which states a class should be open for extension but closed for modification. New behavior should ideally be added by extending existing code, through inheritance or composition, rather than by directly modifying code that's already working and potentially already relied upon elsewhere.
When a class has exactly one clear responsibility, a change to one piece of functionality is far less likely to accidentally affect unrelated functionality bundled into the same class. A class handling multiple unrelated responsibilities means a change motivated by one of them risks breaking the others, simply because they happen to share the same class.
A class name that includes the word And, or a class whose methods naturally split into two or more distinct, unrelated groups that don't really interact with each other, are both common signs. Difficulty summarizing what a class actually does in one clear, focused sentence is often a more informal but equally telling sign of the same underlying problem.
Coupling measures how much one part of a system depends on the internal details of another part. Cohesion measures how closely related the responsibilities within a single class or module actually are to each other. Low coupling means changes in one part are less likely to break another. High cohesion means a class's responsibilities genuinely belong together, both of which independently make a system easier to understand and change safely over time.
Don't Repeat Yourself, meaning knowledge and logic should exist in exactly one place in a codebase rather than being duplicated. Applied too aggressively, it can lead to a forced, awkward abstraction joining two pieces of logic that only coincidentally look similar today but represent genuinely different concepts that would naturally diverge later, at which point the shared abstraction becomes a genuine liability rather than a benefit.
Composition lets you change an object's behavior at runtime by swapping out one of its composed parts, while inheritance locks in a class's behavior structure at compile time through the fixed class hierarchy it was defined with. A system built primarily on composition tends to be easier to restructure and adapt later as requirements genuinely change.
It means writing code that depends on an abstract interface or type rather than a specific concrete class, so the actual concrete implementation being used can be swapped out later without touching the code that depends on it. This matters because it dramatically reduces coupling between different parts of a system, since consuming code only needs to know about the shared contract, not the specific class fulfilling it.
A shallow understanding just repeats the phrase as received wisdom. A deeper understanding can articulate specifically why, that inheritance couples a subclass to its parent's implementation details in ways that make later changes riskier, while composition keeps that coupling looser and more swappable, and can also identify the real cases where inheritance genuinely is still the better, more natural fit despite that general guidance.
Senior (6-8 years)
Liskov Substitution Principle, requiring that a subclass be substitutable for its parent class without breaking the correctness of code that depends on the parent. A violation often shows up as a subclass overriding a method to throw an exception for a case the parent class's contract implies should be handled normally, or a subclass silently changing a method's expected behavior in a way that surprises code written against the parent type.
Interface Segregation Principle, which is about not forcing a class to depend on interface methods it doesn't actually use. Single Responsibility is about a class having one reason to change overall. They're related in spirit, keeping things focused, but ISP specifically targets the shape and size of interfaces, while SRP targets the overall responsibility of a class as a whole.
Dependency Inversion Principle, which states that high-level modules shouldn't depend directly on low-level modules, both should depend on shared abstractions instead. It solves the problem of a high-level business logic class becoming tightly coupled to a specific, concrete low-level implementation detail, like a specific database technology, making that high-level logic hard to test or swap out independently.
Instead of a class directly creating an instance of a concrete dependency itself, like a MySQLDatabase object, it depends on an abstract interface, like a Database interface, and the actual concrete implementation gets passed in, injected, from outside, typically through the constructor. This lets the class work correctly with any implementation of that interface, including a mock version used specifically for testing.
Address one principle at a time, starting with whichever violation is causing the most concrete, actual pain right now, like a Single Responsibility violation that's currently making the class genuinely hard to test. Trying to fix every violation simultaneously in one large refactor significantly increases the risk of introducing a new bug, without a matching increase in real, immediate benefit over doing it incrementally.
Applied too rigidly or too early, before a class or system's actual requirements and boundaries are genuinely well understood, they can lead to premature abstraction, extra indirection and complexity that doesn't pay for itself, solving a flexibility problem that doesn't actually exist yet in the real system. Good engineering judgment about when a principle actually applies matters more than mechanically applying all five to every single class regardless of context.
A design pattern is a general, reusable solution to a commonly recurring design problem, describing a structure and set of relationships between objects rather than a specific, step-by-step sequence of instructions the way an algorithm does. A pattern is more of a template you adapt to your specific situation, not code you copy and paste directly as-is.
Creational patterns address how objects get created, like Singleton or Factory. Structural patterns address how objects and classes are composed into larger structures, like Adapter or Decorator. Behavioral patterns address how objects communicate and interact with each other, like Observer or Strategy.
Singleton ensures a class has exactly one instance and provides a single, global point of access to it. A common criticism is that it introduces hidden global state, which makes unit testing genuinely harder, since tests can end up implicitly sharing state through that singleton across what should otherwise be fully independent test cases.
A factory centralizes the logic for creating an object, returning different concrete implementations of a shared interface based on some given input, without the calling code needing to know or reference those specific concrete classes directly. It solves the problem of object-creation logic being scattered and duplicated across a codebase, and of calling code becoming tightly coupled to specific concrete classes it shouldn't really need to know about.
The Observer pattern lets a subject notify a list of subscribed observers automatically whenever its state changes, without the subject needing to know any specific details about those observers. A real-world fit is a UI element that needs to update automatically whenever some underlying data changes, without that data source needing to know anything specific about which UI elements happen to be displaying it.
Strategy defines a family of interchangeable algorithms behind a shared interface, letting the specific algorithm used vary independently of the code that calls it, choosing at runtime which concrete strategy to actually use. It's really just a direct, structured application of polymorphism, using it deliberately to make an algorithm itself swappable rather than to model a natural is-a relationship between real-world entities.
Lead (8-10 years)
A pattern earns its place when the flexibility or structure it provides is something the code actually needs today, based on real, concrete requirements, not something it might theoretically need someday. Applying a pattern preemptively, before there's a genuine, concrete reason for the flexibility it provides, usually just adds indirection and makes the code meaningfully harder to follow for no actual benefit yet realized.
A God Object is a single class that's taken on far too many responsibilities, knowing about and controlling large parts of a system that should genuinely be separate, focused concerns. It's problematic because it becomes a single, massive point of coupling that nearly every change to the system ends up needing to touch, making the class itself increasingly risky and difficult to modify safely over time as it keeps growing.
It describes domain objects that hold data but almost no actual behavior, with nearly all the real logic living instead in separate service classes that operate on that data from outside. It's considered an anti-pattern in a genuinely object-oriented design specifically because it largely defeats the point of encapsulation, keeping behavior and the data it operates on separated rather than genuinely bundled together the way OOP is meant to encourage.
It describes a design where too many objects have unrestricted, direct access to each other's internal state, effectively defeating encapsulation across the whole system even if each individual class technically marks its own fields private. It usually results from excessive getters and setters exposing nearly all of an object's internal state, letting external code manipulate it in ways that were never really intended, even though nothing was technically made public in the strictest, most literal sense.
It depends on the actual problem domain. A system genuinely modeling complex, stateful entities with rich, intertwined behavior, like a simulation or a game, tends to benefit from OOP's bundling of data and behavior together. A system that's mostly transforming data through a pipeline of straightforward operations, with limited persistent state, often fits a simpler, more functional style more naturally and with less unnecessary structural overhead.
Look for where the hierarchy is actually being used for shared behavior versus genuinely representing an is-a relationship, and replace the parts that are really just behavior-sharing with composition instead, extracting that shared behavior into a separate, composed object rather than forcing it through inheritance. This usually needs to happen incrementally, one level of the hierarchy at a time, rather than attempting a single, large, risky restructuring all at once.
Favor composition over deep inheritance from the outset wherever genuinely possible, and design around stable, well-defined interfaces representing what a component actually does, rather than around the current, potentially still-evolving concrete implementation. A design built around behavior contracts tends to absorb new requirements far more gracefully than one built around a rigid, deep class hierarchy that has to be modified every time a new variation genuinely appears.
If the new behavior genuinely applies to every single subclass in that hierarchy and always will, adding it to the base class is reasonable. If it applies to only a subset of subclasses, forcing it onto every subclass through the base class violates the Interface Segregation Principle, and a separate, smaller interface implemented only by the subclasses that genuinely need it is almost always the better fit.
The core OOP principles still apply within each individual service's own codebase, but a service's public API boundary, not its internal class design, becomes the actual contract other services and teams genuinely depend on. Getting that outward API boundary right matters more at that architectural scale than the specific internal class structure used to implement any single service's own internal logic.
I look at whether the class has one genuinely clear responsibility, whether its public interface is minimal and makes sense from the perspective of whoever will actually be using it, and whether it introduces any unnecessary or overly tight coupling to other, unrelated parts of the system. A class that technically works but is genuinely hard to test in isolation is often already a strong signal of a deeper underlying design problem worth addressing before it ships.
I'd start from a real, concrete problem they've actually run into recently, like a change that unexpectedly broke something unrelated because of hidden, tangled coupling, rather than opening with abstract definitions of encapsulation or inheritance. Connecting a design principle directly to a genuine, already-felt pain point they've personally experienced sticks far better than teaching the concept in the abstract first.
Overusing inheritance for pure code reuse, building unnecessarily deep hierarchies to avoid duplicating a small piece of logic, rather than reaching for composition, is one of the most common mistakes. I'd walk through a concrete example where a deep hierarchy built purely for reuse eventually became genuinely fragile and hard to extend, showing exactly how composition would have avoided that same specific problem.
I'd look at the actual shape of the problem rather than defaulting automatically to whatever paradigm the team happens to already be most comfortable with. Complex, genuinely stateful domain modeling tends to fit OOP naturally. Data transformation pipelines with limited persistent state often fit a functional approach better and with meaningfully less structural overhead. Many real, practical systems successfully blend both approaches rather than committing rigidly to just one paradigm throughout.
I'd bring it back to whether the relationship is genuinely an is-a relationship or more accurately a has-a or uses-a relationship, since that distinction itself usually settles the actual design question fairly directly once it's stated clearly and explicitly. When the relationship is genuinely ambiguous even after that framing, I'd lean toward composition, since it's generally more flexible to change later than an inheritance relationship already baked into the class hierarchy.
Staff (10+ years)
The same underlying principles, low coupling, high cohesion, programming to an interface rather than a specific implementation, scale up directly to how services and modules are structured and communicate with each other across an entire system. A service boundary that violates the same coupling and cohesion principles a poorly designed class would violate causes exactly the same category of maintenance pain, just at a much larger and more expensive scale to eventually fix.
I look at how easily the team can actually make a typical, everyday change, and how often a seemingly small, contained change unexpectedly ripples out and requires touching many unrelated parts of the codebase. Design practices that are genuinely working show up as changes staying reasonably contained to where they should be. Design practices that have become their own liability show up as small requests routinely turning into large, unexpectedly risky changes touching far more of the system than the request itself would suggest.
I look at whether responsibilities are genuinely well-separated, whether the design is consistent with patterns already established elsewhere in the codebase, and specifically whether it will still hold up reasonably well under requirements that are likely to come next, without over-engineering for purely hypothetical future needs that may never actually materialize. An inconsistent one-off pattern becomes a maintenance burden the whole team inherits later, so I'd rather ask pointed questions that surface a team's own blind spots than hand them a prescribed answer.
I'd focus on the handful of principles that actually matter most, communicated with the concrete reasoning behind each one, rather than a long, exhaustive design document nobody actually reads end to end. Design review as an ongoing, collaborative conversation tends to spread good judgment further and more durably than a rigid, mechanically-enforced checklist ever does on its own.
I'd look at where the actual pain is genuinely coming from, frequent, painful bugs tracing back to unexpected inheritance side effects, or a specific area of the codebase that's become genuinely difficult and risky to extend safely, rather than refactoring simply because composition is currently considered the more fashionable or modern approach. Refactoring is worth the real cost and risk once it's solving a concrete, currently-felt problem, not as a purely stylistic, cosmetic exercise.
I'd rather present a small, realistic design problem and see how they actually apply encapsulation, inheritance, and composition to it in practice, including how they reason about the trade-offs of the choices they make along the way. Reciting a memorized textbook definition of polymorphism proves recall, not genuine understanding of when and why to actually use it in a real design decision.
I'd walk through one of their actual designs together and ask what specific, concrete future requirement each additional layer of abstraction was actually built to accommodate, since the honest answer is very often that no such genuine requirement currently exists yet. Making the real cost of unnecessary complexity, harder onboarding, more code to maintain and reason about, more concrete and tangible tends to shift that instinct more effectively than a general, abstract reminder about simplicity alone.
I'd try to anchor the disagreement on specific, concrete, already-known upcoming requirements rather than vague, hypothetical future flexibility that isn't actually grounded in anything currently planned. If neither of us can point to a genuinely concrete, near-term need justifying the additional structure, that itself is usually a fairly strong, telling signal that the design is probably over-engineered for what's actually needed right now.
This is a judgment question interviewers use to see how you reason under genuine uncertainty, not to test a specific textbook fact. A strong answer names the actual constraint that forced the decision, the realistic options that were genuinely on the table, why you picked one knowing it wasn't guaranteed to be right, and what you'd do differently with what you know now, since design decisions about future flexibility are almost always made with an incomplete picture of how requirements will actually evolve.
I'd point to a concrete, already-happened example specific to the actual product, a feature that shipped noticeably faster, or a bug that would have been far more costly and slower to fix, because the underlying code was genuinely well-structured, rather than defending good design as valuable in a purely abstract, theoretical sense. A real, already-lived before-and-after story lands far better with a non-technical audience than an argument about elegance for its own sake.
Staying involved in design review and architecture discussions keeps that judgment genuinely applied rather than purely theoretical, even without writing detailed class hierarchies yourself every day. Occasionally working through a genuinely difficult refactoring decision, whether through mentoring or a real technical problem that comes up, keeps that specific reasoning muscle from going fully dormant over time.
I'd translate the design debt into terms leadership already tracks: the specific, already-incurred cost of the last incident or delayed feature that traced back to it, and what continued growth in that area of the codebase would likely do to that cost going forward if left unaddressed. Framed as risk reduction and velocity recovered rather than a purely stylistic improvement, it competes far better for prioritization.




