C++ Encapsulation and Class Invariants: Build Objects That Stay Valid

Learn to design a C++ BankAccount that cannot be created or changed into an invalid state. Trace constructor checks, guarded operations, representation leaks, and boundary tests.

KnowledgeGate Team

Exam prep & CS education

Updated 17 Sep 20265 min read

Changing a C++ field from public to private may answer a syntax question, but it does not automatically make the class safe. A constructor or method can still create an impossible object. The BankAccount requires a non-empty owner and a balance that never falls below its minimum. Using paise makes each state change explicit. The Coding & Skill Development Courses category places this design skill in a broader programming path.

1. Encapsulation is a validity boundary, not merely private

Encapsulation controls how state is created, observed and changed. A private field stops callers from editing the representation directly, but the public interface must still enforce the class rules.

Suppose balancePaise_ is private, while the class exposes void withdraw(std::int64_t amount) { balancePaise_ -= amount; }. Starting from 135000, withdrawing 50000 produces 85000, even though the required minimum is 100000. The representation is hidden, but the object is invalid.

The stronger design question is not, "Which fields are private?" It is, "Which states may this object represent?" If you first need the broader sequence of classes, methods and access control, use the C++ Tutorial. Every reachable state remains valid.

2. Write class invariants and method contracts before code

For every live BankAccount, we choose these invariants:

  • owner_ != ""

  • minimumBalancePaise_ >= 0

  • balancePaise_ >= minimumBalancePaise_

"Non-empty" is the invariant used here. Production identity rules may also reject whitespace-only or unnormalised names.

An invariant must hold between public calls. A precondition states what an operation accepts, such as amountPaise > 0 for deposits and withdrawals. A postcondition states the result. A successful deposit adds exactly the requested amount. A successful withdrawal subtracts exactly that amount. A refused withdrawal leaves the state unchanged.

The constructor must establish all three invariants, and every public mutator must preserve them. This is the useful design meaning of encapsulation in object-oriented code.

Diagram of a BankAccount validity boundary: private fields, three class invariants, and accepted versus rejected constructor calls.

3. Make the constructor establish a valid object

Use std::string owner_, std::int64_t balancePaise_ and const std::int64_t minimumBalancePaise_ as the representation. Integer paise makes each comparison exact and avoids floating-point rounding in the state trace.

The constructor initialises every field, then checks the rules in a deliberate order:

cpp
BankAccount(std::string owner,
            std::int64_t openingPaise,
            std::int64_t minimumPaise)
    : owner_(std::move(owner)),
      balancePaise_(openingPaise),
      minimumBalancePaise_(minimumPaise) {
    if (owner_.empty())
        throw std::invalid_argument("owner must not be empty");
    if (minimumBalancePaise_ < 0)
        throw std::invalid_argument("minimum must not be negative");
    if (balancePaise_ < minimumBalancePaise_)
        throw std::invalid_argument("opening balance is below minimum");
}

BankAccount account{"Asha", 150000, 100000}; succeeds. BankAccount{"", 150000, 100000} fails the owner check, while BankAccount{"Asha", 80000, 100000} fails the balance check. In both rejected cases, construction throws before a usable object exists.

That is safer than default-constructing an empty account and hoping callers invoke setters in the correct order. A normal object should never mean "temporarily invalid, repair me later." Either construction establishes the invariant, or no object is created.

4. Preserve the invariant through deposit and withdrawal

deposit rejects non-positive input and checks for overflow before adding. withdraw rejects non-positive input, but treats insufficient headroom as an ordinary refused request. It compares the amount with existing headroom before subtraction, so it never calculates and stores an invalid balance.

cpp
void deposit(std::int64_t amountPaise) {
    if (amountPaise <= 0)
        throw std::invalid_argument("deposit must be positive");
    if (amountPaise >
        std::numeric_limits<std::int64_t>::max() - balancePaise_)
        throw std::overflow_error("balance cannot represent deposit");
    balancePaise_ += amountPaise;
}

