Create your own
Lesson illustration

Maximizing Cohesion and Minimizing Coupling in Object Responsibilities

Hello. Last lesson established how invariants protect a domain model: ParkingTicket protects its own lifecycle rules, while ParkingLot coordinates rules spanning tickets and spots. Now we use those rules to answer a design question that appears constantly in LLD interviews:

Which object should do what?

A strong answer is not “put each method where it feels natural.” You will assign behavior so each class remains focused (high cohesion) and does not need excessive knowledge of other classes (low coupling). This makes a design easier to extend when requirements change—exactly the pressure an interviewer will introduce after your first design.

By the end of this lesson, you should be able to allocate responsibilities across a small domain model, explain the allocation using the Information Expert, High Cohesion, and Low Coupling principles, and recognize common “god class” designs before they become difficult to change.


Responsibilities: knowledge, behavior, and bounded coordination

An object’s responsibilities are the things it is accountable for. They usually fall into three categories:

  • Knowing: holding or exposing domain information, such as a ticket’s entry time or a spot’s supported vehicle type.
  • Doing: performing behavior using that information, such as calculating a parking duration or releasing an occupied spot.
  • Coordinating: sequencing a domain operation involving several objects, such as closing a ticket and freeing its corresponding spot together.

The previous lesson already gives us an important constraint: responsibility placement must preserve invariants. If ParkingTicket is responsible for ensuring it cannot close twice, then code outside the ticket should not freely assign ticket.status.

The two quality goals for responsibility assignment are:

  • High cohesion: responsibilities in one class are strongly related and support one well-bounded purpose.
  • Low coupling: a class has limited knowledge of, and dependency on, other classes’ details.

Neither principle means “make every class tiny” or “make classes independent of everything.” Objects must collaborate. The goal is to make the necessary collaborations narrow, understandable, and stable.

Cohesion and Coupling: Write BETTER PYTHON CODE Part 1

Watch “Cohesion and Coupling: Write BETTER PYTHON CODE Part 1” by ArjanCodes. It introduces the two design qualities in Python terms, then shows how placing behavior near the data it needs simplifies a tangled design.

Watch the definitions for the distinction between cohesion and coupling. Then watch the refactoring, focusing on the presenter’s method: identify where data belongs, then move behavior close to the data it uses. Notice that the final design has more methods and classes, but each has a clearer reason to change.

The following image is a useful mental model, not a literal measure of code quality. The left side has unrelated pieces mixed together with many crossing dependencies. The right side groups related pieces and limits connections between groups.

The left side depicts low cohesion and high coupling: mixed components have many tangled dependencies. The right side depicts cohesive groups with fewer, more deliberate connections between them.

A practical way to read the image is:

  • A cohesive group has parts that work toward the same domain purpose.
  • A low-coupled group exposes a small boundary: other parts of the system need to know what it can do, not how it stores or performs it.

Start with the information needed to do the work

The most useful first heuristic is the GRASP principle Information Expert:

Assign a responsibility to the class that has the information needed to fulfill it.

This is a starting point, not an automatic rule. It tends to place behavior near the state it operates on, which improves encapsulation and cohesion.

CS 619 Introduction to OO Design and Development GRASP Patterns Fall 2012

These University of New Hampshire CS 619 slides introduce the three connected GRASP heuristics used in this lesson: Information Expert, Low Coupling, and High Cohesion.

In the “GRASP: Low Coupling” slides, read the definition and the list of costs of excessive coupling. Then find “GRASP: High Cohesion” and read the warning signs for an unfocused class. Finally, in “Information Expert Example,” read from the sale-total question. Follow the delegation: Sale knows its line items, and each line item knows the product and quantity needed for its own subtotal.

Consider a parking-lot prompt with this requirement:

“When a vehicle exits, calculate the fee, close the ticket, and make the associated spot available.”

A weak first attempt might put every action in one ParkingLotService method:

class ParkingLotService:
    def exit_vehicle(self, request: dict) -> None:
        ticket = self.database.find_ticket(request["ticket_id"])
        spot = self.database.find_spot(ticket.spot_id)

        duration = request["exit_time"] - ticket.entry_time
        rate = self.lookup_rate(ticket.vehicle_type)
        fee = duration.total_seconds() / 3600 * rate

        ticket.status = "closed"
        ticket.exit_time = request["exit_time"]
        spot.status = "available"

        self.card_gateway.charge(ticket.vehicle_id, fee)
        self.email_client.send_receipt(ticket.customer_email, fee)
        self.audit_logger.write("vehicle exited")

