8 Object-Oriented Design
A practical guide to designing object-oriented systems by assigning responsibilities, controlling dependencies, applying abstractions thoughtfully, and balancing flexibility with simplicity.
Foundations: Objects, State, and Contracts
Object-oriented design organizes software around collaborating objects. Each object combines state, behavior, and identity, but effective design is not simply a matter of turning every noun in a requirements document into a class. The central question is how responsibilities should be assigned so that the system remains understandable, testable, adaptable, and resistant to unnecessary change.
A class defines structure and behavior shared by its instances. An object is a particular instance with its own state. For example, a BankAccount class might define an account number, balance, deposit() behavior, and withdraw() behavior, while each individual account is an object created from that class.
A well-designed object protects the rules that must remain true. These rules are its invariants. If an account cannot have a negative balance under a particular policy, callers should not be able to bypass that policy by changing the balance directly. Instead, the object can expose an operation such as withdraw(100) and reject an invalid request.
Three closely related ideas shape object relationships:
protects state and hides implementation details behind meaningful operations.
Inheritance lets a subclass reuse or specialize the state and behavior of a superclass, but creates a strong dependency between them.
Interfaces express contracts: operations that a type promises to provide without prescribing one implementation.
An interface such as NotificationSender could define send(message, recipient). Email, SMS, and push-notification implementations could satisfy that contract independently. Interfaces are valuable when implementations vary, when unrelated classes share a capability, or when high-level code should avoid depending on a concrete technology.
Takeaway: Begin with objects, their invariants, and their public contracts. The quality of the design depends more on the assignment of responsibilities than on the number of classes.
Relationships and Replaceable Behavior
Relationships among objects determine how easily behavior can change. allows client code to work with an abstraction while the concrete object supplies the implementation. A payment service might call paymentMethod.pay(amount) without knowing whether the object is a credit-card payment, bank transfer, or digital wallet. Each implementation follows the same contract while applying its own rules.
assembles behavior by giving an object references to collaborators. A CheckoutService might work with a TaxCalculator, PaymentGateway, and OrderRepository. Those collaborators can be replaced at runtime or substituted in tests, and the service does not need to inherit their implementation.
Inheritance can still be appropriate. Choose it when there is a stable behavioral subtype relationship, when the subtype genuinely satisfies the superclass contract, and when shared implementation is valuable. Avoid using inheritance merely as a convenient way to reuse a small amount of code. A subclass inherits assumptions, constraints, and future changes from its base class.
A practical comparison is:
Inheritance expresses a relatively rigid relationship and can support substitutability and shared implementation.
assembles behavior more flexibly and usually limits dependence on implementation details.
Interfaces provide contracts that multiple implementations can satisfy.
lets algorithms use those contracts without scattering type checks throughout the code.
The guideline “favor over inheritance” is therefore a judgment aid, not an absolute prohibition. may require additional objects and delegation, while inheritance may be clearer when the subtype relationship is genuine and stable.
Takeaway: Use interfaces and to isolate variation, and prefer when behavior should be replaceable or when inheritance would create a fragile hierarchy.
Assigning Responsibilities
Responsibility assignment asks which object should perform a behavior or make a decision. The best answer usually considers information ownership, collaboration, , and the stability of the surrounding design.
Important guidelines include:
: Assign behavior to the object that has the information needed to perform it. An
Ordercommonly calculates its own total because it owns the order lines.Creator: Let an object create another object when it closely uses, contains, or records that object. An
Ordermay create anOrderLinewhen a product is added.Controller: Put application-level coordination in a service or controller rather than in user-interface code or a low-level data object.
: Place behavior that varies by type behind a common interface instead of spreading type checks through the system.
Indirection: Introduce an intermediate abstraction when two components would otherwise become tightly coupled.
Pure fabrication: Create a focused service that is not a domain entity when that improves and reduces . An
InvoicePrinterthat formats invoices is one example.
Responsibility assignment requires balance. Putting every operation on data-holding objects can create oversized classes. Moving everything into services can create an anemic domain model whose objects contain little behavior and whose rules are scattered elsewhere.
A useful design sequence is to identify a use case, list the decisions and rules it requires, locate the information needed for each rule, and then examine whether the resulting collaborators have focused purposes. If one class has several unrelated reasons to change, its responsibilities may need to be separated.
Takeaway: Assign behavior near the information and rules it needs, but check the result for both oversized classes and behaviorless data objects.
, , and Change
Two structural properties help reveal whether responsibilities have been assigned well. measures how closely the responsibilities of a module belong together. A PasswordHasher that only hashes passwords is more cohesive than a UserManager that hashes passwords, sends email, writes audit logs, and formats reports.
measures how strongly one module depends on others. A class that constructs a database driver, reads configuration, formats user messages, and performs business calculations is coupled to many concerns. Changes to any of those concerns may affect the class.
Prefer high and controlled by:
Depending on stable abstractions where a meaningful boundary exists.
Passing only the information a collaborator needs.
Hiding implementation details behind public contracts.
Avoiding unnecessary knowledge of another object's internal structure.
Separating concerns that have unrelated reasons to change.
The aim is not to remove all dependencies. Collaboration is necessary in useful software. Instead, dependencies should be limited, intentional, and directed toward stable interfaces or policies. A dependency on a volatile vendor library is often more risky than a dependency on a small application-owned abstraction that expresses the required capability.
A design should also be evaluated through testing. If a class is difficult to test because it constructs infrastructure internally, knows too many concrete types, or performs several unrelated tasks, those difficulties are evidence about and rather than merely testing problems.
Takeaway: Seek focused modules and deliberate dependencies. High makes responsibilities easier to understand, while controlled limits the spread of change.
SOLID as Design Heuristics
SOLID principles provide heuristics for finding common sources of rigidity. They are not mechanical rules; each should be applied in context.
Single responsibility and extension
The asks whether a class has one coherent responsibility and one primary reason to change. Invoice calculation and PDF formatting often evolve independently, so separating them can improve . The principle does not require a class to contain only one method.
The Open–Closed Principle recommends that software entities be open for extension but closed for modification. For example, a shipping calculator can depend on a ShippingStrategy interface so that new strategies can be added without rewriting the checkout algorithm. Do not predict every imaginable feature, however. Introduce abstractions when variation is demonstrated or reasonably expected; premature abstraction can make simple code harder to understand.
Substitution and focused interfaces
The requires a subtype to preserve the important expectations of its supertype. A ReadOnlyFile that is treated as a writable File but throws an exception whenever a client writes may violate the contract. An “is-a” relationship must describe behavior, not merely shared attributes.
The Interface Segregation Principle says that clients should not be forced to depend on methods they do not use. Several small interfaces such as Printable, Scannable, and Faxable may be better than one large OfficeMachine interface. Focused interfaces reduce accidental and make implementations easier to test.
Inverting dependencies
The separates high-level policy from low-level details. A CheckoutService can depend on a PaymentGateway abstraction, while production and test implementations provide different payment behavior. Dependency injection—passing the dependency into a constructor or method—is a common way to create this arrangement.
Takeaway: Use SOLID principles to diagnose real problems such as unrelated reasons to change, fragile subtype contracts, oversized interfaces, or direct dependence on volatile details. Do not apply them merely to increase abstraction.
A Practical Design Process
Object-oriented design involves trade-offs rather than universal formulas. More abstraction can improve replaceability but also adds concepts, files, and navigation overhead. Strong can require explicit operations instead of convenient public fields. can improve flexibility while introducing additional delegation. Reuse through inheritance can reduce duplication while importing unwanted behavior and constraints.
Consider these decision questions:
Is there a genuine, stable behavioral subtype relationship, or is implementation reuse the only motivation?
Does an interface represent a meaningful boundary, isolate volatile code, or enable a useful alternative such as a test double?
Does protect an invariant or hide a representation likely to change?
Will separating a responsibility improve , or will it create needless fragmentation?
Is a performance concern measured and significant, or is it only a hypothetical cost of abstraction?
A practical design process is:
Identify domain concepts and use cases, focusing on required behavior rather than only on nouns.
Find responsibilities and invariants; ask which object has the required information and should protect each rule.
Define collaborations through small, meaningful operations.
Separate stable policy from volatile details such as databases, vendors, and user interfaces when that boundary is valuable.
Evaluate and , then split unrelated responsibilities or remove unnecessary dependencies.
Prefer , strategies, policies, or injected services for interchangeable behavior.
Test public behavior and contracts rather than private implementation details.
Refactor from evidence such as duplication, difficult testing, frequent change, or unclear responsibilities.
A sound design evolves. It balances flexibility with simplicity and introduces abstractions where they solve an observed or reasonably expected problem.
Final takeaway: Effective object-oriented design assigns behavior to collaborating objects that protect their invariants, exposes clear contracts, keeps responsibilities focused, controls , and changes in response to real requirements.