Java Modifiers and Encapsulation
A valid constructor is not enough if callers can break the state afterward. Keep representation private, expose meaningful operations, and prove that every accepted or rejected request preserves the object's rules.
Before you start: review validated constructors, instance members and rejected updates, and copied references and mutation. Every example is a complete, editable Java 21 program. Use Run code; no input or external services are needed.
What you will be able to do
- Explain encapsulation as control over valid state, not just hidden fields.
- Distinguish public, private, package access, and protected at an introductory level.
- Use a constructor and controlled methods to preserve a stated invariant.
- Expose useful queries without creating unrestricted write paths.
- Reject invalid deposits and withdrawals without mutation or overflow.
- Recognize aliases and mutable-array leaks, and test the public contract.
Begin with the rule the object must always preserve
The previous lesson validated an object's starting values. That is only the beginning: if a caller can assign a negative balance afterward, construction did not protect the object's lifetime. This lesson adds a boundary around state so every supported change must follow the same rules.
Our wallet's invariant is simple: its owner is a stripped, nonblank String and its balance stays between zero and 1,000,000 cents inclusive. An invariant is a condition that should hold after successful construction and after each completed public operation. A rejected request must leave the earlier valid state intact.
This is a fictional single-threaded console model using whole cents. It does not move money, authorize users, store transactions, handle currency conversion, or connect to a payment provider. Encapsulation is a programming technique, not a claim that this tiny model is suitable for real financial software.
Main.java · An exposed field bypasses every intended rule
Predict the result, edit Main.java, then run your program.
Standard input · optional
Select Run code to compile and execute this program.
Invalid balance accepted: -500
Access modifiers describe which code may use a declaration
public makes a member available wherever the containing type is accessible. private restricts access to its permitted enclosing top-level class context; in our example, the separate top-level Main cannot directly use Wallet's private fields. Omitting an access modifier gives package access, often called package-private; it does not mean public.
protected allows same-package access and certain subclass access outside the package, with additional rules for cross-package instance access. It does not simply mean subclasses only. We will use inheritance later; for now, prefer private implementation details and a small intentional public contract rather than using protected as a general escape hatch.
These are member-level choices, distinct from static ownership and final assignment rules. Our file contains public Main and a package-private top-level Wallet. Public methods do not make Wallet itself public outside its package. Ordinary top-level classes can be public or package-accessible, not private or protected; multi-file package organization comes next.
Keep representation private and expose only deliberate observations
Wallet declares private final String owner and private long balanceCents. Main cannot compile a direct balance assignment or call the private normalizeOwner helper. The class can use those details internally while callers use the constructor, getOwner, getBalanceCents, deposit, and withdraw.
final prevents owner from being reassigned after construction, while private prevents unrelated caller code from assigning it directly. Neither word makes the whole Wallet immutable: balance changes through accepted operations. The public static final limit is a shared primitive policy value; it does not expose writable balance storage.
getBalanceCents returns a primitive long value, so the caller receives a copy, not a route for assigning the field. getOwner returns a String, which is immutable. These particular getters therefore do not expose mutable internal storage. That conclusion depends on the returned type and implementation, not just on the method being called a getter.
Main.java · Read through the public contract
Predict the result, edit Main.java, then run your program.
Standard input · optional
Select Run code to compile and execute this program.
Owner: Mina
Balance cents: 1200
Limit cents: 1000000
Try an intentional compilation error
In the example's main method, add wallet.balanceCents = -1;. It must fail compilation because Main and Wallet are separate top-level classes. Remove the line and call a public operation instead. The correct repair is not to make the field public.
Expose meaningful actions instead of a setter for every field
deposit(300) and withdraw(500) describe intentions. An unrestricted setBalanceCents(...) would let a caller erase the current balance or bypass the operation rules. Making the field private and then adding a setter that blindly assigns any number would move the same bug into a method rather than fix it.
Both action methods return true for an accepted positive amount and false for a rejected request. The result reports whether the action was applied, not the balance afterward. Queries return the later balance separately. A zero amount is a rejected no-op under this exercise's explicit contract.
The methods do not print, request console input, or change an owner. Main handles output and chooses request order. This keeps the class usable from a different caller without coupling its state rules to one display. A small public API is useful when each operation has a clear purpose and a testable contract.
Main.java · Ask for a deposit or withdrawal
Predict the result, edit Main.java, then run your program.
Standard input · optional
Select Run code to compile and execute this program.
Deposit: true
Withdraw: true
Balance cents: 1000
Check before mutation, including the arithmetic boundary
withdraw first rejects nonpositive amounts and amounts greater than the current balance. Only then does it subtract. With a valid nonnegative balance, those checks ensure an accepted withdrawal cannot make it negative. There is no partial subtraction to undo on the rejected path.
deposit checks amount against MAX_BALANCE_CENTS minus balanceCents before adding. That remaining capacity is safe to calculate because the balance invariant already bounds it. This avoids adding two long values first and discovering an overflow too late. Even Long.MAX_VALUE is rejected without wrapping or changing the balance.
The constructor rejects invalid opening state with IllegalArgumentException, while the two normal action methods report rejected requests with false. These are deliberately different documented contracts. Catching a constructor rejection was introduced in the previous lesson; full exception design comes later. No method silently clamps an amount or invents a replacement operation.
Main.java · Rejection leaves the valid balance unchanged
Predict the result, edit Main.java, then run your program.
Standard input · optional
Select Run code to compile and execute this program.
Overdraw: false
Zero deposit: false
Negative withdrawal: false
Huge deposit: false
Balance cents: 1000
Trace a rejected withdrawal and a full-capacity deposit
- At 1000 cents, withdrawing 1001 fails the amount-versus-balance check.
- The method returns false before any subtraction; the balance remains 1000.
- Remaining capacity is 1,000,000 minus 1000, or 999,000 cents.
- Depositing exactly 999,000 succeeds and reaches the maximum.
- Depositing one more cent fails without changing the maximum balance.
Always inspect both the return value and the stored result. A false return paired with a changed balance is still a bug.
Encapsulation controls access; it does not merge instances
Two new Wallet expressions create two independent objects, each with its own private fields. Withdrawing from first changes only first's balance. Sharing one class and one limit does not share balance storage; making balance static would still break this model even if the field remained private.
Private access is a rule about code locations, not a rule that each object lives in an isolated universe. Methods defined within a class can work with another instance's private data when the access rules allow it. Our public API deliberately avoids cross-wallet transfers so each action's effect stays easy to trace.
Test independence through public observations: record both balances, change one, then query both. Matching owners or starting amounts do not imply shared identity. If an unexpected second report changes, investigate reference aliases and static fields before weakening access restrictions.
Main.java · Private instance fields still belong to separate objects
Predict the result, edit Main.java, then run your program.
Standard input · optional
Select Run code to compile and execute this program.
Mina: 700
Omar: 400
An alias can still call the same public methods
Wallet desk = original copies a reference. desk.withdraw(300) invokes a valid public operation on the same object original refers to, so both names observe the resulting balance. private prevents bypassing the field through ordinary caller code; it does not prevent public actions through another reference.
Do not confuse encapsulation with authentication, authorization, encryption, or a thread-safety guarantee. This model has no concept of who is allowed to withdraw. In a real application, permissions and transaction coordination would require additional boundaries outside the access modifier.
Similarly, giving a caller a reference grants access to the public API that reference exposes. If a caller should only observe a real object, a separate read-only abstraction could be appropriate. That is a later design topic; this lesson focuses on preserving valid state under the documented operations.
Main.java · Private fields do not prevent aliases
Predict the result, edit Main.java, then run your program.
Standard input · optional
Select Run code to compile and execute this program.
Same object: true
Original sees: 700
A query should not change state or act like a live field binding
The saved long before is a snapshot of the balance at the time the getter returned. A later deposit changes the Wallet, not that earlier primitive value. Reading the balance twice after the deposit produces the same current value without consuming money or modifying any state.
Read-only behavior is a method contract, not something Java enforces because the method name begins with get. A broken getter could mutate a field and still compile. Tests should therefore inspect repeated observations, the owner, and subsequent action outcomes rather than assume getters are automatically safe.
Separating queries from actions makes changes easier to diagnose. If a rejected request leaves a different balance, the action is wrong. If a stored snapshot stays old, that is expected. If just reading changes the balance, the query violates its contract even when the first printed line looks correct.
Main.java · A returned primitive is a snapshot
Predict the result, edit Main.java, then run your program.
Standard input · optional
Select Run code to compile and execute this program.
Saved cents: 1000
Current cents: 1250
Read again: 1250
A private array is still exposed if you return its reference
ReadingList shows a different hazard. Declaring its array private final does not freeze the array elements. If the constructor stores the caller's array reference directly, the caller can later edit internal contents through the original reference. If getTitles returns the internal array, it creates a second write path through the getter.
The example copies the array on input and returns a fresh array on output. Editing source and received therefore does not alter the stored titles. Arrays.copyOf creates a new array; it is a shallow copy. That is sufficient for this demonstration because the element values are Strings, whose contents are immutable.
For arrays of mutable objects, copying only the outer array does not copy the element objects. Choose a contract appropriate to the data rather than calling any copied array deeply immutable. This example permits empty arrays and null title elements; its limited goal is isolation of array-slot changes, not a full reading-list validation policy.
Main.java · Copy mutable arrays on input and output
Predict the result, edit Main.java, then run your program.
Standard input · optional
Select Run code to compile and execute this program.
[Java, SQL]
Predict the two missing-copy bugs
If the constructor stores titles directly, changing source[0] changes internal state. If the getter returns titles directly, changing received[1] changes it through a second path. The example closes both paths by copying the outer array on input and output.
Try removing one copy at a time and predict which outside assignment appears in the final report. Restore both copies before moving on.
Test the public contract and the forbidden write paths
Begin with valid construction at zero and at the maximum, then invalid opening amounts and blank owners. For each action, test positive, zero, negative, exact-boundary, just-outside, and extreme long amounts. Check both the returned boolean and the balance that remains.
Verify repeated operations and independent instances. A successful full withdrawal should reach exactly zero; the next positive withdrawal should fail without change. A deposit that exactly fills remaining capacity should succeed, while one extra cent should fail. Owner queries must keep the original normalized owner throughout.
Finally verify access restrictions with small intentional compilation failures: unrelated Main code cannot assign a private balance or call the private helper. Repair the caller by using the public API, not by making the field public. Also test copied-array boundaries separately, since a passing wallet test says nothing about another class's mutable references.
Repair exercise: private storage, unsafe public setter
The field is private, but the public setter still accepts a negative balance. Repair the setter's contract: reject values below zero without mutation, otherwise assign the value and return true. This small repair illustrates why access control alone is insufficient; the final wallet uses deposit and withdraw instead of a balance setter.
Main.java · Find the missing validation boundary
Predict the result, edit Main.java, then run your program.
Standard input · optional
Select Run code to compile and execute this program.
The starter compiles but currently prints an invalid state:
Accepted: true
Balance cents: -1
Reveal the explained repair
private blocks the direct write from Main, but the original setter performed the same invalid assignment on its behalf. The repair checks first and assigns only on success. A validated setter can enforce a simple value range, but the final model exposes meaningful actions so callers cannot arbitrarily replace the accumulated balance.
Main.java · Validate before assigning
Predict the result, edit Main.java, then run your program.
Standard input · optional
Select Run code to compile and execute this program.
Accepted: false
Balance cents: 500
Independent lab: a wallet that preserves its invariant
Build a bounded wallet using only controlled public operations. Constructor validation establishes the invariant; private storage and accepted-or-rejected operations preserve it. This is a single-threaded teaching simulation, not a real payment or account system.
- Declare public Main and a separate package-private Wallet in Main.java. Wallet exposes public static final long MAX_BALANCE_CENTS = 1_000_000L, private final String owner, and private long balanceCents.
- Implement public Wallet(String owner, long openingCents). Reject an opening balance outside 0..MAX_BALANCE_CENTS first, with IllegalArgumentException and message Opening balance must be 0..1000000 cents. Use private static normalizeOwner to reject null/blank owners with message Owner must not be blank. and return owner.strip().
- Expose public getOwner() and getBalanceCents() as read-only queries. Do not expose balance setters or writable mutable internal data. Keep console output out of Wallet.
- Implement public boolean deposit(long amountCents): reject nonpositive amounts or amounts greater than MAX_BALANCE_CENTS - balanceCents, then add only after acceptance. Return true for success and false without mutation otherwise.
- Implement public boolean withdraw(long amountCents): reject nonpositive amounts or amounts greater than balanceCents, then subtract only after acceptance. Keep every accepted balance in the invariant range.
- In main follow the supplied sequence for Mina and Omar, verify rejection and independence, then test an initially full wallet by rejecting one extra cent and withdrawing all funds. Print the exact labels and read-only query check shown below.
Follow the supplied sequence of accepted and rejected requests. Expected output:
Owner: Mina
Deposit: true
Withdraw: true
Overdraw: false
Zero deposit: false
Huge deposit: false
Mina cents: 1000
Omar cents: 400
Queries unchanged: true
Beyond cap: false
Withdraw all: true
Full wallet now cents: 0
Main.java · Build the controlled wallet API
Predict the result, edit Main.java, then run your program.
Standard input · optional
Select Run code to compile and execute this program.
Hint 1: define the boundary before the actions
Keep balance private and owner private final. The constructor validates the opening range, then calls a private owner-normalization helper. Public getters return the primitive balance and immutable String owner. No balance setter is needed.
Hint 2: reject before arithmetic changes state
For deposits, compare a positive amount to the remaining capacity before adding. For withdrawals, compare a positive amount to the current balance before subtracting. Return false before assignment on every rejected path; print results only from main.
Reveal the complete explained solution
Main.java · Complete encapsulated wallet simulation
Predict the result, edit Main.java, then run your program.
Standard input · optional
Select Run code to compile and execute this program.
Mina begins at 1200, accepts 300, then withdraws 500 to reach 1000. Overdrawing, depositing zero, and depositing Long.MAX_VALUE are rejected without mutation. Omar remains at 400. Queries do not alter the balance. The full wallet rejects an extra cent and accepts an exact withdrawal to zero, proving both inclusive boundaries are usable.
Test changes and boundaries
Predict the action result and remaining balance before running. A valid report should agree with the public query, and a rejected request must preserve all earlier state. Reset restores the supplied program.
- Construct at zero and at the maximum. Reject negative or above-cap openings and null/blank owners. Verify opening-balance error precedence when both inputs are invalid.
- Test zero, negative, Long.MIN_VALUE, and Long.MAX_VALUE amounts against both actions. Check that rejected requests preserve the owner and exact earlier balance.
- Deposit exactly the remaining capacity, reject one extra cent, withdraw exactly the balance, then reject a positive withdrawal from zero. Test one cent below and above each relevant boundary.
- Use two independent wallets and a separate alias of one. Confirm that only the receiving instance changes, while the alias observes the same valid change. Repeat getter calls between operations.
- Try direct balance writes and calls to normalizeOwner from Main: they must fail compilation. Separately mutate arrays passed to and returned from ReadingList and confirm its stored array slots remain unchanged.
Common mistakes and how to diagnose them
Making the field public to repair an access error
The compiler is showing that the caller crossed the class boundary. Use the documented query or action instead of exposing the representation. Private storage is intentional in this model.
Adding an unrestricted setter for every private field
A setter that accepts any value can violate the same invariant as a public field. Choose meaningful operations and validate before assignment; do not expose a write merely because a field exists.
Checking after arithmetic or mutation
Adding first can overflow before a later comparison sees the intended value. The wallet compares a positive deposit to remaining capacity before addition, and checks a withdrawal against the balance before subtraction.
Assuming protected means subclasses only
Protected also allows same-package access and has specific cross-package subclass rules. Do not use it as a blanket substitute for private. Detailed inheritance examples come later.
Returning a private array directly
A getter returning an internal array reference exposes writable elements. Copy on input and output when the contract requires isolation; understand that copying an array of mutable objects is not a deep copy of those objects.
Treating private as authorization or thread safety
Private restricts ordinary language-level access. It does not authenticate a user, prevent allowed public operations through aliases, encrypt data, or make concurrent read-modify-write sequences atomic.
Frequently asked questions
Is encapsulation just making fields private?
No. Private storage is one part. Constructor validation, meaningful public operations, rejection without mutation, and safe returned values must work together to preserve the object's contract.
Does every field need a setter?
No. owner has a read-only String query and no setter, while balance changes through deposit and withdraw. Expose the capabilities callers need, not automatic write access to every implementation detail.
Why return false for a rejected withdrawal?
This exercise documents accepted-or-rejected actions as boolean results. A failed request leaves state untouched. Constructor rejection uses a specific exception because construction cannot return a boolean in place of an initialized object.
Is the private final owner enough to make Wallet immutable?
No. owner cannot be reassigned after construction, but balanceCents deliberately changes through valid actions. Immutability describes the entire object's behavior, not one field modifier.
Is copying an array always sufficient?
It isolates array-slot changes, but a shallow copy still shares referenced element objects. The ReadingList example uses immutable String values; a model with mutable element objects needs a more deliberate copying or read-only contract.
Protect the rule, not merely the field name
Encapsulation combines controlled access, valid construction, meaningful public behavior, and safe observations. A private field can still be exposed through a blind setter or a mutable reference. An accepted operation must preserve the invariant; a rejected one must preserve the previous state.
Before moving on, explain why private does not imply immutable, why an alias still reaches the public API, and why a copied array is not automatically a deep copy. Next: packages, imports, and the Java API, where you will organize classes across files and find the contracts you rely on.
Can you defend the object's boundaries?
Answer all six questions and read the explanations. Answers are temporary on this page and do not save course progress.
References and related learning
The wallet scenario, explanations, examples, and exercises are original SovranCode material. The references document Java 21 access rules and array-copy behavior.
- Java 21 specification: access control
- Java 21 specification: fields and modifiers
- Java 21 Arrays API: copying arrays
Review valid construction · Review member ownership · Review mutable references · Java course roadmap.
Last updated: