Encapsulation and abstraction

Part of the free Low-Level Design course on LogicWiz, in the chapter: Object-oriented basics, the way interviewers check them. The class-diagram exercises run in the app.

Week 2: The wallet that went negative

"Anyone in the codebase can write wallet.balancePaise = -48000, and your class will accept it."

Priya, reviewing a pull request


A wallet with a public balance

SwiftKart has a wallet. Customers top it up, pay from it and receive refunds into it. Arjun's first version was short.

public class Wallet {
    public long balancePaise;
}

// in RefundService
wallet.balancePaise += refundPaise;

// in CheckoutService
wallet.balancePaise -= orderPaise;

It compiled and the tests passed. Then a bug in checkout charged one order twice. A customer's balance became minus ₹480. Nobody could say which line did it, because twelve classes wrote to balancePaise and any of them could have.

The business has a rule: a wallet balance can never go negative. A rule that must always be true about an object is called an invariant. With a public field, no single place enforces the invariant. Every caller has to remember it, and one caller forgot.

This lesson fixes that with two ideas. Encapsulation protects an object's data and rules. Abstraction hides how a job is done from the code that asks for it.

Private state guards a rule

Encapsulation means keeping an object's data private and letting other code change it only through the object's methods. Those methods check the rules. Here is Priya's version.

public class Wallet {
    private long balancePaise;

    public long balancePaise() {
        return balancePaise;
    }

    public void credit(long amountPaise) {
        if (amountPaise <= 0) throw new IllegalArgumentException("Credit must be positive");
        balancePaise += amountPaise;
    }

    public void debit(long amountPaise) {
        if (amountPaise <= 0) throw new IllegalArgumentException("Debit must be positive");
        if (amountPaise > balancePaise) throw new IllegalStateException("Insufficient balance");
        balancePaise -= amountPaise;
    }
}

Now the invariant lives in one place. No caller can make the balance negative, even by mistake. If the rule changes, for example to allow a ₹50 overdraft for loyal customers, you edit one method.

Arjun objected. "I always generate a getter and a setter for every field. Isn't that encapsulation?" Priya showed him what a setter does here. A setBalancePaise(long b) method lets any caller set any value, including minus 48,000 paise. The field is private, but the rule is not protected.

A getter and a setter for every field give you a public field with more typing. Encapsulation is about which operations exist, not about the keyword private.

The same applies to lists. If Cart returns its internal List<CartItem>, any caller can add items and skip the cart's checks. Return a read-only copy with List.copyOf(items), and offer methods such as addItem for changes.

💡 Tip: Add a setter only when "set this to any value" is a real business action. For a balance, the real actions are credit and debit.

Tell, don't ask

Here is code Arjun found in checkout.

if (wallet.getBalancePaise() >= orderPaise) {
    wallet.setBalancePaise(wallet.getBalancePaise() - orderPaise);
    order.markPaid();
}

Checkout asks the wallet for its data, makes the decision itself, then pushes the result back. The rule "never go below zero" now lives in checkout, and probably in five other places too.

"Tell, don't ask" is a guideline for this. Tell an object to do the work. Do not ask for its data and do the work outside it.

wallet.debit(orderPaise);   // throws if the balance is too low
order.markPaid();

The decision moved back inside Wallet, next to the data it needs. Interviewers notice this. Code that tells objects what to do has fewer getters, shorter methods and fewer places for a rule to hide.

There is a second benefit. When two requests debit the same wallet at the same moment, the check and the update must happen together. With debit inside Wallet, you can make that one method safe for threads later, in Module 10. With the check copied into five callers, you must fix five places.

Abstraction: show what, hide how

Encapsulation hides an object's data. Abstraction hides how a job is done, behind a simple description of what it does. In Java the usual tool is an interface: a type that lists methods a class must provide, without saying how.

SwiftKart takes payment by wallet, UPI and card. Checkout does not need to know how each one works.

public interface PaymentMethod {
    Receipt pay(long amountPaise);
}

public class WalletPayment implements PaymentMethod {
    private final Wallet wallet;

    public WalletPayment(Wallet wallet) { this.wallet = wallet; }

    @Override
    public Receipt pay(long amountPaise) {
        wallet.debit(amountPaise);
        return new Receipt("WALLET", amountPaise);
    }
}

public class Checkout {
    public Receipt complete(Order order, PaymentMethod method) {
        return method.pay(order.totalPaise());
    }
}

Checkout sees one method: pay. It does not know about wallet balances, UPI ids or card tokens. When SwiftKart adds "pay later", someone writes a PayLaterPayment class, and Checkout does not change.

The two ideas work together. Encapsulation protects the rules inside one class. Abstraction lets other classes depend on a small promise instead of the details. Interviewers often ask for the difference in one line. Encapsulation protects data and rules inside a class. Abstraction lets callers use a class without knowing how it works.

In a machine-coding round, abstraction pays off at the "now add X" moment. The interviewer says "add cash on delivery". With the interface, you write CashOnDeliveryPayment, implement pay and show it in main. Without it, you edit the if chain in checkout and risk breaking UPI.

Do not add an interface for every class. Wallet itself has one form, so it needs no IWallet. An interface earns its place when there are two or more ways to do a job, or when you need to replace the real class with a fake one in tests.

Try it: lock the wallet, then hide the payment

First, fix the wallet. Its balance is public and it has a setter. Make the field private, and replace the setter with methods that keep the balance from going negative.

Next, Checkout is tied to two concrete payment classes. A concrete class is one you can create with new. Put an interface between them, so checkout depends only on what a payment does.

📌 Summary: Keep fields private and expose actions, such as debit and credit, that guard the invariants. Tell objects what to do. Hide how a job is done behind a small interface.

What Arjun learns next

Arjun now has WalletPayment and UpiPayment behind one interface. He wonders whether they should extend a shared base class instead. Next lesson: inheritance, composition and polymorphism.

Check your understanding

  1. Wallet has a private balancePaise field with a public getter and a public setBalancePaise(long). Is the "never negative" rule protected?
  2. Checkout runs `if (w.getBalancePaise() >= amt) w.setBalancePaise(w.getBalancePaise() - amt);`. What is the best change?
  3. Which of these describes abstraction rather than encapsulation?
  4. Arjun adds an IWallet interface with one implementation, Wallet, "for good design". What would Priya say?