The method may work initially, but it has poor cohesion. It is simultaneously concerned with:

  • interpreting a request,
  • loading persistence records,
  • calculating a domain fee,
  • changing ticket state,
  • changing spot state,
  • charging a card,
  • sending an email,
  • writing an audit record.

It is also tightly coupled to a database, dictionary-shaped HTTP input, ticket internals, spot internals, a payment gateway, an email client, and a logging mechanism. Changes to any of these areas can force a change in the same method.

The issue is not merely that the method is long. A short method can also be poorly cohesive if it mixes unrelated responsibilities. The key question is:

Would the same class need to change for several unrelated reasons?

If a class changes when pricing changes, persistence changes, notification changes, and HTTP input changes, it is probably doing too much.


A responsibility map for the parking-lot model

Rather than beginning with method names, walk through the use case and ask two questions for each action:

  1. What information is required?
  2. Which class already owns that information or invariant?

Here is a reasonable allocation for the parking-lot example.

ResponsibilitySuitable ownerWhy this is a strong fit
Know ticket entry time, vehicle type, status, and assigned spotParkingTicketThese facts define a ticket’s lifecycle.
Reject a second close or an invalid exit timeParkingTicketIt owns the lifecycle invariant.
Determine a stay durationParkingTicketIt already knows the entry time and validates the exit time.
Know whether a spot is free and which ticket occupies itParkingSpotThis is the spot’s own state.
Reject release by the wrong ticketParkingSpotIt protects the occupancy invariant.
Determine whether a vehicle can use a spotParkingSpotIt knows its configured type and current availability.
Compute a fee from duration and vehicle typePricingPolicyPricing rules form one business concern and may change independently.
Coordinate a valid exit across ticket and spotParkingLotThe operation preserves a cross-object invariant.
Parse HTTP input, authenticate, load and save dataApplication layerThese are delivery and persistence concerns, not parking-domain rules.

Notice the difference between coordination and ownership.

ParkingLot can coordinate the exit operation because ticket and spot must remain consistent together. But that does not mean ParkingLot should directly set internal fields on either object. It should ask each object to perform its own behavior.

A focused version can look like this:

from datetime import datetime, timedelta


class ParkingTicket:
    def __init__(
        self,
        ticket_id: str,
        spot_id: str,
        vehicle_type: str,
        entry_time: datetime,
    ) -> None:
        self.ticket_id = ticket_id
        self.spot_id = spot_id
        self.vehicle_type = vehicle_type
        self.entry_time = entry_time
        self._closed_at: datetime | None = None

    def ensure_can_close(self, exit_time: datetime) -> None:
        if self._closed_at is not None:
            raise TicketAlreadyClosedError(self.ticket_id)
        if exit_time < self.entry_time:
            raise InvalidExitTimeError("Exit cannot precede entry.")

    def duration_until(self, exit_time: datetime) -> timedelta:
        self.ensure_can_close(exit_time)
        return exit_time - self.entry_time

    def close(self, exit_time: datetime) -> None:
        self.ensure_can_close(exit_time)
        self._closed_at = exit_time


class ParkingSpot:
    def __init__(self, spot_id: str, supported_vehicle_type: str) -> None:
        self.spot_id = spot_id
        self.supported_vehicle_type = supported_vehicle_type
        self._active_ticket_id: str | None = None

    def ensure_occupied_by(self, ticket_id: str) -> None:
        if self._active_ticket_id != ticket_id:
            raise SpotTicketMismatchError(self.spot_id)

    def release(self, ticket_id: str) -> None:
        self.ensure_occupied_by(ticket_id)
        self._active_ticket_id = None


class PricingPolicy:
    def calculate(
        self,
        duration: timedelta,
        vehicle_type: str,
    ) -> Money:
        ...


class ParkingLot:
    def exit(
        self,
        ticket_id: str,
        exit_time: datetime,
        pricing: PricingPolicy,
    ) -> Money:
        ticket = self._find_active_ticket(ticket_id)
        spot = self._find_spot(ticket.spot_id)

        ticket.ensure_can_close(exit_time)
        spot.ensure_occupied_by(ticket.ticket_id)

        duration = ticket.duration_until(exit_time)
        fee = pricing.calculate(duration, ticket.vehicle_type)

        spot.release(ticket.ticket_id)
        ticket.close(exit_time)

        return fee

