Free preview lesson ยท From the full paid path

What a Class Is Actually For

15 min read

The Wallet Whose Balance Stopped Matching Its History

A fintech I advised in 2020 had a Wallet class with two public fields: a long balancePaise and a List<Transaction> history. Any code that needed to add money did wallet.balancePaise += amount and then, if the author remembered, wallet.history.add(txn). Two lines, done by hand, in about forty places across the codebase.

You can already guess the incident. A cashback job credited a user by writing balancePaise and forgot the second line. The balance said 500, the history summed to 300. For most users this never mattered, because nobody added up their history. Then the company launched a "download your statement" feature, which recomputed the balance from the history and showed both. Now thousands of users saw two different numbers for their own money, and support could not tell which one was real. The reconciliation took a week and an apology email.

The bug was not the forgotten line. The bug was that a forgotten line was possible. The Wallet had a rule it was supposed to keep true forever, that the balance always equals the sum of the history, and it had handed that rule out to forty callers and hoped every one of them would honour it. Forty callers, forty chances to get it wrong, and one of them did.

The fix was three lines of thinking, not code. Make the fields private. Add one method, credit(amount, reason), that updates the balance and appends the history together, so they can never be out of step. Delete the forty hand-written pairs. After that, there was exactly one place money could enter a wallet, and that one place was correct by construction.

Encapsulation is hiding an object's data behind a small set of methods so nobody can put it in an invalid state. Abstraction is naming the job an object does so callers depend on the job, not on how it is done. When you finish this page you can explain what a class is actually for: not to hold data, but to own a rule and protect it. That is the idea the whole rest of this module quietly relies on.

Why Should You Care?

First, this is the single most common gap between code that compiles and code that survives. Public fields compile. They also mean every invariant your object has is only as safe as the least careful caller, and on a real team you cannot audit every caller.

Second, interviewers probe it directly. "Why is that field private?" is a real question, and "because encapsulation" is not an answer. "Because the balance and the history must move together, and a private field with one credit method is the only place that guarantees it" is an answer.

Third, abstraction is what lets a system change vendors, swap a payment gateway, move from one SMS provider to another, without rewriting the callers. If your Checkout calls RazorpayHttpClient directly, every caller knows Razorpay's name, and switching gateways touches all of them. If it calls a PaymentGateway interface, switching is one new class.

๐ŸŸข The Simple Version: The Medical Shop Counter

In plain words: encapsulation is a counter you cannot walk behind. You ask the person at the counter for what you want, and they hand it over. You never reach past them to the shelves yourself, because you might grab the wrong strip, or knock over the stock, or take a medicine that needs a prescription.

A medical shop keeps the medicines behind the counter for a reason. If customers could reach the shelves directly, someone would take a schedule-H drug without a prescription, someone would mess up the arrangement, someone would grab an expired strip from the back. The counter is not there to slow you down. It is there so that every transaction goes through one person who knows the rules: check the prescription, take the payment, log the sale. The shelves are the object's private fields. The pharmacist is the object's methods. You get what you need without ever touching the internals, and the rules are enforced in one place.

Encapsulation is exactly this. The fields are behind the counter. The methods are the counter. A caller says wallet.credit(500, "cashback") and the wallet does the right thing internally. The caller never reaches past the counter to set balancePaise directly, because that is how you end up with a balance that lies about its own history.

Public fields lose the invariant; one method keeps it Public fields: invariant lives in nobody Walletpublic balance + history Callers set fieldsby hand, 40 places balance driftsfrom history Private + one method: invariant protected Walletprivate fields one credit()/debit()door balance always== history Private fields plus one method mean the invariant is protected in one place, not hoped for in forty callers.

Scroll sideways to read, or tap to zoom

