The most important design decision is often made before a class exists.
It is tempting to begin with nouns. A reservation system has a User, a Restaurant, a Slot, and a Reservation, so we create those classes, give them fields, add constructors, and wait for the design to reveal itself. Sometimes it does. More often, we have only arranged vocabulary without understanding the problem.
The older tradition of software engineering suggests a more demanding starting point: describe what can happen. Before deciding what an object is, model the behavior in which it participates.
A class is not a noun with methods
A class is not valuable because it represents a noun from a requirements document. It is valuable when it gives a coherent home to rules that must remain true together.
An object is not merely a record with behavior attached. It is a participant in a world: it has an identity, it stands in relationships with other participants, and it responds to actions while preserving its invariants. The class is the boundary through which we make those responses precise.
This is why object-oriented design, at its best, is less about hierarchies and more about responsibility. The question is not “Which class owns this data?” but “Which participant is responsible for keeping this fact true?”
Start with phenomena
To describe a domain, we need a vocabulary for what is observable. A useful vocabulary has three parts.
Individuals are the distinct participants: Alice, Bob, a restaurant, a time slot, or a reservation. They have identity. Two slots may share the same time and restaurant, but they are still different slots if the domain distinguishes them.
Relationships are facts connecting participants or assigning values to them. A reservation is made by Alice. A slot belongs to Maido. A slot has a date and a start time. These facts are not decorative properties added to objects; together they describe the state of the domain.
Actions are events that change the state: a slot is created, Alice reserves it, Bob cancels his reservation, Alice redeems hers. Actions should have a clear meaning, clear participants, and a clear result.
This vocabulary prevents a common mistake: confusing implementation details with domain meaning. A database row, a REST endpoint, or a button click may be part of the implementation. “Alice reserves Slot 3” is the behavior we are trying to preserve across any implementation.
From stories to traces
A trace is a sequence of actions. It is a small story about the system:
createSlot(Maido, 7:30pm, January 10): Slot_3
reserve(Alice, Slot_3): Reservation_1
cancel(Reservation_1)
At every point in that story, some relationships hold. Before the reservation, Slot 3 is available. After it, the slot is associated with Reservation 1, and that reservation is associated with Alice. After cancellation, the reservation is no longer active and the slot may become available again.
The trace is useful because it exposes behavior as time and causality, not as a static collection of fields. It also gives us examples that can become tests. A class design that cannot explain its traces is not finished, no matter how elegant its names look.
State the rules before the code
For each action, write down what must be true before it can occur and what it guarantees afterward. These are the preconditions and postconditions of the operation.
For reserve(Alice, Slot_3), the rules might be:
- the user exists;
- the slot exists and is still available;
- the slot belongs to the restaurant being considered;
- after the action, exactly one active reservation refers to the slot;
- the resulting reservation belongs to Alice.
These rules do more than improve documentation. They define the contract of the future object. They tell us which transitions are legal and which requests must be rejected. They also reveal where responsibilities belong: availability is a rule of the slot or reservation policy, while sending an email may belong to an external service observing the successful action.
Only then choose objects
Once the behavior is explicit, classes become less mysterious. We can ask:
- Which facts must change together?
- Which rules must always be protected?
- Which actions require a participant to decide?
- Which concepts have identity, and which are only values?
An EmailAddress may be a value: two equal addresses are interchangeable. A Reservation usually has identity and a lifecycle. A Restaurant may own a collection of slots, but it should not automatically own every decision in the system. A class should have a boundary that makes its promises visible and its invalid states difficult to construct.
type SlotStatus = "available" | "reserved";
class Slot {
constructor(
readonly id: string,
readonly restaurantId: string,
readonly startsAt: Date,
private status: SlotStatus = "available",
) {}
reserve(): void {
if (this.status !== "available") {
throw new Error("Slot is not available");
}
this.status = "reserved";
}
}
The point is not that every domain needs this exact class. The point is that reserve expresses a transition, and the object protects the rule that makes the transition valid. A public status field plus a convention would be weaker: any caller could create a state that the domain forbids.
Encapsulation is a moral choice
Encapsulation is sometimes taught as a technique for hiding fields. Its deeper purpose is to protect meaning. When a class hides a decision, it prevents the rest of the system from quietly making contradictory decisions about the same fact.
This connects the object tradition to the broader lessons of software engineering from the 1960s through the 2000s. Dijkstra made us take correctness seriously. Hoare gave us a language for reasoning about contracts. Parnas taught us to hide design decisions behind module boundaries. Booch and others developed ways to model complex systems. Robert C. Martin popularized the discipline of keeping responsibilities, dependencies, and abstractions under control.
These ideas are not museum pieces. Modern frameworks, distributed systems, and AI applications still depend on them. A language model may generate a class in seconds, but it cannot decide whether the class represents a real responsibility, whether its state transitions are legal, or whether the domain has been understood. Generation makes typing cheaper; it does not make modeling unnecessary.
Behavior is the architecture
Good architecture is not a diagram full of boxes. It is a set of stable decisions about what the system means, what can happen, and what must never happen. Classes and objects are tools for preserving those decisions as the implementation grows.
So the order matters:
- Observe the domain and name its phenomena.
- Identify individuals, relationships, and actions.
- Write traces that tell realistic stories.
- Define the legal transitions with preconditions and postconditions.
- Group rules by responsibility.
- Choose classes and objects that protect those responsibilities.
- Test the behavior, not only the shape of the code.
The class is the last step in this chain, not the first. It is a compact answer to a question that should already be clear: “Who is responsible for this behavior, and which truth must it protect?”
When we begin there, object-oriented design stops being an exercise in arranging nouns. It becomes a way to make a small, precise model of a world, and to let that model remain understandable when the code around it changes.