Loading content...
At its core, object-oriented programming is about one thing: methods manipulating state. Every method you write either reads state (query), changes state (command), or does both. Understanding this fundamental relationship transforms how you design objects.
When you call account.withdraw(100), you're not just "executing code"—you're requesting a state transformation from the current state (balance = $500) to a new state (balance = $400). The method is the gatekeeper, the validator, the transformer. It ensures the transformation is valid and complete.
By the end of this page, you will understand how to view methods as state transformations, the Command-Query Separation principle, how to design methods that preserve invariants, the relationship between behavior and encapsulation, and how to reason about method correctness through pre/post-conditions.
Every object method can be understood as a state transformation function:
method: (S₁, inputs) → (S₂, outputs)
Where:
This mathematical view helps us reason precisely about what methods do and whether they're correct.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263
public class BankAccount { private BigDecimal balance; private List<Transaction> history; /** * State Transformation: withdraw * * Before: S₁ = (balance=500, history=[...]) * Input: amount=100 * After: S₂ = (balance=400, history=[..., WITHDRAW(100)]) * Output: void * * Transformation equation: * S₂.balance = S₁.balance - amount * S₂.history = S₁.history + [new WithdrawTransaction(amount)] */ public void withdraw(BigDecimal amount) { // Precondition: transformation is valid if (amount.compareTo(balance) > 0) { throw new InsufficientFundsException(); } // State transformation this.balance = this.balance.subtract(amount); this.history.add(new Transaction(TransactionType.WITHDRAW, amount)); // Postcondition: state is consistent assert balance.compareTo(BigDecimal.ZERO) >= 0; } /** * State Transformation: getBalance (Query) * * Before: S₁ = (balance=400, ...) * Input: none * After: S₂ = S₁ (unchanged) * Output: 400 * * Pure query: reads state without modifying it */ public BigDecimal getBalance() { return balance; // State unchanged: S₂ = S₁ } /** * State Transformation: transfer * * This transforms state of TWO objects: * Before: this.balance=400, target.balance=100 * After: this.balance=300, target.balance=200 * * Multi-object transformations need atomicity */ public void transfer(BankAccount target, BigDecimal amount) { // Atomic transformation of multiple objects synchronized (this) { synchronized (target) { this.withdraw(amount); target.deposit(amount); } } }}Rather than thinking "this method does X, Y, then Z", think "this method transforms state from A to B while satisfying constraint C." Declarative thinking about state transformations leads to clearer, more correct code because you focus on the what (correct end state) rather than getting lost in the how (implementation details).
Command-Query Separation (CQS) is a design principle that states: every method should either be a command that changes state, or a query that returns data—but never both.
This principle, articulated by Bertrand Meyer, dramatically simplifies reasoning about code. If you know a method is a query, you know it won't change anything. If it's a command, you know it changes state and shouldn't rely on its return value for logic.
voidsave(), delete(), add(), update()get(), find(), is(), has()123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657
public class ShoppingCart { private List<CartItem> items = new ArrayList<>(); private BigDecimal totalPrice = BigDecimal.ZERO; // ========== COMMANDS (change state, return void) ========== public void addItem(Product product, int quantity) { CartItem item = new CartItem(product, quantity); items.add(item); totalPrice = totalPrice.add(product.getPrice().multiply(new BigDecimal(quantity))); } public void removeItem(String productId) { items.removeIf(item -> item.getProduct().getId().equals(productId)); recalculateTotal(); } public void clear() { items.clear(); totalPrice = BigDecimal.ZERO; } // ========== QUERIES (observe state, no side effects) ========== public BigDecimal getTotalPrice() { return totalPrice; } public int getItemCount() { return items.stream().mapToInt(CartItem::getQuantity).sum(); } public boolean isEmpty() { return items.isEmpty(); } public boolean containsProduct(String productId) { return items.stream() .anyMatch(item -> item.getProduct().getId().equals(productId)); } public List<CartItem> getItems() { return List.copyOf(items); // Defensive copy } // ========== CQS VIOLATIONS (avoid these) ========== // BAD: Mixed command-query - adds item AND returns count public int addItemAndGetCount(Product product, int quantity) { addItem(product, quantity); return getItemCount(); // Mixing mutation with query } // BETTER: Separate into two calls // cart.addItem(product, quantity); // Command // int count = cart.getItemCount(); // Query}CQS is a guideline, not a law. Some operations naturally combine command and query: stack.pop() removes and returns, queue.poll() does the same. The key is intentionality—if violating CQS, do so consciously and document it. For most methods, prefer pure commands or pure queries.
Design by Contract is a methodology where methods are specified by three elements:
These contracts make state transformations explicit and verifiable.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788
public class Stack<T> { private Object[] elements; private int size; private final int capacity; /** * INVARIANT: 0 <= size <= capacity (always true) * INVARIANT: elements[0..size-1] are non-null, elements[size..capacity-1] are null */ /** * Push an element onto the stack. * * PRECONDITIONS: * - element != null (no null elements allowed) * - size < capacity (stack is not full) * * POSTCONDITIONS: * - size = old(size) + 1 * - elements[old(size)] = element * - peek() returns element * - isEmpty() returns false * * @throws NullPointerException if element is null (precondition violation) * @throws StackOverflowException if stack is full (precondition violation) */ public void push(T element) { // Check preconditions Objects.requireNonNull(element, "Cannot push null element"); if (size >= capacity) { throw new StackOverflowException("Stack is full"); } // State transformation elements[size] = element; size++; // Postconditions are now satisfied by construction // No need to check them explicitly in production code // (assertions can verify during testing) assert size > 0 : "Postcondition failed: size must be > 0 after push"; assert peek().equals(element) : "Postcondition failed: top must be pushed element"; } /** * Pop the top element. * * PRECONDITIONS: * - !isEmpty() (stack has at least one element) * * POSTCONDITIONS: * - size = old(size) - 1 * - return value = old(peek()) * - elements[size] = null (for GC) */ @SuppressWarnings("unchecked") public T pop() { // Check preconditions if (isEmpty()) { throw new EmptyStackException("Cannot pop from empty stack"); } // State transformation T element = (T) elements[--size]; elements[size] = null; // Help GC return element; } /** * Peek at top element without removing. * * PRECONDITIONS: !isEmpty() * POSTCONDITIONS: state unchanged (this is a query) */ @SuppressWarnings("unchecked") public T peek() { if (isEmpty()) { throw new EmptyStackException("Cannot peek empty stack"); } return (T) elements[size - 1]; // No state change } // Pure queries - no preconditions beyond invariants public boolean isEmpty() { return size == 0; } public boolean isFull() { return size == capacity; } public int size() { return size; }}Preconditions are caller responsibilities—if violated, it's a programming error. Validation handles untrusted input—if invalid, it's expected and must be handled gracefully. Don't confuse them: userId != null is a precondition; userId exists in database is validation that might legitimately fail.
Methods must preserve class invariants. An invariant is a property that must be true before and after every public method call (it may be temporarily violated during method execution).
Designing methods around invariant preservation is perhaps the most important discipline in object-oriented programming. Violated invariants lead to bugs that are notoriously difficult to track down.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293
public class SortedIntList { private final List<Integer> elements = new ArrayList<>(); /** * INVARIANT: elements is always sorted in ascending order. * * Every public method must ensure this invariant holds * after it completes, assuming it held before. */ /** * Add an element, maintaining sorted order. * * Preserves invariant by using binary search insertion. */ public void add(int value) { // Find insertion point that maintains sort order int index = Collections.binarySearch(elements, value); if (index < 0) { index = -(index + 1); // Convert to insertion point } elements.add(index, value); // Invariant preserved: list is still sorted } /** * Remove an element if present. * * Preserves invariant: removing from a sorted list keeps it sorted. */ public boolean remove(int value) { int index = Collections.binarySearch(elements, value); if (index >= 0) { elements.remove(index); return true; } return false; // Invariant preserved: removal doesn't affect sort order } /** * Get minimum element (relies on invariant). * * Because invariant guarantees sorted order, min is always first element. */ public int getMin() { if (elements.isEmpty()) { throw new NoSuchElementException(); } return elements.get(0); // O(1) because of invariant! } /** * Get maximum element (relies on invariant). */ public int getMax() { if (elements.isEmpty()) { throw new NoSuchElementException(); } return elements.get(elements.size() - 1); // O(1) because of invariant! } /** * Check if value exists (relies on invariant). * * Binary search is only valid because invariant guarantees sorted order. */ public boolean contains(int value) { // O(log n) binary search only works if invariant holds return Collections.binarySearch(elements, value) >= 0; } /** * INVARIANT VIOLATION EXAMPLE - DO NOT DO THIS */ // public void addUnsorted(int value) { // elements.add(value); // BREAKS INVARIANT! // // Now contains(), getMin(), getMax() return WRONG results // } /** * Check invariant (useful for testing and debugging) */ public boolean checkInvariant() { for (int i = 1; i < elements.size(); i++) { if (elements.get(i) < elements.get(i - 1)) { return false; // Not sorted! } } return true; }}Notice how the invariant (sorted order) enables O(1) min/max and O(log n) contains. Without the invariant, these would be O(n). Invariants aren't just about correctness—they enable performance optimizations by establishing guarantees other code can rely on. Breaking an invariant thus breaks both correctness AND performance.
Methods are the guardians of state. Encapsulation means that state can only be changed through behavior (methods), never through direct field access. This isn't bureaucracy—it's protection.
When state changes only through methods:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566
public class Temperature { private double kelvin; // Internal representation: always Kelvin // Private constructor forces use of factory methods private Temperature(double kelvin) { if (kelvin < 0) { throw new IllegalArgumentException( "Temperature cannot be below absolute zero" ); } this.kelvin = kelvin; } // Factory methods: controlled state creation public static Temperature fromKelvin(double k) { return new Temperature(k); } public static Temperature fromCelsius(double c) { return new Temperature(c + 273.15); } public static Temperature fromFahrenheit(double f) { return new Temperature((f - 32) * 5/9 + 273.15); } // Accessors: controlled state observation public double toKelvin() { return kelvin; } public double toCelsius() { return kelvin - 273.15; } public double toFahrenheit() { return (kelvin - 273.15) * 9/5 + 32; } // Mutators: controlled state transformation /** * Increase temperature by delta degrees Kelvin. * * Note: this could also return a new Temperature (immutable style) * or modify in place (mutable style). The method controls this choice. */ public void increaseBy(double deltaKelvin) { double newKelvin = this.kelvin + deltaKelvin; if (newKelvin < 0) { throw new IllegalArgumentException( "Cannot cool below absolute zero" ); } this.kelvin = newKelvin; } /** * If Temperature had public fields: * * temp.kelvin = -100; // CATASTROPHE: Below absolute zero! * temp.celsius = 25; // CATASTROPHE: Which field is source of truth? * * Clients could: * - Violate invariants (negative Kelvin) * - Create inconsistent state (Kelvin and Celsius disagree) * - Depend on internal representation (breaks if we change to Celsius internal) */ // The benefit: we can change internal representation! // Perhaps later we decide to store as Celsius for precision at human-scale temps. // With encapsulation: change internal field, update conversions. No client changes. // Without encapsulation: every client directly accessing kelvin field breaks.}Instead of asking an object for its state and then deciding what to do (if (account.getBalance() > amount) account.setBalance(...)), tell the object what you want (account.withdraw(amount)) and let it manage its own state. Objects should be experts on their own data. This principle reduces coupling and localizes behavior where the data lives.
Several proven patterns guide how to design methods that manipulate state well:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798
public class Account { // ========== PATTERN 1: FLUENT INTERFACE ========== // Return 'this' to enable method chaining public Account withName(String name) { this.name = name; return this; // Return this for chaining } public Account withEmail(String email) { this.email = email; return this; } // Usage: account.withName("Alice").withEmail("alice@example.com"); // ========== PATTERN 2: BUILDER PATTERN ========== // Separate construction logic into a builder public static class Builder { private String name; private String email; private AccountType type = AccountType.STANDARD; public Builder name(String name) { this.name = name; return this; } public Builder email(String email) { this.email = email; return this; } public Builder type(AccountType type) { this.type = type; return this; } public Account build() { // Validate complete state before constructing Objects.requireNonNull(name, "Name is required"); Objects.requireNonNull(email, "Email is required"); return new Account(this); } } // ========== PATTERN 3: VALIDATE-THEN-MUTATE ========== // All validation before any state change public void transfer(Account target, BigDecimal amount) { // Phase 1: Validate EVERYTHING first Objects.requireNonNull(target, "Target cannot be null"); Objects.requireNonNull(amount, "Amount cannot be null"); if (amount.compareTo(BigDecimal.ZERO) <= 0) { throw new IllegalArgumentException("Amount must be positive"); } if (this.balance.compareTo(amount) < 0) { throw new InsufficientFundsException("Balance too low"); } if (this.equals(target)) { throw new IllegalArgumentException("Cannot transfer to self"); } // Phase 2: Mutate only after all validation passes this.balance = this.balance.subtract(amount); target.balance = target.balance.add(amount); // If any validation throws, no state was changed } // ========== PATTERN 4: COPY-ON-WRITE ========== // Mutate a copy, then atomically swap public void updateItems(Consumer<List<Item>> mutator) { List<Item> copy = new ArrayList<>(this.items); mutator.accept(copy); // Mutate the copy this.items = List.copyOf(copy); // Atomic swap with immutable list } // ========== PATTERN 5: EXECUTE AROUND ========== // Wrap state-changing operation with before/after logic public void withLock(Runnable operation) { lock.lock(); try { operation.run(); } finally { lock.unlock(); } } public void safeUpdate(Consumer<Account> updater) { withLock(() -> { Account backup = this.clone(); try { updater.accept(this); } catch (Exception e) { this.restoreFrom(backup); // Rollback on failure throw e; } }); }}Choose patterns based on your needs: Fluent Interface for readable configuration, Builder for complex construction, Validate-Then-Mutate for atomic updates, Copy-on-Write for thread safety, Execute Around for cross-cutting concerns. Multiple patterns can be combined in the same class.
A side effect is any observable change to state outside the method's return value. This includes modifying object fields, writing to databases, sending network requests, or printing output.
Pure functions have no side effects—they only compute and return a value based on their inputs. While OOP inherently involves state mutation (methods changing object state), minimizing unnecessary side effects makes code more predictable and testable.
| Type | Example | Predictability | Testability |
|---|---|---|---|
| Pure Function | Math.sqrt(x) | Perfect | Trivial |
| Local Mutation | list.add(item) | High | Easy |
| Object Mutation | account.deposit(100) | Medium | Requires setup |
| External Write | db.save(entity) | Low | Needs mocking |
| Non-deterministic | new Date(), random() | Very Low | Needs seeding |
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879
public class OrderProcessor { // HIGHLY SIDE-EFFECTFUL: Hard to test, unpredictable public void processOrder_Bad(Order order) { // Side effect: Database write orderRepository.save(order); // Side effect: External API call paymentGateway.charge(order.getPaymentInfo(), order.getTotal()); // Side effect: Email send emailService.sendConfirmation(order.getCustomerEmail(), order); // Side effect: Logging logger.info("Processed order: " + order.getId()); // Testing this requires mocking 4 external systems! } // BETTER: Separate pure computation from side effects /** * PURE: Validates and computes what should happen. * Returns a description of actions, performs no side effects. */ public ProcessingResult computeProcessing(Order order) { // Pure validation List<ValidationError> errors = validateOrder(order); if (!errors.isEmpty()) { return ProcessingResult.failed(errors); } // Pure computation of what to do BigDecimal chargeAmount = calculateTotal(order); String confirmationEmail = generateConfirmationEmail(order); return ProcessingResult.success( new ChargeRequest(order.getPaymentInfo(), chargeAmount), new EmailRequest(order.getCustomerEmail(), confirmationEmail), order ); } /** * EFFECTFUL: Executes side effects described by ProcessingResult. * Thin layer that just performs I/O. */ public void executeProcessing(ProcessingResult result) { if (!result.isSuccess()) { throw new ValidationException(result.getErrors()); } paymentGateway.charge(result.getChargeRequest()); orderRepository.save(result.getOrder()); emailService.send(result.getEmailRequest()); logger.info("Processed order: {}", result.getOrder().getId()); } // Now testing is easy: // - Test computeProcessing with different orders (pure, no mocking) // - Test executeProcessing once with a mock ProcessingResult private List<ValidationError> validateOrder(Order order) { // Pure validation logic List<ValidationError> errors = new ArrayList<>(); if (order.getItems().isEmpty()) { errors.add(new ValidationError("Order has no items")); } // ... more validations return errors; } private BigDecimal calculateTotal(Order order) { // Pure computation return order.getItems().stream() .map(item -> item.getPrice().multiply(new BigDecimal(item.getQuantity()))) .reduce(BigDecimal.ZERO, BigDecimal::add); }}Structure your code with a 'functional core' of pure logic surrounded by an 'imperative shell' of I/O. The core is easy to test and reason about. The shell handles messy real-world interactions. This architecture, sometimes called 'Ports and Adapters' or 'Hexagonal Architecture', maximizes the amount of code that's pure and testable.
Methods are not arbitrary code—they are state transformers that convert objects from one valid state to another. Let's consolidate:
What's Next:
Now that we understand how behavior manipulates state, we'll examine a critical architectural decision: should objects be stateful or stateless? The next page explores the trade-offs between stateful and stateless design, when to use each, and how this decision affects scalability, testability, and complexity.
You now understand methods as state transformations. You can apply Command-Query Separation, design with contracts, preserve invariants, and structure code to minimize side effects. Next, we'll tackle the fundamental choice between stateful and stateless design.