Loading content...
The ultimate promise of encapsulation is this: a well-designed object cannot be put into an invalid state through normal operations.
This isn't just defensive programming or input validation—it's a fundamental design principle. When an object truly protects its invariants, entire categories of bugs become impossible. You don't need to remember to validate; the object refuses to break itself. You don't need to check for impossible states; they literally cannot exist.
This page explores the techniques for achieving self-protecting objects: constructor validation, state transition guards, invariant enforcement, and the patterns that make invalid states unrepresentable.
By the end of this page, you will understand how to enforce invariants from construction, design state machines that only allow valid transitions, use types to make invalid states unrepresentable, and apply validation patterns that guarantee object integrity throughout the entire lifecycle.
Before we can prevent invalid state, we must understand what makes state invalid in the first place. State becomes invalid when it violates the invariants—the rules that must always be true for the object to make sense.
Types of Invariants:
123456789101112131415161718192021222324
// Each of these represents INVALID STATE that should be impossible: // Data type violationappointment.setStartTime(null); // Appointment without a time // Range violation employee.setAge(-5); // Impossible age // Structural violationmeeting.setEndTime(LocalTime.of(9, 0));meeting.setStartTime(LocalTime.of(10, 0)); // End before start! // State machine violationOrder order = new Order();order.setStatus(OrderStatus.SHIPPED);order.setTrackingNumber(null); // Shipped without tracking! // Business rule violationorder.setDiscount(0.75); // 75% discount violates max 50% rule // Referential violationtask.setAssignee(deletedUser); // Reference to non-existent user // A well-designed class makes ALL of these impossible through its interface.Invalid state causes bugs that are notoriously difficult to debug. The corruption happens in one place; the symptom appears much later, in unrelated code. By the time you see the NullPointerException or wrong calculation, you have no idea how the object got into that state. Prevention is infinitely cheaper than debugging.
The first line of defense is the constructor. If an object can only be created with valid data, then it starts its life in a valid state.
The Principle:
A constructor should either produce a fully valid object or throw an exception. There is no middle ground.
This means: all required fields are validated, all invariants are established, and the object is immediately usable. Never create an object that requires additional initialization steps to become valid.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869
public class Reservation { private final String reservationId; private final Guest guest; private final Room room; private final LocalDate checkIn; private final LocalDate checkOut; private final BigDecimal totalPrice; private ReservationStatus status; /** * Creates a new reservation. * * @throws NullPointerException if any required field is null * @throws IllegalArgumentException if dates are invalid or price is non-positive */ public Reservation( Guest guest, Room room, LocalDate checkIn, LocalDate checkOut, BigDecimal totalPrice) { // 1. Null checks - fail fast with clear messages Objects.requireNonNull(guest, "Guest cannot be null"); Objects.requireNonNull(room, "Room cannot be null"); Objects.requireNonNull(checkIn, "Check-in date cannot be null"); Objects.requireNonNull(checkOut, "Check-out date cannot be null"); Objects.requireNonNull(totalPrice, "Total price cannot be null"); // 2. Individual field validation if (totalPrice.compareTo(BigDecimal.ZERO) <= 0) { throw new IllegalArgumentException( "Total price must be positive, was: " + totalPrice); } // 3. Cross-field validation (structural invariants) if (!checkOut.isAfter(checkIn)) { throw new IllegalArgumentException( "Check-out (" + checkOut + ") must be after check-in (" + checkIn + ")"); } // Only after validation passes do we set fields this.reservationId = UUID.randomUUID().toString(); this.guest = guest; this.room = room; this.checkIn = checkIn; this.checkOut = checkOut; this.totalPrice = totalPrice; this.status = ReservationStatus.PENDING; // Post-construction invariant check (optional but recommended) assert isValid() : "Reservation invariants violated after construction"; } /** * Checks all invariants. Used for assertions and debugging. */ private boolean isValid() { return reservationId != null && guest != null && room != null && checkIn != null && checkOut != null && checkOut.isAfter(checkIn) && totalPrice != null && totalPrice.compareTo(BigDecimal.ZERO) > 0 && status != null; }}Objects often evolve through states: an order goes from Draft → Submitted → Paid → Shipped. These aren't arbitrary transitions—they form a state machine with rules about what transitions are legal.
State transition guards enforce that only valid transitions can occur, preventing objects from jumping to states that don't make sense.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110
public class Order { private OrderStatus status; private PaymentInfo paymentInfo; private ShippingInfo shippingInfo; public Order() { this.status = OrderStatus.DRAFT; // Draft orders have no payment or shipping info yet } // ===== VALID TRANSITIONS ONLY ===== /** * Submit the order for processing. * Valid only from DRAFT state. */ public void submit() { requireStatus(OrderStatus.DRAFT, "submit"); requireNonEmpty(); status = OrderStatus.SUBMITTED; // Submitted orders are ready for payment } /** * Record payment for the order. * Valid only from SUBMITTED state. * Requires valid payment information. */ public void recordPayment(PaymentInfo payment) { requireStatus(OrderStatus.SUBMITTED, "record payment"); Objects.requireNonNull(payment, "Payment info required"); payment.validate(); // PaymentInfo validates itself this.paymentInfo = payment; status = OrderStatus.PAID; // Paid orders are ready for shipping } /** * Ship the order. * Valid only from PAID state. * Requires valid shipping information. */ public void ship(ShippingInfo shipping) { requireStatus(OrderStatus.PAID, "ship"); Objects.requireNonNull(shipping, "Shipping info required"); if (shipping.getTrackingNumber() == null) { throw new IllegalArgumentException("Tracking number required for shipping"); } this.shippingInfo = shipping; status = OrderStatus.SHIPPED; } /** * Cancel the order. * Valid from DRAFT, SUBMITTED, or PAID states. * Cannot cancel shipped orders. */ public void cancel(String reason) { if (status == OrderStatus.SHIPPED) { throw new IllegalStateException( "Cannot cancel shipped order. Use return process instead."); } if (status == OrderStatus.CANCELLED) { throw new IllegalStateException("Order already cancelled"); } status = OrderStatus.CANCELLED; // If there was payment, initiate refund if (paymentInfo != null) { initiateRefund(reason); } } // ===== TRANSITION HELPERS ===== private void requireStatus(OrderStatus required, String operation) { if (status != required) { throw new IllegalStateException(String.format( "Cannot %s: order is %s, must be %s", operation, status, required)); } } private void requireNonEmpty() { if (getItems().isEmpty()) { throw new IllegalStateException("Cannot submit empty order"); } } // ===== STATE MACHINE QUERIES ===== public boolean canSubmit() { return status == OrderStatus.DRAFT && !getItems().isEmpty(); } public boolean canRecordPayment() { return status == OrderStatus.SUBMITTED; } public boolean canShip() { return status == OrderStatus.PAID; } public boolean canCancel() { return status != OrderStatus.SHIPPED && status != OrderStatus.CANCELLED; }}The State Machine Pattern:
Notice how each state transition method:
Provide query methods like canSubmit() and canCancel() so clients can check whether an operation is valid before attempting it. This enables better UX (disable invalid buttons) and cleaner error handling.
The most powerful technique for preventing invalid state is to make invalid states impossible to represent at the type level. If the compiler won't let you create invalid state, you can't have bugs caused by invalid state.
This is the principle of "parse, don't validate" and "make illegal states unrepresentable."
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465
// ❌ REPRESENTABLE INVALID STATEpublic class Payment { private PaymentStatus status; // PENDING, AUTHORIZED, CAPTURED, FAILED private String authorizationCode; // Only valid if AUTHORIZED or CAPTURED private String captureId; // Only valid if CAPTURED private String failureReason; // Only valid if FAILED // PROBLEM: Nothing prevents: // - PENDING payment with an authorizationCode // - CAPTURED payment without a captureId // - status=AUTHORIZED but authorizationCode=null // Invalid combinations are representable!} // ✅ UNREPRESENTABLE INVALID STATE// Use separate types for each state! public sealed interface Payment permits PendingPayment, AuthorizedPayment, CapturedPayment, FailedPayment { String getPaymentId(); BigDecimal getAmount();} public record PendingPayment( String paymentId, BigDecimal amount) implements Payment {}// No extra fields - a pending payment HAS no auth code or capture ID public record AuthorizedPayment( String paymentId, BigDecimal amount, String authorizationCode // GUARANTEED to exist for authorized payments) implements Payment {} public record CapturedPayment( String paymentId, BigDecimal amount, String authorizationCode, // GUARANTEED String captureId // GUARANTEED) implements Payment {} public record FailedPayment( String paymentId, BigDecimal amount, String failureReason // GUARANTEED to exist for failed payments) implements Payment {} // Usage with pattern matching:public String describePayment(Payment payment) { return switch (payment) { case PendingPayment p -> "Pending: " + p.amount(); case AuthorizedPayment p -> "Authorized: " + p.authorizationCode(); case CapturedPayment p -> "Captured: " + p.captureId(); case FailedPayment p -> "Failed: " + p.failureReason(); };} // It's IMPOSSIBLE to have a CapturedPayment without a captureId!// The type system enforces our invariants.When invalid states are unrepresentable, bugs are caught at compile time, not runtime. You don't need defensive checks scattered throughout your code. The type system does the work. This is the gold standard for invariant enforcement.
Some invariants involve relationships between multiple fields: end date after start date, items matching total price, related fields staying in sync. These structural invariants require special attention.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485
public class TimeRange { private final LocalDateTime start; private final LocalDateTime end; public TimeRange(LocalDateTime start, LocalDateTime end) { Objects.requireNonNull(start, "Start time required"); Objects.requireNonNull(end, "End time required"); // Cross-field validation: end must be after start if (!end.isAfter(start)) { throw new IllegalArgumentException( "End time (" + end + ") must be after start time (" + start + ")"); } this.start = start; this.end = end; } // Duration is derived - always consistent with start/end public Duration getDuration() { return Duration.between(start, end); } // Contains check uses both fields public boolean contains(LocalDateTime time) { return !time.isBefore(start) && !time.isAfter(end); } public boolean overlaps(TimeRange other) { return this.start.isBefore(other.end) && other.start.isBefore(this.end); }} // ============================ public class Invoice { private final List<InvoiceLine> lines; private final BigDecimal subtotal; // Derived from lines private final BigDecimal taxRate; private final BigDecimal tax; // Derived from subtotal and taxRate private final BigDecimal total; // Derived from subtotal and tax private Invoice(List<InvoiceLine> lines, BigDecimal taxRate) { // Private constructor - use factory method this.lines = List.copyOf(lines); // Defensive copy this.taxRate = taxRate; // Calculate derived values this.subtotal = calculateSubtotal(lines); this.tax = subtotal.multiply(taxRate); this.total = subtotal.add(tax); // Verify invariant assert linesMatchSubtotal() : "Lines don't sum to subtotal"; } // Factory method ensures invariants public static Invoice create(List<InvoiceLine> lines, BigDecimal taxRate) { if (lines == null || lines.isEmpty()) { throw new IllegalArgumentException("Invoice must have at least one line"); } if (taxRate == null || taxRate.compareTo(BigDecimal.ZERO) < 0) { throw new IllegalArgumentException("Tax rate must be non-negative"); } return new Invoice(lines, taxRate); } private BigDecimal calculateSubtotal(List<InvoiceLine> lines) { return lines.stream() .map(InvoiceLine::getLineTotal) .reduce(BigDecimal.ZERO, BigDecimal::add); } private boolean linesMatchSubtotal() { BigDecimal calculated = calculateSubtotal(lines); return calculated.compareTo(subtotal) == 0; } // Public interface only exposes reads - no way to break invariants public BigDecimal getSubtotal() { return subtotal; } public BigDecimal getTax() { return tax; } public BigDecimal getTotal() { return total; } public List<InvoiceLine> getLines() { return lines; } // Already immutable}Different contexts require different validation approaches. Understanding when to use each strategy ensures appropriate protection without unnecessary overhead.
| Strategy | When to Use | Trade-offs |
|---|---|---|
| Fail-fast (throw immediately) | Domain objects, constructors, public API | Clear errors, no invalid state; may lose input work |
| Accumulate errors (collect all) | Forms, batch processing, user input | Better UX, complete feedback; more complex code |
| Defensive programming | Public API boundaries, untrusted input | Robust against callers; verbose, maybe redundant |
| Assertions | Internal invariants, debugging | Catch bugs in development; disabled in production |
| Type-level enforcement | Core domain invariants | Compile-time safety; requires sophisticated types |
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748
// For user-facing input, accumulate all errorspublic class UserRegistrationValidator { public ValidationResult validate(UserRegistrationRequest request) { List<ValidationError> errors = new ArrayList<>(); // Validate each field, collecting all errors if (request.getEmail() == null || request.getEmail().isBlank()) { errors.add(new ValidationError("email", "Email is required")); } else if (!isValidEmailFormat(request.getEmail())) { errors.add(new ValidationError("email", "Invalid email format")); } else if (emailAlreadyExists(request.getEmail())) { errors.add(new ValidationError("email", "Email already registered")); } if (request.getPassword() == null) { errors.add(new ValidationError("password", "Password is required")); } else { if (request.getPassword().length() < 8) { errors.add(new ValidationError("password", "Password must be at least 8 characters")); } if (!containsUppercase(request.getPassword())) { errors.add(new ValidationError("password", "Password must contain uppercase letter")); } if (!containsNumber(request.getPassword())) { errors.add(new ValidationError("password", "Password must contain a number")); } } if (request.getBirthDate() != null && request.getBirthDate().isAfter(LocalDate.now())) { errors.add(new ValidationError("birthDate", "Birth date cannot be in the future")); } return errors.isEmpty() ? ValidationResult.success() : ValidationResult.failure(errors); }} // Usage:ValidationResult result = validator.validate(request);if (result.isFailure()) { // Return all errors to user at once return ResponseEntity.badRequest().body(result.getErrors());} // Only after validation passes, create the domain objectUser user = User.create(request); // This constructor can throw for edge casesUse accumulated validation at system boundaries (API endpoints, form handlers) for better user experience. Use fail-fast validation in domain object constructors for internal integrity. The domain objects are your last line of defense—they should never accept invalid state regardless of how well the outer layers validated.
When all pieces come together—constructor validation, state transition guards, unrepresentable invalid states, and cross-field invariants—you achieve what we call the Invariant Guarantee:
If an object exists, it is valid. There is no moment in an object's lifecycle when it can be in an invalid state.
This property transforms how you write code that uses these objects:
The Productivity Multiplier:
The invariant guarantee isn't just about correctness—it's about velocity. When you trust that objects are always valid:
This is why experienced engineers obsess over object invariants. The upfront investment in proper encapsulation pays dividends forever.
Strive for objects where the phrase 'invalid state' is meaningless. If someone asks 'what if the Order has null items?', the correct answer is 'that object can't exist.' This is the power of well-designed encapsulation.
We've explored how encapsulation prevents invalid state—from constructor validation to compile-time guarantees. Here are the essential takeaways:
What's Next:
Now that we understand how to prevent invalid state, we'll examine encapsulation as protection—a broader view of how encapsulation shields your code from changes in other parts of the system, protects against misuse, and creates stable contracts that enable large-scale software development.
You now understand the techniques for making objects self-protecting: constructor validation, state machine guards, type-level enforcement, and structural invariants. Apply these techniques consistently, and entire categories of bugs simply disappear from your codebase.