8 Object-Oriented Design Principles
A structured guide to assigning responsibilities, managing dependencies, and designing object-oriented systems that remain understandable, testable, and adaptable to change.
Objects, Classes, and Responsibilities
Object-oriented design organizes software as collaborating objects rather than as disconnected collections of data and procedures. A class describes the structure and operations shared by a category of objects; an object is a particular runtime instance.
An object commonly has:
State: data it holds, such as an account balance.
Behavior: operations it can perform, such as depositing money.
Identity: characteristics that distinguish it from other objects, even when their state is initially identical.
The central design question is not merely which data belongs in a class. It is: What responsibility should this object own? Good design assigns responsibilities and collaborations so that the system remains understandable, changeable, and testable.
Takeaway: Treat classes as responsible participants in a system, not merely as passive data containers.
and Contracts
keeps state and the operations that manage it together while controlling access to the internal representation. A bank account, for example, can expose a deposit operation that rejects nonpositive amounts instead of allowing callers to assign any balance directly.
This protects invariants: conditions that must remain true for an object to be valid. It also means that clients depend on the account’s operations rather than on the details of how its balance is stored.
An interface specifies a contract without requiring clients to know how that contract is implemented. A checkout service can depend on a payment-processing capability while allowing credit-card, online-wallet, or test implementations to provide that capability.
A well-designed interface should express a meaningful contract and avoid forcing clients to depend on operations they do not need.
Takeaway: Hide decisions and mutable details behind focused, stable contracts.
, , and
expresses a specialization relationship between a subclass and a superclass. It is appropriate only when the subclass genuinely satisfies the parent type’s behavioral contract. Using solely to reuse a few methods can create a strong dependency on the parent implementation and expose operations that do not make sense for the child.
allows client code to use a common type while each actual object supplies its own implementation. A collection of shapes can be processed by calling an area operation on each shape without conditional logic for every concrete shape.
assembles behavior by giving an object references to collaborators. A checkout service might receive a payment processor and inventory component. Replacing one collaborator can change behavior without changing the coordinating service.
The heuristic favor over is not an absolute rule. Use when behavior should vary through supplied collaborators; use when the subtype relationship is genuine and stable.
Takeaway: Prefer stable contracts and replaceable collaborators, while reserving for valid specialization.
Assigning Responsibilities
A responsibility is an obligation of an object, such as knowing information, performing an operation, changing state, creating another object, or coordinating a use case. Responsibility-driven design asks what the system must do and which object should do it.
The principle assigns an operation to the object that has the information required to perform it. An order can usually calculate its subtotal because it owns the order lines. A separate tax component may be more appropriate when tax rules are independent, variable, or shared across many orders.
The Creator rule commonly assigns object creation to a class that closely uses, aggregates, contains, or records the object being created. A factory may be preferable when construction is complex, configurable, or environment-dependent.
A coordinator such as an application service can receive a system request and delegate to domain objects. It should orchestrate the work rather than accumulate every business rule in one large class.
A strong assignment usually preserves , keeps related behavior near the data it uses, avoids unnecessary knowledge of other objects, and keeps likely changes local.
Takeaway: Place behavior where the necessary information and responsibility naturally belong.
and
concerns how closely a class’s responsibilities belong together. A focused pricing component that calculates totals, discounts, and taxes can be cohesive when those rules form one stable responsibility. A class that calculates totals, sends email, writes database records, formats HTML, and compresses files has unrelated reasons to change and is not cohesive.
High improves:
Comprehensibility, because the class has a clear purpose.
Reusability, because focused behavior is easier to use elsewhere.
Testability, because fewer unrelated dependencies are required.
Maintainability, because changes are less likely to create side effects.
measures how strongly one class or module depends on others. becomes high when a class knows many implementation details, constructs many collaborators directly, reaches through long object chains, or changes whenever unrelated classes change.
Low does not mean eliminating every dependency. Object-oriented systems need collaboration. The goal is to make dependencies few, stable, explicit, and directed toward suitable abstractions.
Takeaway: Aim for high within components and low, explicit between them.
Dependencies and Design Heuristics
A dependency exists when one class relies on another to perform its work. Dependency management controls what a class knows, who creates its collaborators, and how dependencies flow.
The separates high-level policy from low-level implementation details. An ordering policy should depend on a payment-processing abstraction rather than directly on a particular payment vendor. Concrete implementations can then be selected at the application boundary.
supplies collaborators from outside the object. Constructor injection makes required dependencies visible and allows the object to be initialized in a valid state. The root assembles concrete implementations, while application logic uses abstractions.
Useful heuristics include:
Encapsulate what varies.
Program to an interface, not an implementation.
Make dependencies explicit.
Separate business policy from databases, user interfaces, network clients, and vendor libraries.
Introduce abstractions at real or likely variation points rather than for every imaginable future change.
Refactor boundaries as understanding, tests, and requirements evolve.
Takeaway: Manage dependencies so that policy remains independent of replaceable mechanisms.
Putting the Principles Together
Consider a system that notifies customers when orders ship. A weak design might place shipment rules, database access, message formatting, and email delivery in one order-management class.
A more deliberate design can assign responsibilities as follows:
Orderknows whether it is eligible for shipment.ShipmentServicecoordinates shipment operations.NotificationMessagerepresents message content.NotificationSenderdefines the delivery contract.Email and SMS sender implementations provide delivery mechanisms.
OrderRepositoryabstracts persistence.
The service depends on notification and persistence abstractions, while the root supplies concrete implementations. Changing from email to SMS can then avoid changing the shipment policy.
The number of classes alone does not determine design quality. Evaluate the boundaries against the domain, expected changes, testability, and the cost of additional abstraction. A small program may reasonably use fewer types, while a large or frequently changing system benefits from clearer boundaries.
The overall aim is the simplest structure that keeps responsibilities clear, dependencies manageable, and likely changes localized.