Encapsulation, Modularity, and Domain ModelingIntroductionObject-oriented programming is not primarily about placing data inside classes. It is about designing software objects that represent meaningful concepts, protect their own validity, collaborate through clear contracts, and remain understandable as systems grow. Encapsulation, modularity, and domain modeling provide the central techniques for achieving this goal in Java. A well-designed domain model makes invalid states difficult to create, limits the effects of change, and assigns behavior to the objects that possess the relevant knowledge. These qualities are essential in banking, healthcare, logistics, education, retail, and public administration systems, where software must reflect business rules rather than merely store data. This chapter examines Java access control, information hiding, packages, modules, immutability, records, enums, validation, cohesion, coupling, and responsibility assignment. It also applies these ideas to a realistic order-management example. Learning ObjectivesBy the end of this chapter, you should be able to: - distinguish encapsulation from information hiding;
- apply Java access modifiers appropriately;
- organize related types into packages and modules;
- design immutable classes, records, and value objects;
- use enums to represent constrained alternatives;
- validate state at appropriate boundaries;
- evaluate cohesion and coupling;
- assign responsibilities using domain knowledge;
- construct maintainable domain models;
- identify limitations and trade-offs in encapsulation and modularity.
1. Encapsulation and Information Hiding1.1 EncapsulationEncapsulation is the bundling of state and behavior into a unit, usually a class, together with controlled access to that unit. A BankAccount, for example, can contain a balance and operations such as deposit and withdrawal. The object, rather than unrelated client code, controls how the balance changes. Information hiding is the design decision to conceal implementation details that clients do not need to know. Encapsulation is the mechanism; information hiding is the design objective. A class may encapsulate data while still exposing too many implementation details. Good design exposes stable concepts and hides changeable decisions. Consider a poorly protected account: public class BankAccount {
public double balance;
}Any client can assign a negative balance or bypass transaction rules. A better design protects the field and exposes meaningful operations: import java.math.BigDecimal;
import java.util.Objects;
public final class BankAccount {
private final String accountNumber;
private BigDecimal balance;
public BankAccount(String accountNumber, BigDecimal openingBalance) {
this.accountNumber = Objects.requireNonNull(accountNumber);
if (openingBalance.signum() < 0) {
throw new IllegalArgumentException("Opening balance cannot be negative");
}
this.balance = openingBalance;
}
public BigDecimal balance() {
return balance;
}
public void deposit(BigDecimal amount) {
requirePositive(amount);
balance = balance.add(amount);
}
public void withdraw(BigDecimal amount) {
requirePositive(amount);
if (amount.compareTo(balance) > 0) {
throw new IllegalStateException("Insufficient funds");
}
balance = balance.subtract(amount);
}
private static void requirePositive(BigDecimal amount) {
Objects.requireNonNull(amount);
if (amount.signum() <= 0) {
throw new IllegalArgumentException("Amount must be positive");
}
}
}The class protects the invariant that the balance cannot become negative through its public operations. BigDecimal is preferable to double for monetary values because binary floating-point representation can introduce rounding errors. Currency systems should also define scale, rounding, and currency rules explicitly. 1.2 Java access controlJava provides four relevant access levels: | Modifier | Accessible from same class | Same package | Subclasses in other packages | Any package |
|---|
private | Yes | No | No | No | | no modifier (package-private) | Yes | Yes | Only through package relationships, not ordinary access | No | protected | Yes | Yes | Yes, subject to inheritance rules | No | public | Yes | Yes | Yes | Yes |
A practical default is to make fields private, expose the smallest useful public interface, and use package-private visibility for implementation types that should not be part of the external API. protected should be used cautiously: it exposes implementation details to subclasses and can make inheritance fragile. Access modifiers alone do not guarantee encapsulation. Returning a mutable internal collection can leak state: public List<String> tags() {
return tags; // unsafe if tags is mutable
}Safer alternatives include: public List<String> tags() {
return List.copyOf(tags);
}or returning an unmodifiable view when a live view is deliberately required. List.copyOf creates an unmodifiable copy, although the elements themselves may still be mutable. 1.3 Invariants and contractsAn invariant is a condition that should remain true for every valid object. Examples include: - an order total is not negative;
- a product price is positive or zero according to the business rule;
- an email address is not blank;
- a shipment cannot be delivered before it is dispatched.
A class contract usually includes preconditions, postconditions, and invariants. A precondition states what a caller must provide; a postcondition states what the operation guarantees; an invariant applies throughout the object's valid lifetime. Validation should occur at the boundary where invalid information enters the model. 2. Immutability, Value Objects, Records, and Enums2.1 ImmutabilityAn immutable object cannot change observable state after construction. Immutability reduces accidental interference, simplifies reasoning, and is naturally safer for concurrent use. Typical practices include: - declare the class
final unless controlled inheritance is required; - make fields
private final; - validate constructor arguments;
- provide no mutator methods;
- defensively copy mutable inputs and outputs;
- ensure referenced objects are themselves immutable or safely encapsulated.
A class with final fields is not automatically immutable. The following class is not deeply immutable because its list can be changed: public final class Course {
private final List<String> topics;
public Course(List<String> topics) {
this.topics = topics;
}
public List<String> topics() {
return topics;
}
}A safer version uses List.copyOf both to protect the input reference and to prevent mutation through the result: public final class Course {
private final List<String> topics;
public Course(List<String> topics) {
this.topics = List.copyOf(topics);
}
public List<String> topics() {
return topics;
}
}This provides shallow immutability: the list cannot change, but mutable elements inside it could still change. 2.2 RecordsA record is a concise syntax for a class whose primary purpose is to model transparent, immutable-style data. For example: public record CustomerId(String value) {
public CustomerId {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException("Customer ID is required");
}
}
}The compiler supplies private final components, accessors named after the components, a canonical constructor, equals, hashCode, and toString. Records are useful for value objects, data-transfer objects, and immutable messages. A record is not automatically deeply immutable. A component such as List<String> should be copied in a compact constructor. Records can contain methods and implement interfaces, but they cannot extend another class because all records implicitly extend java.lang.Record. They can implement interfaces and may declare static members and nested types. Use a record when identity is determined by its component values and the representation is suitable for clients. Use a conventional class when the object has complex lifecycle, identity independent of fields, extensive hidden behavior, or carefully controlled mutation. 2.3 EnumsAn enum represents a closed set of named alternatives: public enum OrderStatus {
NEW, PAID, PACKED, SHIPPED, DELIVERED, CANCELLED
}Enums are safer than strings because the compiler checks values and enables exhaustive reasoning in switch expressions. They can contain fields and behavior: public enum PaymentMethod {
CARD(true), BANK_TRANSFER(false), CASH(false);
private final boolean requiresAuthorization;
PaymentMethod(boolean requiresAuthorization) {
this.requiresAuthorization = requiresAuthorization;
}
public boolean requiresAuthorization() {
return requiresAuthorization;
}
}An enum is appropriate when the alternatives are stable and controlled by the application. If administrators must add alternatives without redeploying the system, a database-backed entity may be more suitable. 3. Packages and Modules3.1 PackagesPackages organize types by namespace and conceptual responsibility. A package structure might be: com.example.shop
├── customer
├── catalog
├── ordering
├── payment
└── shipping Packages reduce naming collisions, support package-private collaboration, and communicate architecture. They should reflect meaningful boundaries rather than arbitrary technical layers alone. A package named ordering is often more expressive than a package named misc. A package is not a complete security boundary. Package-private access controls ordinary Java source access, but reflection, configuration, and deployment concerns require additional controls. Public classes in a package may still expose excessive coupling. 3.2 Java modulesThe Java Platform Module System (JPMS), introduced in Java 9, provides a stronger unit of encapsulation than a package. A module declares its dependencies and the packages it exports: module com.example.ordering {
requires com.example.catalog;
requires java.logging;
exports com.example.ordering.api;
}requires identifies module dependencies. exports makes a package available to other modules at compile time and run time under normal access rules. Packages not exported remain internal to the module. opens is used when reflective access is needed, commonly by frameworks; it should not be added indiscriminately because it weakens strong encapsulation.
Modules provide reliable configuration, explicit dependencies, and reduced accidental API exposure. They do not replace good class design. A module with hundreds of unrelated responsibilities can still be difficult to maintain. 3.3 Modularity as a design principleModularity means decomposing a system into units with clear responsibilities and controlled relationships. A useful module or package should have: - a coherent purpose;
- a small, stable public interface;
- limited knowledge of other units;
- replaceable internal implementation;
- dependencies that point toward abstractions or stable policies where appropriate.
A change to database technology should ideally remain inside an infrastructure boundary rather than forcing changes throughout the domain model. 4. Cohesion, Coupling, and Responsibility Assignment4.1 CohesionCohesion measures how closely the responsibilities within a unit belong together. High cohesion is desirable because a class with one coherent purpose is easier to understand, test, and change. A PriceCalculator that calculates prices is cohesive. A Customer class that validates addresses, sends email, formats invoices, opens database connections, and generates shipping labels is not. Such a class is often called a “god object.” Cohesion is not the same as having only one method. A cohesive class can contain several operations when they serve one conceptual responsibility. 4.2 CouplingCoupling describes the degree of dependency between units. High coupling means that a change in one class or module is likely to require changes elsewhere. Some coupling is unavoidable; the objective is manageable, explicit coupling rather than zero coupling. Common sources of harmful coupling include: - exposing mutable internal collections;
- depending on concrete infrastructure classes throughout the domain;
- using global state;
- requiring clients to know construction details;
- making many classes depend on unstable implementation details.
Dependency injection can reduce construction coupling: public final class CheckoutService {
private final PaymentGateway paymentGateway;
public CheckoutService(PaymentGateway paymentGateway) {
this.paymentGateway = Objects.requireNonNull(paymentGateway);
}
}The service depends on the PaymentGateway abstraction rather than directly constructing a specific card processor. This improves testing and permits replacement, but an abstraction should represent a real variation point; unnecessary interfaces can increase complexity. 4.3 Responsibility assignmentA responsibility is a duty to know something or do something. Responsibilities should generally be assigned to the object that has the necessary information, while avoiding excessive dependence on unrelated objects. Useful heuristics include: - Information expert: assign behavior to the object with the required data.
- Creator: let a class create objects it closely aggregates or initializes.
- Controller: use an application-facing object to coordinate a system operation without placing all business logic in it.
- Low coupling: avoid unnecessary dependencies.
- High cohesion: keep related behavior together.
- Polymorphism: use varying behavior behind a common contract when type-specific rules are expected.
For example, an Order should usually calculate its own total because it owns order lines. A CheckoutController may coordinate payment and shipping, but should not directly manipulate private order-line state. 5. Validation and Domain BoundariesValidation has several layers: | Layer | Main purpose | Example |
|---|
| Input validation | Reject malformed external data | Required HTTP field | | Domain validation | Protect business invariants | Quantity must be positive | | Application validation | Check operation context | Customer may place order | | Persistence validation | Protect storage constraints | Unique database key |
These layers overlap but are not interchangeable. Client-side validation improves usability but cannot protect a server-side domain model. Database constraints provide a final safeguard but should not be the only place business rules exist. Use exceptions for invalid operations when the caller cannot reasonably continue. For expected, user-correctable validation failures, an application may instead return a structured error result. The choice depends on the API contract and operational context. Validation should also distinguish syntax from meaning. A string may have a valid email-like format but refer to an unverified account. A date may be correctly formatted but violate a booking rule. Domain validation requires domain knowledge. 6. Worked Domain Model: Online Order ManagementThe following illustrative model is intentionally small. It demonstrates value objects, enums, encapsulation, aggregate-like boundaries, and responsibility assignment. public record ProductId(String value) {
public ProductId {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException("Product ID is required");
}
}
}
public record Money(BigDecimal amount, Currency currency) {
public Money {
Objects.requireNonNull(amount);
Objects.requireNonNull(currency);
if (amount.signum() < 0) {
throw new IllegalArgumentException("Money cannot be negative");
}
}
public Money add(Money other) {
if (!currency.equals(other.currency)) {
throw new IllegalArgumentException("Currencies must match");
}
return new Money(amount.add(other.amount), currency);
}
public Money multiply(int factor) {
if (factor < 0) {
throw new IllegalArgumentException("Factor cannot be negative");
}
return new Money(amount.multiply(BigDecimal.valueOf(factor)), currency);
}
}The Money record protects two important rules: its amount is non-negative, and arithmetic is not silently performed across different currencies. A production implementation would also define scale and rounding policy. public final class OrderLine {
private final ProductId productId;
private final Money unitPrice;
private final int quantity;
public OrderLine(ProductId productId, Money unitPrice, int quantity) {
this.productId = Objects.requireNonNull(productId);
this.unitPrice = Objects.requireNonNull(unitPrice);
if (quantity <= 0) {
throw new IllegalArgumentException("Quantity must be positive");
}
this.quantity = quantity;
}
public Money subtotal() {
return unitPrice.multiply(quantity);
}
public ProductId productId() { return productId; }
public int quantity() { return quantity; }
}OrderLine owns the rule that quantity must be positive and calculates its subtotal because it knows both price and quantity. The order need not reproduce that calculation.
public final class Order {
private final UUID id;
private final List<OrderLine> lines = new ArrayList<>();
private OrderStatus status = OrderStatus.NEW;
public Order(UUID id) {
this.id = Objects.requireNonNull(id);
}
public void addLine(OrderLine line) {
if (status != OrderStatus.NEW) {
throw new IllegalStateException("Only new orders can be changed");
}
lines.add(Objects.requireNonNull(line));
}
public Money total() {
if (lines.isEmpty()) {
throw new IllegalStateException("An order must contain at least one line");
}
Money total = lines.get(0).subtotal();
for (int i = 1; i < lines.size(); i++) {
total = total.add(lines.get(i).subtotal());
}
return total;
}
public List<OrderLine> lines() {
return List.copyOf(lines);
}
public void markPaid() {
if (status != OrderStatus.NEW) {
throw new IllegalStateException("Order cannot be paid in its current state");
}
status = OrderStatus.PAID;
}
public OrderStatus status() { return status; }
}The order protects its line collection, controls legal state transitions, and owns the total calculation. It does not process credit cards or send shipping emails. Those responsibilities belong to collaborators such as a payment gateway and shipping service. Worked calculationSuppose an order contains: - three units at 12.50 USD each;
- two units at 8.00 USD each.
The total is calculated as: Total = (3 × 12.50) + (2 × 8.00) = 37.50 + 16.00 = 53.50 USD The variables represent quantity and unit price, and the calculation assumes that all lines use the same currency and that no tax, discount, or shipping charge is included. If those policies exist, they should be modeled explicitly rather than hidden in an unexplained arithmetic expression. 7. Detailed Illustrative Case: Refactoring a Library SystemBackgroundA university library initially implements a LibraryManager class. The class stores books, registers members, calculates overdue fines, sends email notices, writes database records, and prints reports. All fields are public or accessed through broad setters. ProblemThe system exhibits low cohesion and high coupling. A change to fine policy requires editing the manager, database code, user-interface code, and several tests. Any caller can change a book's availability directly. Different screens calculate fines differently, producing inconsistent results. AnalysisThe domain contains distinct concepts: Book and BookId;Member and MemberId;Loan with checkout and due dates;FinePolicy for calculating fines;LoanRepository for persistence;NotificationService for communication;- an application service for coordinating checkout and return.
The Loan object has the knowledge needed to determine whether it is overdue. A policy object should calculate the amount because fine rules may vary. Persistence and notification are infrastructure concerns, not responsibilities of the domain entity. Approach and solution- Replace public fields with private state and operations such as
returnBook(). - Create value objects for identifiers and money.
- Use an enum such as
LoanStatus for a closed set of lifecycle states. - Put loan validity rules inside
Loan. - Inject
FinePolicy and repository abstractions into an application service. - Place public API types in an exported module package and keep adapters internal.
- Return immutable snapshots or unmodifiable collections.
- Add tests for invariants and state transitions.
A policy interface may be defined as: public interface FinePolicy {
Money calculate(Loan loan, LocalDate asOf);
}The application service coordinates: public final class ReturnBookService {
private final LoanRepository loans;
private final FinePolicy finePolicy;
public ReturnBookService(LoanRepository loans, FinePolicy finePolicy) {
this.loans = Objects.requireNonNull(loans);
this.finePolicy = Objects.requireNonNull(finePolicy);
}
public Money returnBook(UUID loanId, LocalDate date) {
Loan loan = loans.getRequired(loanId);
loan.returnOn(date);
Money fine = finePolicy.calculate(loan, date);
loans.save(loan);
return fine;
}
}The service coordinates the use case, while Loan retains its own state rules and FinePolicy owns variable fine calculation. The repository abstraction prevents the application service from depending on a particular database. OutcomeThe refactored design makes invalid state harder to create, permits fine policies to be tested independently, and limits the effect of infrastructure changes. It does not eliminate complexity; it moves complexity to explicit boundaries where it can be reasoned about. Learning points- A large class is not automatically cohesive merely because it serves one screen.
- Domain entities should protect rules that define their valid state.
- Application services coordinate but should not become containers for all business logic.
- Interfaces are valuable when they represent meaningful variation or boundaries.
- Immutability and controlled collections reduce accidental interference.
8. Limitations, Trade-offs, and Professional PracticeEncapsulation can be overused. Excessive getters and setters create the appearance of object orientation while leaving rules in procedural service code. Conversely, making every detail private without a usable behavior-oriented interface can make the model awkward and encourage reflection or duplication. Immutability may require object reconstruction and can create allocation overhead. In most business applications, the clarity and safety benefits outweigh that cost, but high-throughput systems should measure rather than assume. Defensive copying also has a cost for large collections. Modules can improve architecture, but migrating a legacy system may expose split packages, reflection dependencies, and unclear APIs. Frameworks sometimes require opens; such access should be limited and documented. Domain models must also consider security and governance. Encapsulation is not authorization: a private field does not decide whether a user is permitted to perform an operation. Authorization belongs at appropriate application and security boundaries. Sensitive data should not be exposed through toString, logs, exceptions, or serialized records. Personal data should be minimized and handled according to applicable privacy laws and institutional policies. AI-assisted code generation can produce classes with public mutable fields, unsafe collection exposure, incorrect money arithmetic, or invented validation rules. Generated code must therefore be reviewed against domain requirements, tested, and checked for data-protection implications. AI should assist design and implementation, not replace responsibility for the model's correctness. Best practices checklist- Keep fields private and expose behavior rather than unrestricted mutation.
- Validate at construction and at state-transition boundaries.
- Prefer immutable value objects for identifiers, money, dates, and measurements.
- Use records for suitable value-oriented data, while checking deep immutability.
- Use enums for stable closed alternatives.
- Organize packages around domain boundaries and cohesive responsibilities.
- Export only the module packages that form a deliberate public API.
- Depend on abstractions at genuine architectural boundaries.
- Protect collections from external mutation.
- Keep application services orchestral and domain objects knowledgeable.
- Test invariants, transitions, and boundary conditions.
Chapter SummaryEncapsulation combines state and behavior while controlling access; information hiding conceals decisions likely to change. Java access modifiers, private fields, defensive copying, and behavior-oriented methods help preserve invariants. Immutable classes and records reduce accidental state changes, while enums provide type-safe closed alternatives. Packages organize namespaces and support local collaboration. Java modules make dependencies explicit and expose only selected packages. High cohesion keeps related responsibilities together, while manageable coupling limits the consequences of change. Responsibility assignment places behavior with the object that has the relevant knowledge and uses coordinating services only where appropriate. Validation should occur at input, application, domain, and persistence boundaries according to the rule being protected. A maintainable domain model represents business concepts explicitly, protects valid state, separates domain logic from infrastructure, and treats security, privacy, testing, and operational constraints as part of professional design. Key TermsEncapsulation: Bundling state and behavior with controlled access. Information hiding: Concealing implementation decisions behind a stable interface. Invariant: A condition that remains true for every valid object. Precondition: A requirement that must hold before an operation executes. Postcondition: A guarantee established after an operation completes. Access modifier: A Java keyword controlling visibility of a type or member. Immutability: The property of not changing observable state after construction. Defensive copy: A copy made to prevent external mutation of internal state. Record: A concise Java type for value-oriented data with generated members. Enum: A Java type representing a fixed set of named constants. Package: A Java namespace that groups related types. Module: A Java deployment and encapsulation unit with declared dependencies and exports. Cohesion: The degree to which responsibilities within a unit belong together. Coupling: The degree of dependency between software units. Value object: An object defined primarily by its values rather than independent identity. Domain model: A software representation of concepts, rules, and relationships in a problem domain. Aggregate boundary: A boundary within which consistency and modification rules are coordinated. Dependency injection: Supplying a dependency from outside rather than constructing it internally. Responsibility assignment: The process of deciding which object should know or perform an operation. Review QuestionsShort Answer Questions- How do encapsulation and information hiding differ?
- Why should a class avoid returning a mutable internal collection directly?
- What is the principal difference between a package and a Java module?
- Why is a record not automatically deeply immutable?
- What is the relationship between cohesion and maintainability?
Long Answer Questions- Explain Java access modifiers and recommend a visibility strategy for a domain model.
- Compare immutable classes, records, and enums, including appropriate and inappropriate uses of each.
- Explain how cohesion and coupling influence package and module design.
- Describe how validation should be distributed across input, application, domain, and persistence boundaries.
- Analyze the responsibilities of
Order, OrderLine, a payment gateway, and an application service in an online ordering system.
Analytical / Application Questions- A class has twelve public setters, writes to a database, calculates discounts, and sends email. Identify its design problems and propose a responsibility-based refactoring.
- Design a Java model for a hotel reservation that prevents a reservation from having an invalid date range and prevents cancellation after check-in. Identify suitable value objects, enums, and invariants.
- A module must use a persistence implementation internally but expose only domain services to other modules. Propose a package and
module-info.java structure, explaining which packages should be exported or opened.
Multiple Choice Questions with AnswersQuestion 1Which design best protects an object's invariant? A. Make all fields public and document valid values B. Make fields private and enforce rules through controlled operations C. Use package names that describe the business domain D. Allow clients to replace the object's collection directly Correct Answer: B. Make fields private and enforce rules through controlled operations Question 2What does a Java module's exports directive do? A. Declares which packages are available to other modules B. Makes every field in the module public C. Adds a dependency on another module D. Prevents all reflection within the module Correct Answer: A. Declares which packages are available to other modules Question 3Which statement about records is correct? A. Records can extend any application class B. Records automatically make referenced mutable objects immutable C. Records provide generated value-oriented members such as accessors and equality D. Records must contain only static methods Correct Answer: C. Records provide generated value-oriented members such as accessors and equality Question 4Why is List.copyOf commonly used when exposing internal list state? A. It sorts the list alphabetically B. It returns an unmodifiable copy of the list C. It converts every element into a record D. It permits callers to update the original list safely Correct Answer: B. It returns an unmodifiable copy of the list Question 5Which class most naturally should calculate an order-line subtotal? A. The order line, because it knows its quantity and unit price B. The database adapter, because it stores the line C. The user-interface controller, because it displays the result D. The notification service, because it communicates totals Correct Answer: A. The order line, because it knows its quantity and unit price Question 6Which quality means that responsibilities within a class strongly belong together? A. Coupling B. Inheritance C. Cohesion D. Reflection Correct Answer: C. Cohesion Question 7What is a suitable use of an enum? A. A set of stable order lifecycle states B. A list of products changed daily by administrators C. A mutable database table D. An arbitrary collection of unrelated objects Correct Answer: A. A set of stable order lifecycle states Question 8In the worked order example, what is the total for three items at 12.50 USD and two items at 8.00 USD, before tax and shipping? A. 20.50 USD B. 37.50 USD C. 45.50 USD D. 53.50 USD Correct Answer: D. 53.50 USD Question 9Which responsibility is most appropriate for an application service? A. Exposing every private field of a domain entity B. Coordinating a use case across domain objects and external abstractions C. Replacing all domain invariants with UI validation D. Allowing direct mutation of persistence records Correct Answer: B. Coordinating a use case across domain objects and external abstractions Question 10Why can public setters weaken a domain model? A. They always make a program slower B. They prevent classes from being placed in packages C. They allow state changes without enforcing domain rules D. They make enums impossible to compile Correct Answer: C. They allow state changes without enforcing domain rules
|