Public fields let callers reach in and break the rule; a private field with one method keeps it
100%
Public fields lose the invariant; one method keeps it Public fields: invariant lives in nobody Walletpublic balance + history Callers set fieldsby hand, 40 places balance driftsfrom history Private + one method: invariant protected Walletprivate fields one credit()/debit()door balance always== history Private fields plus one method mean the invariant is protected in one place, not hoped for in forty callers.

The precise word for the rule an object protects is its invariant. The wallet's invariant is "balance equals the sum of the history." A parking spot's invariant is "occupied is true if and only if some vehicle is parked here." An object's whole job is to hold some state and keep its invariant true no matter what callers do. Public fields throw the invariant away, because now the state can be changed without the object getting a say.

๐ŸŸก Encapsulation: Hide the Fields, Expose the Intent

Here is the wallet, the broken way and the fixed way, so the difference is concrete.

The broken version, the one that shipped:

// Anyone can write these. The invariant lives in nobody.
class Wallet {
    public long balancePaise;
    public List<Transaction> history = new ArrayList<>();
}

// Somewhere in a cashback job:
wallet.balancePaise += 20000;
// ...and the line that credits history is easy to forget, so it was.

The fixed version:

final class Wallet {
    private long balancePaise;
    private final List<Transaction> history = new ArrayList<>();

    Wallet(long openingPaise) {
        this.balancePaise = openingPaise;
        history.add(new Transaction("opening", openingPaise));
    }

    // The only door money enters by. Balance and history move together,
    // so they can never drift. The invariant is protected in one place.
    void credit(long amountPaise, String reason) {
        if (amountPaise <= 0) {
            throw new IllegalArgumentException("credit must be positive");
        }
        balancePaise += amountPaise;
        history.add(new Transaction(reason, amountPaise));
    }

    void debit(long amountPaise, String reason) {
        if (amountPaise <= 0) {
            throw new IllegalArgumentException("debit must be positive");
        }
        if (amountPaise > balancePaise) {
            throw new IllegalStateException("insufficient balance");
        }
        balancePaise -= amountPaise;
        history.add(new Transaction(reason, -amountPaise));
    }

    long balancePaise() { return balancePaise; }
    List<Transaction> history() { return List.copyOf(history); }
}

Three things earn their place here, and each one is a design decision worth saying out loud in an interview.

The fields are private, so no caller can set the balance directly. There is exactly one door for money in (credit) and one for money out (debit), and both update balance and history in the same breath. The drift bug is now impossible to write, not merely discouraged.

The methods validate before they mutate. A credit must be positive, a debit cannot exceed the balance. This is the invariant defending itself. In the public-field version there was nowhere to put these checks, so they lived in each caller, which means they were missing in some.

The history() getter returns List.copyOf(history), a copy, not the live list. If it returned the real list, a caller could do wallet.history().clear() and blow away the record while leaving the balance untouched, breaking the invariant through the back door. Handing out a copy keeps the counter closed even for reads. Returning your internal collection directly is one of the most common leaks I see in interview code.

The point is not "getters and setters are good." The point is that a class exists to own a rule, and encapsulation is the mechanism that lets it. Notice there is no setBalance. A setter for the balance would reopen the exact hole we just closed.

๐ŸŸก Abstraction: Name the Job, Not the Vendor

Encapsulation hides an object's data from its callers. Abstraction hides an object's implementation from its callers by giving them an interface, a named job, to depend on instead.

Say the wallet needs to send an SMS when money arrives. The naive version calls the SMS vendor directly:

// Every class that notifies now knows Gupshup exists.
GupshupSmsClient sms = new GupshupSmsClient(apiKey);
sms.sendSms(user.phone(), "500 credited to your wallet");

The day the company switches from Gupshup to Kaleyra, and Indian companies switch SMS vendors constantly over price, every one of these call sites changes. The abstraction move is to name the job:

interface Notifier {
    void notify(String phone, String message);
}

final class GupshupNotifier implements Notifier {
    private final String apiKey;
    GupshupNotifier(String apiKey) { this.apiKey = apiKey; }

