9 Designing Object-Oriented Systems

A practical guide to designing object-oriented systems by identifying domain concepts, assigning responsibilities, defining collaborations, and choosing abstractions that remain cohesive, loosely coupled, testable, and adaptable.

1. Model the Problem Domain

-oriented design models a problem domain as a set of objects that maintain state, perform behavior, and collaborate through well-defined operations. The aim is not merely to produce a collection of classes. The aim is to arrange responsibilities and relationships so the system is understandable, changeable, and testable.

Begin with the domain rather than with a preferred hierarchy. Identify:

  • Important concepts such as people, resources, transactions, and events.

  • State that must be remembered.

  • Behavior associated with that state.

  • Rules that must always be satisfied.

  • Collaborations in which one needs information or services from another.

For a library-lending system, possible concepts include Book, LibraryMember, Loan, Catalog, and NotificationService. Treat extracted nouns and verbs as candidates, not automatic classes: a noun may become a , an attribute, or a value, while a verb may become a method or a collaboration.

A useful guiding question is: what does each concept know, and what should it be responsible for doing? Behavior that protects or uses particular data usually belongs near that data rather than in unrelated procedural code.

Takeaway: Start with use cases, domain concepts, state, behavior, and rules before deciding on classes or .

2. Classes, Objects, and Contracts

A defines the common structure and operations for a kind of . An is a particular runtime instance of that , with its own identity and state. For example, a LibraryMember can describe borrowing behavior, while each member represents one specific member.

A language-independent design might give a member private state such as memberId and activeLoans, along with operations such as borrow(book), returnBook(book), and canBorrow(). A client can invoke those operations without knowing how loans are stored internally.

This distinction helps separate a usable contract from an implementation. The contract is the set of operations and expectations that clients may rely on. The implementation is the internal representation and logic that make those operations work.

Avoid treating every as a passive data container. If a rule concerns a member’s borrowing privileges, placing the rule with the member’s state can make the design clearer and reduce duplicated checks elsewhere.

Takeaway: Classes describe kinds of objects; objects hold individual state and participate in collaborations through meaningful operations.

3. and Assignment

combines state and behavior while controlling access to implementation details. An should protect its invariants—conditions that must remain true—and expose operations that preserve them.

For example, if a Loan must have a due date after its checkout date, unrelated code should not freely assign arbitrary values to dueDate or returned. Operations such as markReturned() and isOverdue(today) can enforce valid transitions and provide meaningful behavior.

provides several benefits:

  • Consistency: validation is kept in a suitable place.

  • Changeability: internal data structures can change without requiring client changes.

  • Security: clients receive only the access they need.

  • Clarity: public operations communicate domain responsibilities.

does not require a getter and setter for every field. Unrestricted setters can expose implementation details and permit invalid state. Prefer operations that express domain intent, such as addItem, cancel, approve, or markReturned.

When assigning a , ask which has the needed information, which should enforce the related rule, and whether placing the elsewhere would introduce unnecessary dependencies. This supports : related responsibilities remain together. It also supports : objects depend on as little unnecessary detail as possible.

Takeaway: Protect state through operations that express valid domain actions, and keep related responsibilities together.

4. Collaborations and Interfaces

A collaboration occurs when one asks another to perform a . A checkout workflow might coordinate several focused objects: a catalog finds an available book, a member confirms borrowing eligibility, a loan represents the lending relationship, and a notification service sends a confirmation.

A coordinator can make the overall workflow visible, but domain objects should still protect their own rules. Placing every operation in one large controller creates a concentration of logic that is difficult to understand and test. Distributing responsibilities according to information and invariants produces clearer boundaries.

Interfaces define contracts between collaborating components. An NotificationService , for example, can expose an operation such as sendDueReminder(member, loan) without specifying whether the implementation uses email, text messaging, or a test fake.

Use a focused when:

  • Several unrelated classes need to provide the same capability.

  • A client should depend on a stable contract rather than a concrete implementation.

  • Tests need a small substitute.

  • The implementation may vary by configuration, environment, or policy.

Keep interfaces coherent. An with unrelated operations forces implementers to provide methods they do not need. Several small interfaces are often easier to understand than one broad .

Takeaway: Make messages between objects explicit, keep workflows coordinated but focused, and use narrow contracts to isolate replaceable capabilities.