This code is intentionally incomplete; it is a responsibility design, not a finished production implementation. What matters is the distribution:

  • ParkingTicket protects ticket lifecycle.
  • ParkingSpot protects its occupancy.
  • PricingPolicy owns the pricing calculation.
  • ParkingLot performs the narrow coordination needed to keep ticket and spot consistent.

The ParkingLot still depends on tickets, spots, and pricing. That coupling is justified because coordinating their collaboration is its purpose. The design reduces unnecessary coupling, rather than pretending those objects are unrelated.


High cohesion: one meaningful reason to change

A class is cohesive when its methods and state contribute to one well-bounded behavior.

For example, these ParkingTicket operations are cohesive:

ticket.duration_until(exit_time)
ticket.close(exit_time)
ticket.is_active()

They all concern the ticket’s lifecycle.

These operations would make ParkingTicket less cohesive:

ticket.charge_customer()
ticket.send_exit_email()
ticket.save_to_database()
ticket.render_html_receipt()

The ticket knows the facts needed to describe a parking stay, but that does not make it responsible for every action that happens after a vehicle exits.

A useful diagnostic is to name the reasons an object could change:

ClassAppropriate reasons to changeSuspicious reasons to change
ParkingTicketTicket lifecycle rules or ticket stateEmail template changes, payment-provider changes
ParkingSpotSpot compatibility or occupancy rulesHourly pricing changes, database schema changes
PricingPolicyRate schedules, discounts, vehicle categoriesTicket storage rules, notification content
ParkingLotRules for assigning or releasing ticket-spot pairsJSON format changes, card-network APIs
Application endpointRequest format, authentication, response mappingParking-duration calculation

This is more precise than blindly applying “single responsibility.” Most useful domain objects have several related methods. A ticket may validate closing, report activity, calculate duration, and expose its assigned spot. These are multiple responsibilities in a literal sense, but together they serve one cohesive purpose: representing and managing a parking ticket.

Do not split every method into its own class. That produces a different failure mode: a maze of tiny objects with unclear boundaries. Split when you see a genuinely independent concern, a distinct source of change, or a separate external dependency.


Low coupling: depend on small, stable boundaries

Coupling measures how strongly one software element depends on another. Dependency is unavoidable: a ParkingLot must collaborate with ParkingTicket to close a ticket. The question is how much it needs to know.

Compare these two approaches.

# Fragile: external code knows ticket representation details.
if ticket._closed_at is None:
    ticket._closed_at = exit_time
# Narrower contract: the ticket owns its representation and validation.
ticket.close(exit_time)

The second caller depends on the behavior promised by close(), not on the internal field name, state encoding, or validation steps. ParkingTicket could later represent its state with an enum, an event history, or a different persistence mapping without forcing every caller to change.

Common forms of unnecessary coupling in LLD answers include:

  • Reaching into another object’s state. For example, code directly changes spot._active_ticket_id.
  • Long navigation chains. For example, ticket.customer.account.payment_profile.card_number. This exposes several internal relationships at once.
  • Knowing concrete implementation details. For example, a domain class manually constructs SQL or knows a particular vendor’s payment request format.
  • Hard-coding every subtype or option. For example, a central class with a growing if vehicle_type == ... block for behavior that belongs closer to vehicle or pricing rules.
  • Passing oversized objects when only a few values are required. A pricing calculation may need a duration and vehicle category, not an entire HTTP request or database session.

A helpful design rule is:

Ask an object to perform a meaningful operation; do not make it expose its internals so another object can perform the operation for it.

This is sometimes summarized as “tell, don’t ask.” It does not mean getters are forbidden. It means that if external code repeatedly reads an object’s fields, applies a rule, and writes fields back, the behavior likely belongs with the object that owns the state.


When Information Expert is not enough

Information Expert is a valuable first move, but applying it mechanically can create poor designs.

Suppose ParkingTicket knows the amount due and the customer reference. Should ParkingTicket make an HTTP call to a payment provider?

No. A payment-provider request involves an external protocol, credentials, retries, timeouts, provider failures, and audit requirements. Those concerns change for different reasons than ticket lifecycle rules. Giving that responsibility to ParkingTicket would lower its cohesion and couple it to infrastructure.

A better separation is:

  • The ticket exposes the domain information needed to settle the parking stay.
  • The pricing component calculates an amount.
  • An application-level use case coordinates persistence and payment handling.
  • A payment-specific component communicates with the external provider.

So responsibility assignment is an evaluation process, not a single formula:

  1. Start with information ownership. Which object has the required data and invariant?
  2. Check cohesion. Does the new behavior serve the object’s central purpose?
  3. Check coupling. Does the placement force the class to know unrelated or volatile details?
  4. Check change pressure. Would a new requirement change this class for a sensible reason?
  5. Check the invariant boundary. If several objects must change consistently, identify one narrow coordinator.

