Java access modifiers limit which source-level declarations may refer to a member; they do not replace validation or protect data after it has been returned.
Java access control: private state and package boundaries
Java 8+. The program uses only JDK classes and runs without a framework.
Expose an operation, not a writable field
A warehouse wallet should not publish its balance as a writable integer. A caller could set it negative or update it without the checks required for a withdrawal. The example keeps the field private and exposes a method that validates the requested debit before modifying state.
The nested example fits in one runnable file, so it is not a proof of cross-package access rules. In a real package boundary, public means accessible where the enclosing type is accessible, package-private means the same package, and private confines direct access to the applicable enclosing declaration context. Protected adds subclass access rules, with restrictions on the qualifying receiver across packages.
Package-private is the absence of a modifier; it is not a keyword named default. A top-level class can be public or package-private, while nested declarations have more choices. Keep an implementation type package-private when clients only need an interface. That reduces the surface that later refactoring must preserve.
Returning a reference can widen the boundary
A private List field stops direct field access. A public getter returning that same mutable list still allows callers to change its elements. A view or copy policy is needed for mutable state, including nested values. The collection views lesson separates those choices.
Access is also not authentication. A public method can be called by code that can reach it; deciding whether an end user may withdraw funds belongs to an application authorization boundary. The sample is about preserving a numeric invariant inside one process. It deliberately makes no security claim about reflection, native code or untrusted code execution.
Working program
public class WalletBoundary {
static class WarehouseWallet {
private int credits;
WarehouseWallet(int initialCredits) {
if (initialCredits < 0) throw new IllegalArgumentException("Negative balance");
credits = initialCredits;
}
public boolean debit(int amount) {
if (amount <= 0) throw new IllegalArgumentException("Positive debit required");
if (amount > credits) return false;
credits -= amount;
return true;
}
public int balance() { return credits; }
}
public static void main(String[] args) {
WarehouseWallet wallet = new WarehouseWallet(40);
System.out.println(wallet.debit(12));
System.out.println(wallet.debit(50));
System.out.println("balance=" + wallet.balance());
}
}Output
true
false
balance=28Cost and failure boundaries
The field reads and single-threaded debit operation take O(1) work. No container copy is required because the exposed balance is a primitive value. If two workers share this instance, private does not make check-and-update atomic; use a lock or another ownership rule before sharing it.
Rejected withdrawals leave the old balance intact. That is a useful failure boundary: validation runs before mutation. Test zero, negative, exact-balance and excessive debits. A multi-account transfer needs a wider invariant than either account alone, so two individually valid debit/credit methods do not establish an atomic transfer.
Common Mistakes
- Do not equate private with thread safety.
- Do not publish a mutable object and assume a private field still protects its contents.
- Do not use a same-package example to infer every protected cross-package rule.
Connect the contracts
Compare the boundary explained in Object invariants with the assumptions made by this program.
