Skip to content
Search lessons, topics, tests…
Esc

    ↑ ↓ moveEnter openEsc close

    Module 4 · 4. Object-Oriented Design, Records, Interfaces, and SOLID · Lesson 10 of 24

    Classes, Encapsulation, and Maintaining Valid State

    Learning outcomes

    Design a class around valid states and permitted transitions, find an exception-ordering bug, and choose between a live read-only view and a detached snapshot.

    This is a single-threaded, in-memory teaching model using invented balances and entries. It is not banking, payment, or financial guidance. Currency units, rounding policy, persistence, authorization, concurrency, fees, and real account operations are outside its scope.

    Start with the contract

    A private field is a boundary, not a proof of correctness. For this account, construction starts at balance zero with no entries. A successful deposit records one positive delta; a successful withdrawal records one negative delta. Balance must agree with replaying accepted deltas in order using the same decimal arithmetic. The basic account must never end an operation with a negative balance.

    A rejected input or an arithmetic overflow must not record an entry or change the balance. These are transition rules: inspect both the result and what changed. Merely checking the final balance misses half the contract. Decimal is finite-precision; this lesson does not promise exact mathematical sums for arbitrary magnitudes or supply a currency rounding policy.

    Find the original bug

    The original deposit appended the amount before calculating the new balance:

    C#
    _entries.Add(amount);
    Balance += amount;

    After depositing decimal.MaxValue into an empty account, depositing 1m cannot produce a representable balance. Decimal addition throws OverflowException. But the second entry has already been appended. The balance stays at its previous value while the history now includes a deposit that failed. Making both fields private did not prevent this method from breaking its own contract.

    The baseline test intentionally preserves this defect. Its expected result is an overflow with two entries. That is evidence of corruption, not a behavior to keep.

    Repair the transition order

    Validate first, calculate the candidate balance without mutation, then append the entry and assign the already-computed value:

    Complete runnable example. Use a separate .NET 10 console project for this example and replace its Program.cs with the listing below. Run it with dotnet run. No extra packages are needed. The assertions throw if a required state check fails.

    C#
    using System;
    using System.Collections.Generic;
    using System.Globalization;
    CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
    var account = new BankAccount();
    account.Deposit(decimal.MaxValue);
    try { account.Deposit(1m); }
    catch (OverflowException) { Console.WriteLine("deposit: OverflowException"); }
    Require(account.Balance == decimal.MaxValue && account.Entries.Count == 1);
    Console.WriteLine($"unchanged after overflow: {account.Balance == decimal.MaxValue && account.Entries.Count == 1}");
    var small = new BankAccount();
    small.Deposit(12m);
    Require(small.TryWithdraw(12m));
    Require(!small.TryWithdraw(1m) && !small.TryWithdraw(0m) && !small.TryWithdraw(-1m));
    try { small.Deposit(0m); }
    catch (ArgumentOutOfRangeException) { Console.WriteLine("zero deposit: rejected"); }
    try { small.Deposit(-1m); }
    catch (ArgumentOutOfRangeException) { Console.WriteLine("negative deposit: rejected"); }
    Require(small.Balance == 0m && small.Entries.Count == 2);
    Require(small.Entries[0] == 12m && small.Entries[1] == -12m);
    Console.WriteLine($"full withdrawal: balance={small.Balance}, entries={small.Entries.Count}");
    Console.WriteLine("rejected withdrawals: unchanged");
    
    static void Require(bool condition)
    {
        if (!condition) throw new Exception("Assertion failed");
    }
    
    public sealed class BankAccount
    {
        private readonly List<decimal> _entries = [];
        public decimal Balance { get; private set; }
        public IReadOnlyList<decimal> Entries => _entries.AsReadOnly();
        public IReadOnlyList<decimal> GetEntriesSnapshot() =>
            Array.AsReadOnly(_entries.ToArray());
    
        public void Deposit(decimal amount)
        {
            if (amount <= 0) throw new ArgumentOutOfRangeException(nameof(amount));
            decimal nextBalance = Balance + amount;
            _entries.Add(amount);
            Balance = nextBalance;
        }
    
        public bool TryWithdraw(decimal amount)
        {
            if (amount <= 0 || amount > Balance) return false;
            decimal nextBalance = Balance - amount;
            _entries.Add(-amount);
            Balance = nextBalance;
            return true;
        }
    }

    Verified output (.NET 10):

    Text
    deposit: OverflowException
    unchanged after overflow: True
    zero deposit: rejected
    negative deposit: rejected
    full withdrawal: balance=0, entries=2
    rejected withdrawals: unchanged

    For Deposit, overflow now occurs before the append. For TryWithdraw, the guard rejects non-positive amounts and amounts above the current balance; subtracting an accepted amount leaves a nonnegative candidate. Appending precedes the simple balance assignment, so a failure to grow the list does not first publish a new balance. Do not insert callbacks, logging that can throw, or more fallible work between the append and assignment.

    This fixes the demonstrated exception-ordering defect. It is not an atomic transaction for concurrent readers, a thread-safety guarantee, crash recovery, or durable storage. A concurrent call could observe or produce inconsistent state. Resource exhaustion and process failure are not being offered as recoverable business outcomes.

    The API deliberately uses two failure styles: invalid deposits throw, while an ordinary withdrawal rejection returns false. Callers should know that TryWithdraw does not promise to suppress every possible runtime failure. A different application can choose a richer result type, but its failure contract should be explicit.

    Read-only is not a moment-in-time snapshot

    Entries returns a wrapper over the account's list. Callers cannot edit through that wrapper, but a wrapper saved before another deposit sees the new entry. Returning the list itself under an IReadOnlyList type would expose an object that could be cast back to List and mutated.

    GetEntriesSnapshot first copies the entries into a new array and wraps that private copy. Subsequent deposits do not affect it, and its public collection interface rejects writes. Because the elements here are decimal values and the mutable copy is not exposed, this is an immutable snapshot through the ordinary public API. It is not a claim that every read-only collection, or a collection of mutable objects, is deeply immutable.

    C#
    var account = new BankAccount();
    account.Deposit(10m);
    var live = account.Entries;
    var snapshot = account.GetEntriesSnapshot();
    account.Deposit(5m);
    // Expected: live.Count == 2; snapshot.Count == 1.

    Solved practice: an explicit overdraft policy

    Use these synthetic rules: the constructor accepts a nonnegative, fixed limit; the initial balance is zero; withdrawals must be positive; a resulting balance exactly equal to the negative limit is allowed. Reject a withdrawal below that floor, or one whose candidate calculation overflows, without changing either field. Deposits remain positive-only and may improve a negative balance. There are no fees, interest, pending operations, or changes to the limit.

    The constructor establishes a usable object or throws before it can be returned. There is no public balance setter or limit setter that could bypass the policy.

    Complete runnable example. Use a separate .NET 10 console project for this example and replace its Program.cs with the listing below. Run it with dotnet run. No extra packages are needed. The assertions throw if a required state check fails.

    C#
    using System;
    using System.Collections.Generic;
    using System.Globalization;
    CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
    var account = new OverdraftAccount(20m);
    account.Deposit(30m);
    Require(account.TryWithdraw(50m));
    Console.WriteLine($"at limit: balance={account.Balance}, entries={account.Entries.Count}");
    Require(!account.TryWithdraw(1m));
    Require(!account.TryWithdraw(0m) && !account.TryWithdraw(-1m));
    Require(account.Balance == -20m && account.Entries.Count == 2);
    Console.WriteLine("beyond limit / zero / negative: unchanged");
    account.Deposit(7m);
    Require(account.Balance == -13m && account.Entries.Count == 3);
    Console.WriteLine($"deposit while negative: balance={account.Balance}");
    Require(account.TryWithdraw(7m));
    Require(account.Balance == -20m && account.Entries.Count == 4);
    Console.WriteLine($"limit reused: balance={account.Balance}");
    Require(account.Entries[0] == 30m && account.Entries[1] == -50m &&
            account.Entries[2] == 7m && account.Entries[3] == -7m);
    var zero = new OverdraftAccount(0m);
    Require(!zero.TryWithdraw(1m) && zero.Entries.Count == 0);
    Console.WriteLine("zero limit: withdrawal rejected");
    try { _ = new OverdraftAccount(-1m); }
    catch (ArgumentOutOfRangeException) { Console.WriteLine("negative limit: constructor rejected"); }
    var extreme = new OverdraftAccount(decimal.MaxValue);
    Require(extreme.TryWithdraw(decimal.MaxValue));
    Require(!extreme.TryWithdraw(1m));
    Require(extreme.Balance == -decimal.MaxValue && extreme.Entries.Count == 1);
    Console.WriteLine("underflow withdrawal: rejected unchanged");
    var full = new OverdraftAccount(20m);
    full.Deposit(decimal.MaxValue);
    try { full.Deposit(1m); }
    catch (OverflowException) { Console.WriteLine("overflow deposit: rejected unchanged"); }
    Require(full.Balance == decimal.MaxValue && full.Entries.Count == 1);
    
    static void Require(bool condition)
    {
        if (!condition) throw new Exception("Assertion failed");
    }
    
    public sealed class OverdraftAccount
    {
        private readonly List<decimal> _entries = [];
        public decimal Balance { get; private set; }
        public decimal OverdraftLimit { get; }
        public IReadOnlyList<decimal> Entries => _entries.AsReadOnly();
    
        public OverdraftAccount(decimal overdraftLimit)
        {
            if (overdraftLimit < 0)
                throw new ArgumentOutOfRangeException(nameof(overdraftLimit));
            OverdraftLimit = overdraftLimit;
        }
    
        public void Deposit(decimal amount)
        {
            if (amount <= 0) throw new ArgumentOutOfRangeException(nameof(amount));
            decimal nextBalance = Balance + amount;
            _entries.Add(amount);
            Balance = nextBalance;
        }
    
        public bool TryWithdraw(decimal amount)
        {
            if (amount <= 0) return false;
            decimal nextBalance;
            try { nextBalance = Balance + (-amount); }
            catch (OverflowException) { return false; }
            if (nextBalance < -OverdraftLimit) return false;
            _entries.Add(-amount);
            Balance = nextBalance;
            return true;
        }
    }

    Verified output (.NET 10):

    Text
    at limit: balance=-20, entries=2
    beyond limit / zero / negative: unchanged
    deposit while negative: balance=-13
    limit reused: balance=-20
    zero limit: withdrawal rejected
    negative limit: constructor rejected
    underflow withdrawal: rejected unchanged
    overflow deposit: rejected unchanged

    Work the boundary cases

    1. Limit 20, deposit 30, withdraw 50: succeeds at balance -20, with entries [30, -50]. Equality with the floor is allowed.
    1. From -20, withdraw 1: returns false; balance and the two entries stay unchanged. Zero and negative withdrawal requests also return false without an entry.
    1. Deposit 7: balance becomes -13. Withdraw 7: succeeds back at -20. The final entries are [30, -50, 7, -7].
    1. Limit zero, starting at zero: withdrawing 1 fails. A negative constructor limit throws instead of creating a usable invalid account.
    1. Limit decimal.MaxValue, withdraw decimal.MaxValue from zero: succeeds at the negative limit. A further withdrawal of 1 would underflow; the narrow arithmetic catch returns false before mutation.
    1. Deposit decimal.MaxValue into an empty account, then deposit 1: the second call throws, with one entry and the original balance preserved.

    The narrow catch surrounds only candidate arithmetic. Do not wrap the entire method in catch-all logic and return false after partially changing state.

    Test the contract, not just the happy path

    The two complete examples above can be run independently and include assertions for successful, rejected, and boundary transitions. Check both balance and entry contents/count after each operation. The short baseline and snapshot listings illustrate the reasoning; they are excerpts, not separate complete programs. Both complete examples were compiled and run on .NET 10. Their output matched the transcripts above, and their assertions passed.

    Interview check

    Question: How did encapsulation prevent a bug here?

    Worked answer: It gave the account one place to enforce a transition contract. The first implementation still violated that contract because a fallible calculation ran after a mutation. Moving the calculation before the append fixes the demonstrated overflow path, and tests verify that rejected operations leave both representations unchanged. Read-only exposure also prevents ordinary callers from appending entries behind the account's back. Neither measure makes the object concurrent or durable.

    Separate data objects can be appropriate. The useful design question is who owns the invariant and whether every supported write goes through that owner, not whether every class contains business methods.

    Analogy

    Everyday picture

    At a club game, one scorekeeper controls a score display and a record of accepted score changes. The display has limited room for digits. If the keeper records an increase before checking that the new score will fit, the record can claim a change that the display cannot represent. Keeping spectators away from the pen prevents outside edits; it does not prevent the scorekeeper's own mistake.

    Mapping. The scorekeeper represents the account as owner of its transition rules; the display and record represent Balance and the entries. Working out the proposed score before recording the change mirrors calculating nextBalance before appending. Controlling the pen resembles restricting public writes, which cannot by itself ensure correct operation order.

    Where it stops. The display's digit limit is only a stand-in for decimal overflow. Computing first fixes the demonstrated arithmetic-failure path; it does not make the object's updates atomic for concurrent readers or durable across a crash.

    Cheat sheet (PDF)

    csharp-encapsulation-state-transitions-companion.pdf11 pages · 80 KB
    Every page, in this page.

    Practice

    Sign in to mark lessons done and keep your place in the course.Sign in