This balance is especially useful in interviews. An interviewer may ask, “Why did you place fee calculation in PricingPolicy rather than ParkingTicket?” A concise answer is:

“The ticket is the expert on entry time and vehicle type, so it can provide the facts needed for pricing. But rate schedules can change independently of ticket lifecycle, so I keep the calculation in a focused pricing component. The parking lot coordinates the ticket and spot transition because it owns their cross-object consistency rule.”

That answer demonstrates reasoning, not just pattern vocabulary.


Walk through a use case with a responsibility card

Before drawing a UML class diagram, use a compact CRC-style view: Class, Responsibility, Collaborator. It is a fast way to test your design against a use case.

For vehicle exit:

ClassResponsibilitiesCollaborators
ParkingLotLocate the active ticket and spot; coordinate valid exitParkingTicket, ParkingSpot, PricingPolicy
ParkingTicketValidate closure; calculate duration; record closureNone for local lifecycle rules
ParkingSpotVerify assigned ticket; release itselfNone for local occupancy rules
PricingPolicyCalculate fee from parking factsMoney value object, if modeled
Application use caseReceive request; load/save model; invoke payment workflowRepository and payment components

Now mentally narrate the collaboration:

  1. The application layer receives a valid exit request and loads the relevant domain objects.
  2. ParkingLot finds the active ticket and its assigned spot.
  3. ParkingTicket validates that it can close at the supplied time.
  4. ParkingSpot confirms it is occupied by that ticket.
  5. PricingPolicy calculates the fee from the parking duration and vehicle type.
  6. ParkingLot asks the spot to release itself and the ticket to close itself.
  7. The application layer persists the completed operation and handles external side effects.

This walkthrough exposes design issues early. If ParkingLot needs to inspect private ticket fields, give ParkingTicket a meaningful operation. If ParkingTicket suddenly needs a database session, a payment SDK, and an email template, move those responsibilities out.

In an interview, you do not need to draw CRC cards formally. A small responsibility table or a narrated use-case trace is enough to make your choices legible.


A time-boxed responsibility-assignment method

For a typical LLD prompt, use this sequence after extracting requirements and invariants:

  1. State the main use case.
    Choose one critical flow, such as “park vehicle” or “exit vehicle.”

  2. List the domain verbs.
    Examples: assign, validate, calculate, close, release, persist, notify.

  3. Identify the information holder for each verb.
    This gives an initial Information Expert candidate.

  4. Place invariant-protecting behavior with the state it protects.
    A spot releases itself; a ticket closes itself.

  5. Identify cross-object operations.
    Give coordination to a small domain coordinator or aggregate root, rather than to a UI controller.

  6. Separate infrastructure and delivery concerns.
    HTTP parsing, ORM calls, provider SDKs, and message delivery should not blur the domain model.

  7. Challenge each class with change scenarios.
    Ask: “If pricing changes, which class changes?” “If we add an exit notification, which class changes?” A focused answer indicates healthy cohesion.

A concise interview explanation might sound like this:

“I will keep ticket lifecycle behavior inside ParkingTicket and occupancy behavior inside ParkingSpot, because each object owns the associated state and invariants. ParkingLot coordinates entry and exit because those operations must keep ticket and spot references consistent. Pricing is separated because rate rules have an independent change cycle. The application layer handles request parsing, persistence, and external payment calls, keeping the domain objects cohesive and limiting infrastructure coupling.”


Key takeaways

Responsibility assignment is the bridge between a list of domain concepts and a maintainable object design.

  • Use Information Expert to find the initial owner: place behavior near the data and invariants it needs.
  • Preserve high cohesion by giving each class a well-bounded domain purpose and a sensible reason to change.
  • Preserve low coupling by depending on meaningful operations and small contracts rather than other objects’ internal state.
  • Keep bounded coordination where a cross-object invariant requires it, but do not turn the coordinator into a god class.
  • Separate domain behavior from HTTP handling, persistence mechanics, payment SDKs, and notification delivery.
  • Test your allocation by narrating one main use case and asking what changes when requirements evolve.

Next, you will build on these responsibilities to decide when to use composition versus inheritance. That choice will determine whether your classes remain adaptable as new vehicle types, pricing rules, or parking-spot behaviors are introduced.

Can't find a good explanation? Sign up and we'll make it for you

Sign up