    @Override
    public void notify(String phone, String message) {
        // Gupshup-specific HTTP call lives here, and only here.
    }
}

Now the wallet holds a Notifier, not a GupshupSmsClient. It calls notifier.notify(...) and has no idea which vendor is behind it.

Depend on the job, not the vendor Walletneeds to notify Notifier (interface)notify() GupshupNotifier KaleyraNotifier The wallet calls notify() and never learns the vendor's name. Switching vendors is one new class, not forty edits.

Scroll sideways to read, or tap to zoom

Callers depend on the Notifier contract, not on any vendor's class
100%
Depend on the job, not the vendor Walletneeds to notify Notifier (interface)notify() GupshupNotifier KaleyraNotifier The wallet calls notify() and never learns the vendor's name. Switching vendors is one new class, not forty edits.

Switching to Kaleyra is one new class, KaleyraNotifier implements Notifier, and one line changed at the composition root where the wallet is built. Not forty call sites. The callers never knew the vendor's name, so the vendor's name changing does not touch them. That is what abstraction buys: the caller depends on what is done, not on who does it or how.

In plain words: an interface is a promise about what an object can do, with the how hidden behind it. Depend on the promise. The parking lot lesson's PricingStrategy is the same idea. ParkingLot depends on "something that can price a ticket," not on the specific hourly formula, which is why a festival rate is a new class and not an edit to the lot.

๐Ÿ”ด Architect's Corner: Encapsulation Is Not Ceremony

The most common overcorrection, once a team learns "make fields private," is to slap a getter and a setter on every field and call it encapsulation. It is not. A private field with a public getter and a public setter is a public field with three extra lines. The counter is back, but the pharmacist hands the shelf keys to anyone who asks. If setBalance exists, the balance is not protected, no matter how private the field looks.

Real encapsulation is about which operations you expose, not about the private keyword count. The wallet exposes credit and debit, verbs that keep the invariant, and deliberately does not expose setBalance, a verb that would break it. The question to ask of every setter is: "does exposing this let a caller reach an invalid state?" If yes, it should not exist, and the operation the caller actually wants should be a method that stays valid.

The second senior idea is the Law of Demeter, or "don't talk to strangers." If your code does order.getCustomer().getAddress().getPincode().getZone(), you have reached through four objects, and now your code is coupled to the shape of all four. Change any of them and this chain breaks. The fix is to ask the nearest object for what you actually want: order.deliveryZone(). The Order figures out how to get it. This is encapsulation applied across objects, not just within one. Each object hides not only its own fields but its collaborators' shapes.

The trap on the other side is over-abstraction. An interface with exactly one implementation, and no concrete second one on the horizon, is usually a layer of indirection nobody asked for. Abstraction earns its place when the what genuinely has more than one how, two SMS vendors, two pricing rules, a real fake for tests. Writing interface UserServiceInterface with a single UserServiceImpl behind it, forever, is cargo-culting the shape of abstraction without the benefit. Wait for the second implementation, or a real testing need, before you introduce the interface.

I have seen both failure modes in the same codebase: Wallet with public fields and no protection at all, sitting next to a TaxCalculatorFactoryProvider interface with one implementation that will never have a second. The skill is not "always encapsulate, always abstract." It is knowing which rule needs protecting and which how actually varies.

How This Shows Up in India

Paytm or PhonePe wallet. The balance and the transaction ledger must agree to the paise, always. That invariant lives inside one wallet object with credit and debit doors, not in the forty jobs that touch balances. RBI audits reconcile the ledger; a balance that can be set directly is an audit finding waiting to happen.

Razorpay or PayU gateway switching. Merchants route payments through a gateway abstraction precisely so they can add or swap a provider without rewriting checkout. The PaymentGateway interface names the job, "authorize and capture this amount," and each provider is one implementation behind it.

