Skip to content

Encapsulation

Encapsulation broadly means bundling related data and behavior inside a unit such as a Class. Its design quality depends on how that unit controls access to its representation and protects its rules. Visibility is a tool for this; making a Property private does not by itself guarantee a good design.

Replacing public balance access with setBalance() still lets callers choose arbitrary balances. Even a setter that rejects negative values cannot explain why money was added or removed. Operations such as deposit() and withdraw() express intent and provide a place for each operation’s rules.

Tell, Don’t Ask: request an operation from its owner

Section titled “Tell, Don’t Ask: request an operation from its owner”

The principle encourages callers to request behavior from the Object that owns the relevant responsibility and let it apply its rules. Don’t Ask does not ban all questions or getters. The concern is retrieving an Object’s data to implement its own rules elsewhere.

Consider this checkout code. It assumes a query and an unrestricted setter to illustrate a weak design:

$balance = $wallet->balance();
if ($amount <= 0) {
throw new InvalidArgumentException('Amount must be positive.');
}
if ($amount > $balance) {
throw new LogicException('Insufficient balance.');
}
$wallet->setBalance($balance - $amount);

The caller reads the balance, decides whether withdrawal is valid, computes the new balance, and writes it back. The Wallet holds data while withdrawal rules live outside it. Another caller must repeat the checks and might omit one. Adding a rule such as preventing withdrawal from a suspended Wallet would require finding and changing these callers.

With Tell, Don’t Ask, external code expresses its request:

$wallet->withdraw($amount);

The Wallet validates the amount and available balance before changing its State. Every caller uses the same protected operation. Rules can evolve inside withdraw() while its meaning remains consistent for callers. Tell does not mean a request always succeeds: the Object may reject an invalid operation.

The caller knows which operation it needs without knowing the balance-update calculation or every withdrawal rule. The Object keeps its information and the decisions that govern it together behind a meaningful entry point. Renaming getters and setters does not help if the rules remain external.

For example, canWithdraw() followed by setBalance() still permits callers to bypass checks:

if ($wallet->canWithdraw($amount)) {
$wallet->setBalance($wallet->balance() - $amount);
}

canWithdraw() may be useful for display, but withdraw() must enforce the rules at execution time instead of relying on a prior caller check.

Displaying a balance is a legitimate query:

$displayBalance = $wallet->balance();

Similarly, isPaid() can support displaying an Order’s state. When reviewing code, ask whether it reads information for a legitimate consumer need or extracts information to implement the Object’s own rules externally. The principle guides responsibility placement; it does not prohibit queries.

It also does not put every responsibility into Wallet. Checkout coordinates components, Wallet protects withdrawal, and a notification component delivers confirmation. Broader applications of this principle have a later lesson; here the focus is protecting State through encapsulation.

Protect a rule throughout the object’s lifetime

Section titled “Protect a rule throughout the object’s lifetime”

An invariant is a condition that must remain true for a valid Object. For this educational Wallet, the invariant is that its balance never becomes negative. The constructor and withdrawal operation both protect it:

<?php
declare(strict_types=1);
final class Wallet
{
public function __construct(private int $balance)
{
if ($balance < 0) {
throw new InvalidArgumentException('Negative initial balance.');
}
}
public function withdraw(int $amount): void
{
if ($amount <= 0) {
throw new InvalidArgumentException('Amount must be positive.');
}
if ($amount > $this->balance) {
throw new LogicException('Insufficient balance.');
}
$this->balance -= $amount;
}
public function balance(): int
{
return $this->balance;
}
}

Amounts are integers in a single currency’s smallest unit. This example teaches object rules; it is not a complete payment or persistent wallet system.

Starting with 100, withdrawing 30 leaves 70. A subsequent withdrawal of 90 throws before mutation, leaving 70. An Exception does not automatically undo previous successful operations.

$order->isPaid() expresses a meaningful question and can hide how the Order represents payment state. Its result must retain the same meaning when the implementation changes. Reading state through a focused query is useful; getters are not inherently bad.

  • Adding getters and setters for every Property without considering rules.
  • Checking rules only in callers, where checks may be forgotten or duplicated.
  • Validating operations but allowing invalid constructor state.
  • Making internal state public and allowing callers to bypass behavior.
  • Hiding useful information unnecessarily: choose access boundaries according to the rules and responsibilities of the Object.

Good encapsulation keeps related state and behavior together, hides details that callers need not depend on, and protects valid state through meaningful operations.

  1. Why can a private balance with an unrestricted setter still be unsafe?
  2. Why must constructor validation allow zero but reject a negative balance?
  3. Does a failed operation undo an earlier successful operation?
  4. What does isPaid() hide from its caller?