5. Choosing or

models a genuine “is-a” relationship. A Librarian may extend User if a librarian is a kind of user and can safely satisfy the expectations established by User. A proposed subtype should not require special-case checks whenever code expects the base type.

is appropriate when:

  • The derived type truly is a kind of the base type.

  • The base type defines a stable abstraction.

  • Subtypes can honor the base type’s expectations.

  • Shared behavior is genuinely common rather than merely convenient to reuse.

Deep hierarchies make behavior difficult to trace, and a subclass may inherit operations that do not make sense for it. A change in a base can also affect many descendants. Do not use solely to avoid duplicating a few lines of code.

builds objects from other objects and expresses a has-a or uses-a relationship. A Library can contain a catalog and use checkout and notification services without inheriting from those components. A checkout service can receive a fee policy and notification service as collaborators, making those parts replaceable.

often provides:

  • Smaller, more focused classes.

  • Easier testing with substitutes.

  • Runtime configuration of behavior.

  • Fewer rigid dependencies.

  • Less risk from changes in a base .

and are not mutually exclusive. Use for stable, substitutable type relationships and for replaceable policies, services, and components.

Takeaway: Prefer for flexibility, and reserve for relationships that are genuinely meaningful and safely substitutable.

6. and Dependency Injection

allows code to depend on an abstraction while the actual supplies the appropriate behavior at runtime. A fee calculation component can call feePolicy.calculate(loan, today) without knowing whether the policy is standard, premium, or another implementation.

This approach avoids a growing conditional that checks a member category in every caller. Adding a new fee policy can instead involve creating another implementation of the same contract. The calling code remains stable, and each policy can be tested independently.

is most useful when variation is a meaningful concept, likely to grow, or independently testable. It is not automatically better than a conditional. If there are only two stable cases and the behavior is simple, a conditional may be clearer. Choose the simplest design that handles the current requirements and credible variation.

Dependency injection supports this flexibility by supplying collaborators from outside an rather than constructing them invisibly inside it. A checkout service can receive a fee policy and a fake notification service during a test, or receive production implementations in an application environment.

Takeaway: Use one stable contract for meaningful variations, and inject collaborators when replacement or isolated testing matters.

7. Apply and Evaluate the Design

A disciplined design process turns requirements into a set of responsibilities and collaborations:

  1. State the use cases. Describe what users need to accomplish, such as finding an available book, checking out a book, returning it, calculating an overdue fee, or sending a reminder.

  2. Identify candidate concepts. Extract nouns and verbs, but decide deliberately whether each becomes a , attribute, value, method, or collaboration.

  3. Assign responsibilities. Place each rule with the that has the relevant information or should protect the relevant .

  4. Define collaborations. List the messages exchanged during an important workflow, such as finding a book, checking borrowing eligibility, creating a loan, marking a book unavailable, and sending a confirmation.

  5. Choose abstractions carefully. Introduce an when multiple implementations or isolation from a concrete dependency justifies it. Introduce only for a meaningful and substitutable subtype relationship; otherwise, prefer .

  6. Protect invariants. Keep state private where appropriate and expose operations that enforce valid transitions. Consider invalid inputs, repeated operations, collaboration failures, and concurrent or repeated requests when relevant.

  7. Refine with examples and tests. Walk through normal and exceptional cases, including a borrowing-limit violation, an already-loaned book, a failed notification provider, and a repeated return.

Evaluate the result against several questions:

  • Is each cohesive and focused on a clear purpose?

  • Is coupling manageable through stable contracts?

  • Are invalid states prevented through ordinary operations?

  • Is change localized when a policy or implementation changes?

  • Are names and collaborations understandable in the problem domain?

  • Can important behavior be tested without external systems?

  • Are abstractions solving real problems rather than anticipating every possibility?

There is no universally best diagram. The appropriate balance depends on the system’s goals. Rich domain objects can protect rules locally, while simple structures may be suitable for data transfer or immutable values. Interfaces can improve substitution but add concepts, so their cost should be justified by variation or isolation needs.

Final takeaway: Good -oriented design connects use cases to focused responsibilities, explicit collaborations, protected invariants, and abstractions that make real change easier without adding unnecessary complexity.