IRCTC seat state. A seat is available, held, or booked, and the transitions are guarded inside the seat object. If a controller could set seat.status = BOOKED directly, an illegal jump (free to booked, skipping the hold) is one assignment. Encapsulating the transition behind a book() method that checks the current state is what makes that jump impossible to write. Two threads can still both pass the check. That race is a later lesson, and a lock or compare-and-set is what defends it.

The Decision Matrix

Situation The move Why
Two fields must always agree (balance + history) Private fields, one method updates both The invariant is protected in one place, not hoped for in many
A getter would hand out your internal list Return a copy, or an unmodifiable view Otherwise a caller mutates your state through the back door
You are about to write setX Ask if it can create an invalid state; if so, do not Expose the valid operation the caller wants instead
The same job has two vendors or formulas Extract an interface, depend on it Swapping the how never touches the callers
a.getB().getC().getD() Ask the nearest object for the end result Reaching through objects couples you to all their shapes
One implementation, no second in sight Skip the interface for now An interface with one impl forever is indirection nobody asked for

Common Mistakes

  1. "Public fields are fine, everyone on the team is careful." Forty careful callers still means forty chances to forget the second line, and one of them will. The wallet drift bug shipped exactly this way. The invariant must live in the object, not in the discipline of every caller.
  2. "I made the fields private and added getters and setters, so it is encapsulated." A setter that can create an invalid state is a public field with extra steps. Encapsulation is about which operations you expose, not the private count. If setBalance exists, the balance is not protected.
  3. "My getter returns the list, that is read-only enough." Returning the live collection lets a caller clear or mutate it and break your invariant from outside. Return a copy or an unmodifiable view.
  4. "I'll depend on the concrete GupshupSmsClient, it works today." It works until the vendor changes, and then it changes in forty places. Depend on a Notifier interface so the vendor's name never leaks into callers.
  5. "Everything should have an interface, just in case." An interface with one implementation and no second on the horizon is over-abstraction. Introduce it when a second how is real or a test genuinely needs a fake, not before.

๐Ÿง  Key Takeaways

  • A class exists to own a rule (its invariant) and protect it, not merely to hold data. Encapsulation is the mechanism that makes protection possible.
  • Private fields plus intent-methods mean invalid states are impossible to write, not just discouraged. One credit door beats forty hand-written updates.
  • A getter that returns your live collection is a leak. Hand out a copy or an unmodifiable view so reads cannot mutate state.
  • Abstraction names the job so callers depend on the what, not the vendor or the how. Swapping the implementation then never touches the callers.
  • Encapsulation is not the private keyword and abstraction is not one-impl interfaces. Both earn their place by protecting a real invariant or hiding a real variation, and both can be overdone.

Think About It

"A Cart exposes getItems() that returns its internal List<CartItem>. A caller does cart.getItems().clear() in a bug. Whose fault is the design, and what is the fix?" The design's fault. Handing out the live list means the cart no longer controls its own contents, so its total invariant can be broken from outside without the cart knowing. The fix is to return List.copyOf(items) for reads and expose addItem / removeItem methods for changes, so every mutation goes through the counter and the total stays honest.

"When is a plain getter with no logic actually fine?" When the field is genuinely a value with no invariant tying it to anything else, and reading it cannot enable an invalid state. A MenuItem's pricePaise() is fine to expose, because reading a price breaks nothing. The danger is not getters in general, it is getters (and setters) on fields that participate in an invariant, and getters that hand out mutable internals.

"Your team wants a NotificationService interface but there is only ever going to be email. Do you add the interface?" Not yet. One implementation with no realistic second and no testing need is indirection for its own sake. If tests want to assert "a notification was sent" without hitting a real mail server, that is a real second implementation (a fake), and then the interface earns its place. Absent that, a concrete class is honest, and the interface can be extracted the day a second reason appears.

Further Reading

Full quizzes, answers, progress, case studies and interview problems available in the paid path.