bool withdraw(std::int64_t amountPaise) {
    if (amountPaise <= 0)
        throw std::invalid_argument("withdrawal must be positive");
    if (amountPaise > balancePaise_ - minimumBalancePaise_)
        return false;
    balancePaise_ -= amountPaise;
    return true;
}

Do not silently clamp a request or mix rupees and paise. std::invalid_argument reports a broken input contract, std::overflow_error reports an unrepresentable deposit, and false reports the expected case where the account lacks withdrawal headroom.

Call

Before

Decision

After

Invariant

Construct with 150000

No object

Accept

150000

150000 >= 100000

deposit(75000)

150000

Add 75000

225000

225000 >= 100000

withdraw(90000)

225000

Headroom 225000 - 100000 = 125000, return true

135000

135000 >= 100000

withdraw(50000)

135000

Headroom 135000 - 100000 = 35000, return false

135000

135000 >= 100000

The sequence has small-scale atomicity: validate first, then mutate once. A rejected call produces no partial state change.

State trace of a BankAccount across deposit and withdraw calls, showing balances, headroom checks, and one refused withdrawal.

5. Close representation leaks and keep observations read-only

Read-only accessors can expose useful facts without exposing writable state:

cpp
std::int64_t balancePaise() const noexcept;
std::int64_t minimumBalancePaise() const noexcept;
const std::string& owner() const noexcept;

The trailing const promises that the member function will not change the object through this interface. Returning the owner by const reference avoids a copy while preventing callers from changing it through the returned reference.

Never expose std::int64_t& balancePaise(). It would allow account.balancePaise() = -1;, bypassing every guard. A universal setBalance has the same design smell because it describes arbitrary storage replacement, not a valid domain operation.

Making minimumBalancePaise_ a const data member reduces the proof burden. If requirements later allow that threshold to change, add a dedicated checked operation with its own preconditions and postconditions.

6. Common invariant traps and how to test them

Trap

What breaks

Better design

Check only in the constructor

An unchecked withdrawal can turn 135000 into 85000

Guard every mutator

Mutate before validation

A rejected withdraw(50000) may leave partial change

Check 35000 headroom first

Return a mutable reference or broad setter

A caller can assign -1 directly

Offer const observations and domain operations

Check overflow after addition

Signed overflow may occur before the check

Compare against max() - balancePaise_ first

Test boundaries, not only comfortable values. Opening at 100000 with a 100000 minimum succeeds; opening at 99999 fails. deposit(1) succeeds, while deposit(0) throws. From 135000, withdraw(35000) succeeds and reaches exactly 100000. A following withdraw(1) returns false and leaves 100000 unchanged.

For a property-style test, generate valid operation sequences and assert after every operation that !owner().empty() and balancePaise() >= minimumBalancePaise(). Such tests probe many paths, but they support the class design rather than replacing it.

7. How exams and interviews test encapsulation

Common questions ask you to predict an exception or output, identify the method that can break private state, choose the constructor that prevents invalid objects, or explain why a mutable-reference getter defeats encapsulation. Trace the contract and resulting state instead of guessing from a method's name.

For the worked sequence, construction sets 150000, deposit(75000) produces 225000, withdraw(90000) returns true and produces 135000, and withdraw(50000) returns false. The final balance is 135000 paise. The tempting wrong answer is 85000, which assumes the rejected call still changes state.

Use OOP for Teaching CS Exams for adjacent revision. For interview-oriented follow-through across several languages and competitive coding, see Coding For Placements.

8. C++ class invariants: the short version and next step

Remember this retrieval chain:

  1. State the invariant.

  2. Establish it in every constructor.

  3. Preserve it in every mutator.

  4. Expose observations without writable representation leaks.

For a five-minute exercise, add changeMinimumBalance(std::int64_t newMinimumPaise) and write the invariant first. Reject a negative minimum or one above the current balance, and check before storing so failure leaves the object unchanged. Then use the C++ Programming course as a structured next step when you want guided practice.