Messages Before Objects
When you start designing an object-oriented system, the natural instinct is to ask: “What classes do I need?” You look at the problem, identify the nouns, and start sketching a class for each one. For a hotel booking system, for example, you’d need User, Room, Reservation, and PaymentMethod.
But this approach is incomplete. The classes you identify up front are rarely the full cast of your application. Worse: if you commit to them too early, you end up forcing behavior into classes that shouldn’t own it. You’re left with one class that knows everything and a handful of passive data containers orbiting around it.
The paradigm shift is this: instead of designing classes, design conversations. The classes you need will emerge from the messages those conversations require.
Let’s see how that shift plays out in practice, why sequence diagrams are such a useful discovery tool, how to design interfaces that trust instead of command, and what specific decoupling techniques let your objects collaborate with each other.
The Problem with Thinking in Classes First
We’re building a hotel booking platform. Customers reserve rooms for a date range, pay for them, and receive a confirmation.
Following the instinct to start with classes, we identify the obvious nouns and start writing:
class Reservation def initialize(user, room_id, start_date, end_date) @user = user @room_id = room_id @start_date = start_date @end_date = end_date end
def confirm room = Room.find(@room_id) return false unless room.available_between?(@start_date, @end_date)
amount = room.price_per_night * nights @user.payment_method.charge(amount)
room.block_dates(@start_date, @end_date)
NotificationService.new.send_email( @user.email, "Reservation confirmed", "Your reservation at #{room.name} is confirmed..." )
true end
private
def nights (@end_date - @start_date).to_i endendEven though this code works, look at what Reservation knows:
- The
Roomclass by name, and how to retrieve rooms withfind. - That
Roomresponds toavailable_between?,price_per_night,block_dates, andname. - That users have a
payment_method, and that payment methods respond tocharge. - That a
NotificationServiceclass exists, and how to send an email with it. - The exact template for the confirmation message.
Reservation is the primary entity, and everything else is secondary. That means Reservation would have to change in situations like these:
- The payment API changes.
- The notification system is replaced with SMS.
block_datesgets renamed toreserve_dates.- A discount rule is added.
This is the core problem with designing around classes first. You named the classes upfront, so when new behavior appears, the natural place to put it is inside whichever class seems to govern the flow. Reservation grows into a miniature god object. The rest of the system becomes support infrastructure.
Designing from Messages, Not from Classes
Let’s rewind and approach the same problem differently. Instead of asking “What classes do I need?”, ask “What messages need to be sent?”
A customer wants to book a room. That’s a message: book, sent to… something. The receiver isn’t obvious yet, and that’s exactly the point. We’re not going to commit prematurely. What we know is that the message needs to exist.
To book a room, we need to know it’s available. Another message: available?, sent to something that knows about room availability (probably a Room, but we’re not locking that in yet).
Other messages we need:
- Charge the customer.
- Record the reservation.
- Notify the customer.
Each message is a question: Who should respond to this? And the answer isn’t always a class we already had in mind.
You don’t send messages because you have objects. You have objects because you send messages.
Classes exist to be participants in a conversation. When the conversation needs someone new, you introduce a new class. When a class would have to stretch to respond to a message, that’s the signal the message belongs somewhere else.
Sequence Diagrams: Thinking with Arrows
Sequence diagrams are a UML tool. The idea is to draw the conversation before writing any code.
Here’s what our initial design looks like as a sequence diagram:
Customer Reservation | | |--- confirm() ------------->| | | | |--> Room (find, availability, pricing, blocking, name) | |--> User.payment_method (charge) | |--> NotificationService (send_email) | | |<---- true/false -----------|Every arrow leaving Reservation is a dependency. Drawing it out makes the problem visible: Reservation is a hub with spokes going in every direction.
Now let’s sketch what the conversation should look like, without committing yet to which objects take on which responsibilities. The customer wants a booking. Something should orchestrate the process, and whatever does the orchestrating should delegate the specialized work:
Customer BookingService Room Billing Mailer | | | | | |--- book(request) ----->| | | | | |---- reserve() ----->| | | | |<------ :ok ---------| | | | | | | | |-------- charge(payer) ---------->| | | | | | |-------- notify(request) ------------------->| | | |<---- :confirmed -------|Two things are immediately different:
- A
BookingServiceappeared. It wasn’t in our original list of nouns, because nouns aren’t the same as responsibilities. Booking isn’t a thing—it’s a conversation, and someone has to orchestrate it. - Each external object receives a single, high-level message.
BookingServicedoesn’t know howRoomreserves, doesn’t know howBillingcharges, and doesn’t know howMailernotifies. It knows what it wants.
Ask for What You Want, Not How to Do It
Look at the initial code again. The line room.block_dates(@start_date, @end_date) looks harmless, but it’s telling Room how to do its job: “here are the dates, block them.” It assumes that Room has an internal data structure of blocked dates, and that adding to that structure is the right operation.
Compare that to room.reserve(dates). The message is higher-level. It says: “I want these dates reserved; you decide what that means.” If Room later delegates to a calendar service, or applies complex blocking rules (minimum nights, buffer time between reservations), the caller doesn’t care. The implementation is free to evolve.
The same pattern shows up with payment. Compare:
# Tells how@user.payment_method.charge(amount)
# Asks whatbilling.charge(request.payer, amount: request.total)The first version assumes:
- That the user has a payment method
- That there is exactly one
- That charging goes directly to that method
The second asks the billing collaborator to handle everything related to charging, encapsulating operations like:
- Identifying the correct payment method
- Splitting the charge if applicable
- Applying discounts
The distinction between asking what and telling how shows up everywhere once you start looking for it. Any time a method step-by-step orchestrates the internals of another object, it’s telling how. Any time it declares a goal and trusts the receiver to achieve it, it’s asking what.
Context Independence: Trust Instead of Control
There’s a related idea that takes this one step further. Even when you ask for what you want, your object can still end up knowing too much about its collaborators.
Consider a BookingService that expects to receive a User and a Room:
class BookingService def book(user:, room:, dates:) return :unavailable unless room.reserve(dates)
Billing.charge(user, amount: room.price_for(dates)) Mailer.send_confirmation(user, room, dates)
:confirmed endendThis is better than where we started, but the book signature is tied to the exact trio (user, room, dates). If tomorrow we add “package reservations” (room + breakfast + spa) or group bookings where several people share the charge, that signature falls short and BookingService has to change. On top of that, BookingService already knows there’s something called Room that responds to reserve and price_for.
A more context-independent design flips the relationship. Instead of having BookingService operate on a user and a room, it operates on a booking request that knows how to describe itself:
class BookingService def book(request) return :unavailable unless request.reserve
Billing.charge(request.payer, amount: request.total) Mailer.send_confirmation(request)
:confirmed endendNow BookingService only knows about an abstract request that responds to reserve, payer, total, and that can be passed to the mailer. A RoomBookingRequest, a SuiteBookingRequest, or a PackageBookingRequest can all fulfill this contract. BookingService doesn’t know which one it has, doesn’t care, and doesn’t change when new types appear.
Public and Private Interfaces
Every class has two kinds of methods: those it invites others to use, and those it uses internally. The former make up the public interface; the latter, the private interface.
The distinction isn’t cosmetic. It’s a contract with the rest of the application. Public methods:
- Reveal the class’s primary responsibility.
- Are expected to be called from outside.
- Should remain stable across versions.
- Are documented, typically through tests.
Private methods:
- Handle implementation details.
- Are not expected to be called from outside.
- Can change at any time, for any reason.
- Are not safe to depend on from other places.
In Ruby, the private keyword isn’t a very high fence—you can technically jump over it. Its purpose is to communicate: “this is an implementation detail; if you depend on it, stability isn’t guaranteed.”
When you design a class, consciously decide which methods are public, and treat them as a promise. That’s your public interface, and the smaller it is, the better.
This connects directly to the “ask what, not how” principle. The more a method asks for, the smaller the public interface it requires of its collaborators. room.reserve(dates) requires a single public method on Room. room.check_calendar; room.block_dates; room.update_availability_count requires three, and each one becomes a promise you have to keep.
The Law of Demeter
The Law of Demeter is a law about knowing, and it’s often summed up as “use only one dot.” It says something like: “your object should only talk to its immediate neighbors, not to strangers reached through them.” When you write reservation.user.payment_method.charge(amount), you’re drilling through three levels of objects to invoke behavior on one of them. Now Reservation knows that users have payment methods, that payment methods can be charged, and that charging is reached by traversing that chain. A change to any of those intermediate structures breaks Reservation.
But not every dot chain is a violation. Look at this example:
hash.keys.sort.join(", ")The intermediate results (an Array of keys, a sorted Array, the final String) form a fluent pipeline. Each step transforms a value—there’s no reaching into a distant domain object to invoke behavior.
The question to ask when you see chained calls is: Am I trying to reach another object to make something happen? If yes, it’s a violation. If you’re simply chaining transformations on data, it isn’t.
When you do have a violation, the usual fix is delegation: adding a method on the immediate neighbor that hides the chain:
# Beforereservation.user.payment_method.charge(amount)
# Afterclass Reservation def pay!(amount) @user.charge(amount) endend
class User def charge(amount) @payment_method.charge(amount) endendBut be careful: delegation that only hides the chain without reducing coupling is cosmetic. If the caller still needs to know the amount, and that amount is calculated from fields scattered across multiple objects, you haven’t solved the problem. You’ve just moved the dots around.
The real fix for Demeter violations is usually the same as the fix for context: ask for higher-level behavior. Instead of reservation.user.payment_method.charge(amount), ask reservation.pay! and let Reservation handle the rest. Or better yet, let a Billing service manage the entire transaction.
Concrete Decoupling Techniques
Having the right mental model is most of the battle. But once you identify the problems, you can apply specific techniques.
Inject Dependencies Instead of Hardcoding Them
The original Reservation contained this line:
room = Room.find(@room_id)That’s a hardcoded dependency on the Room class. Reservation doesn’t work without Room. It can’t be tested without Room. It can’t be reused with any other kind of bookable thing.
With dependency injection, it becomes:
class BookingRequest def initialize(reservable:, dates:, payer:) @reservable = reservable @dates = dates @payer = payer endendNow the request accepts anything that responds to the right messages: a Room, a Suite, a Package. All of them work, as long as they’re bookable.
Isolate Dependencies You Can’t Inject
Sometimes injection isn’t possible. In those cases, plan B is isolation: not eliminating the dependency, but wrapping it so it lives in one obvious place.
class BookingRequest # ...
def reservable @reservable ||= Room.find(@room_id) endendRoom.find still exists, but now it’s tucked inside a single method. All other methods refer to reservable, not to Room. When it’s time to refactor toward dependency injection, the change happens in exactly one place.
Use Keyword Arguments
Positional arguments create a hidden dependency: every caller has to know the order. Add a new parameter and every call site breaks. Reorder them to fit a new convention and every call site breaks.
Keyword arguments eliminate the dependency on order:
# Positional: the caller depends on orderBookingRequest.new(user, room, start_date, end_date)
# Keyword: order is flexible, names document intentBookingRequest.new( payer: user, reservable: room, dates: start_date..end_date)The verbosity is the point. Each argument is labeled at the call site, which serves as both documentation and protection against reordering.
Depend on Things That Change Less Than You Do
This is the deepest of the techniques, because it’s the most strategic. Every dependency is a potential vector for change. If A depends on B, changes to B can force changes in A. That’s fine if B is more stable than A, but it’s a problem if B is less stable.
How do you know what’s stable? Two heuristics:
- Abstractions are more stable than concretions. An interface that says “something that can be booked for a date range” changes less than a
Roomwith fourteen specific fields. - External collaborators vary in stability. Ruby’s
Stringclass is rock-solid. A brand-new gem isn’t. Keep track of which collaborators are stable and which might be volatile.
The practical rule: when you have a choice, depend on abstractions and stable collaborators. When you don’t have a choice (your concrete collaborator is volatile), isolate the dependency behind a wrapper so at least the damage stays contained.
Putting It All Together
Let’s go back to the booking platform and see what the redesign looks like end to end:
class BookingRequest attr_reader :payer
def initialize(reservable:, dates:, payer:) @reservable = reservable @dates = dates @payer = payer end
def reserve @reservable.reserve(@dates) end
def total @reservable.price_for(@dates) end
def description "#{@reservable.name}, #{@dates}" endend
class BookingService def initialize(billing:, mailer:) @billing = billing @mailer = mailer end
def book(request) return :unavailable unless request.reserve
@billing.charge(request.payer, amount: request.total) @mailer.send_confirmation(request)
:confirmed endendCompared to the original:
BookingServicedoesn’t knowRoom,User, orNotificationServiceby name. It knows a request that describes itself, a billing collaborator, and a mailing collaborator.- The public interface of each collaborator is minimal.
requestexposesreserve,total,payer, anddescription.billingexposescharge.mailerexposessend_confirmation. Small interfaces, stable promises. - There are no Demeter violations.
BookingServicetalks to its direct collaborators and trusts them. BookingServiceis context-independent: it works with any type of reservable, any type of payer, any billing or mailing implementation.
The original Reservation accumulated dependencies on four different classes, some visible and some hidden behind other objects. The redesigned BookingService has two injected collaborators (billing, mailer) and receives a request that describes itself.
Conclusion
The hardest shift in object-oriented design is the one from “What classes do I need?” to “What messages need to be sent?” When you design from messages, the classes you discover tend to have clear public interfaces, minimal context dependencies, and behavior placed where it belongs. When you design from classes, you end up forcing behavior into a central class and building a system that resists change.
The techniques we’ve covered here—dependency injection, keyword arguments, isolation, and attention to the stability of collaborators—are how you translate that mental shift into code. What makes them effective isn’t the technique itself; it’s the underlying question they answer: How can this object know less about who it’s talking to?
The more your objects can do their job without knowing who they’re talking to, the more your application can change without breaking.
Test your knowledge
-
What is the main problem with starting a design by identifying the nouns in the domain and creating one class for each?
-
What are sequence diagrams for?
-
Which principle best explains the difference between
@user.payment_method.charge(amount)andbilling.charge(request.payer, amount: request.total)?
-
You’re reviewing this line of code in a project:
hash.keys.sort.join(", "). Is it a violation of the Law of Demeter?
-
Which heuristic best captures the golden rule for deciding the direction of your dependencies?