Encapsulation, Abstraction, and Modular DesignIntroductionObject-oriented programming (OOP) is not only a way to represent data as objects. It is also a discipline for controlling dependencies, protecting invariants, expressing contracts, and organizing software so that it can evolve safely. Three closely related ideas are central to this discipline: - Encapsulation controls how an object’s state is accessed and changed.
- Abstraction exposes essential behavior while hiding unnecessary implementation detail.
- Modular design organizes related responsibilities into cohesive, replaceable units with stable public interfaces.
These ideas matter in Java and Python because software systems are rarely built once and left unchanged. Requirements change, teams work in parallel, security threats evolve, and internal implementations are optimized or replaced. A design that exposes every implementation detail makes such change expensive. A design with carefully protected state and stable contracts allows internal improvements without breaking clients. Learning ObjectivesBy the end of this chapter, you should be able to: - Explain information hiding, encapsulation, and access control.
- Use Java access modifiers and Python naming conventions appropriately.
- Design properties, validation rules, and immutable objects.
- Distinguish abstraction from encapsulation.
- Apply abstract classes, interfaces, and Python protocols.
- Design cohesive modules with low unnecessary coupling.
- Create stable public APIs and assess compatibility risks.
- Evaluate design decisions in a realistic application scenario.
1. Information Hiding and Encapsulation1.1 The central design problemSuppose a BankAccount object exposes its balance as a publicly writable field. Any client can assign a negative balance, bypass transaction history, or change the balance without authorization. The object no longer controls its own validity. An invariant is a condition that should remain true for a valid object. For a simple bank account, possible invariants include: - the account identifier cannot change after creation;
- the balance cannot become negative, unless overdrafts are explicitly supported;
- withdrawals must be recorded;
- a withdrawal cannot exceed the permitted balance.
Encapsulation places data and the operations that govern that data together. The object becomes responsible for preserving its invariants. Information hiding is the deliberate decision not to expose design details that clients do not need to know. Encapsulation is therefore more than making fields private. It involves: - identifying state that must be protected;
- defining legal operations on that state;
- validating requests at the boundary;
- exposing only the behavior needed by clients;
- preventing clients from depending on unstable representation details.
1.2 Access control in JavaJava provides four commonly discussed access levels: | Modifier | Same class | Same package | Subclass in another package | Unrelated class |
|---|
private | Yes | No | No | No | | package-private (no modifier) | Yes | Yes | No | No | protected | Yes | Yes | Yes, under inheritance rules | No | public | Yes | Yes | Yes | Yes |
A robust default is to keep fields private and expose deliberate methods rather than public mutable state. public final class BankAccount {
private final String accountNumber;
private long balanceInCents;
public BankAccount(String accountNumber, long openingBalanceInCents) {
if (accountNumber == null || accountNumber.isBlank()) {
throw new IllegalArgumentException("Account number is required");
}
if (openingBalanceInCents < 0) {
throw new IllegalArgumentException("Opening balance cannot be negative");
}
this.accountNumber = accountNumber;
this.balanceInCents = openingBalanceInCents;
}
public String getAccountNumber() {
return accountNumber;
}
public long getBalanceInCents() {
return balanceInCents;
}
public void deposit(long amountInCents) {
if (amountInCents <= 0) {
throw new IllegalArgumentException("Deposit must be positive");
}
balanceInCents += amountInCents;
}
public void withdraw(long amountInCents) {
if (amountInCents <= 0 || amountInCents > balanceInCents) {
throw new IllegalArgumentException("Invalid withdrawal");
}
balanceInCents -= amountInCents;
}
}The class protects its state, validates inputs, and expresses financial operations in domain terms. Notice that money is represented as integer cents rather than binary floating-point values. This avoids many rounding problems associated with values such as 0.1 in floating-point arithmetic. The unit of balanceInCents is cents, and all amounts must use the same unit. A getter is not automatically good encapsulation. Returning a mutable internal collection can leak representation: public List<String> getTags() {
return tags; // unsafe if tags is mutable
}Safer alternatives include returning an unmodifiable view or a defensive copy: public List<String> getTags() {
return List.copyOf(tags);
}1.3 Encapsulation in PythonPython does not enforce Java-style private access at the language level. Its conventions are nevertheless meaningful: _name indicates an internal attribute or method;__name triggers name mangling, reducing accidental access from subclasses;__name__ is normally reserved for Python’s special methods.
Python properties allow attribute-like syntax while executing validation logic. class BankAccount:
def __init__(self, account_number: str, opening_balance_cents: int = 0):
if not account_number:
raise ValueError("account number is required")
if opening_balance_cents < 0:
raise ValueError("opening balance cannot be negative")
self._account_number = account_number
self._balance_cents = opening_balance_cents
@property
def account_number(self) -> str:
return self._account_number
@property
def balance_cents(self) -> int:
return self._balance_cents
def deposit(self, amount_cents: int) -> None:
if amount_cents <= 0:
raise ValueError("deposit must be positive")
self._balance_cents += amount_cents
def withdraw(self, amount_cents: int) -> None:
if amount_cents <= 0 or amount_cents > self._balance_cents:
raise ValueError("invalid withdrawal")
self._balance_cents -= amount_centsThe absence of a setter for account_number communicates that the value is read-only through the public API. It is not absolute security: Python programmers can technically access _balance_cents. The design relies on convention, code review, testing, and disciplined use of interfaces. 2. Properties, Validation, and ImmutabilityA property is an object value that can be read, calculated, or changed through a controlled interface. A property need not correspond directly to a stored field. For example, a Rectangle may store width and height while calculating area when requested. Validation should occur at the boundary where invalid data enters the object. Delaying validation creates objects whose validity is uncertain and shifts error detection far from the cause. A useful validation policy distinguishes: - type validity: is the value of an appropriate type?
- range validity: is it within permitted limits?
- semantic validity: does it make sense in the domain?
- relationship validity: is it consistent with other values?
For example, an appointment may require a valid date, a duration greater than zero, and an end time later than its start time. 2.1 Immutable objectsAn immutable object cannot change observable state after construction. Instead of modifying it, an operation returns a new object. Immutability simplifies reasoning, testing, caching, and concurrent use. A Java record is concise for a value-oriented immutable type, although immutability of referenced objects still requires care: public record Money(String currency, long cents) {
public Money {
if (currency == null || currency.isBlank()) {
throw new IllegalArgumentException("Currency is required");
}
if (cents < 0) {
throw new IllegalArgumentException("Negative money is not allowed");
}
}
}The record’s components are final, but a record containing a mutable list would not automatically make that list immutable. A defensive copy is required. Python’s dataclass can express a frozen value object: from dataclasses import dataclass
@dataclass(frozen=True)
class Money:
currency: str
cents: int
def __post_init__(self):
if not self.currency:
raise ValueError("currency is required")
if self.cents < 0:
raise ValueError("negative money is not allowed")frozen=True prevents ordinary field reassignment, but immutability is still a design claim that must include referenced objects and any exposed resources.
Use immutability when a value represents a fact, such as a date range, coordinate, identifier, or monetary amount. Use controlled mutability when an object represents a changing process, such as a bank account or shopping cart. 3. Abstraction and ContractsAbstraction focuses on what an object does rather than how it does it. A payment service might provide charge(amount) without requiring clients to understand HTTP, encryption, retries, or database transactions. Encapsulation protects internal state; abstraction simplifies the client’s mental model. They reinforce one another but are not identical. A class can hide fields while still exposing a confusing abstraction, and it can offer a clear abstraction while failing to protect mutable state. A useful public contract specifies: - operation names and parameters;
- return values and observable effects;
- valid input conditions;
- errors or exceptions;
- performance or reliability expectations where relevant;
- ownership and mutability rules.
3.1 Abstract classes in JavaAn abstract class may contain shared state, implemented methods, constructors, and abstract methods. It is useful when related types share a substantial implementation or invariant. public abstract class Notification {
private final String recipient;
protected Notification(String recipient) {
if (recipient == null || recipient.isBlank()) {
throw new IllegalArgumentException("Recipient is required");
}
this.recipient = recipient;
}
public final String recipient() {
return recipient;
}
public abstract void send(String message);
}The final method prevents subclasses from changing a rule that should be common to every notification. Abstract classes are not appropriate merely because inheritance is available; they should represent a genuine “is-a” relationship and a coherent shared abstraction. 3.2 Interfaces in JavaAn interface expresses a capability or contract. A class can implement multiple interfaces, which allows composition of roles without inheriting multiple class implementations. public interface PaymentGateway {
PaymentReceipt charge(Money amount);
}A checkout service can depend on PaymentGateway rather than on a specific provider: public final class CheckoutService {
private final PaymentGateway gateway;
public CheckoutService(PaymentGateway gateway) {
this.gateway = gateway;
}
public PaymentReceipt pay(Money amount) {
return gateway.charge(amount);
}
}This is dependency inversion in practical form: high-level business logic depends on a stable abstraction, while provider-specific adapters implement that abstraction. A test can supply a fake gateway without contacting a real payment system. 3.3 Python protocolsPython supports abstract base classes, but protocols provide structural subtyping. A type conforms to a protocol if it supplies the required operations, even when it does not explicitly inherit from the protocol. from typing import Protocol
class PaymentGateway(Protocol):
def charge(self, amount_cents: int) -> str:
...
class CheckoutService:
def __init__(self, gateway: PaymentGateway):
self._gateway = gateway
def pay(self, amount_cents: int) -> str:
return self._gateway.charge(amount_cents)A class with a compatible charge method can be passed to CheckoutService. Protocols are especially useful for dependency injection and testing. They primarily support static analysis; Python’s runtime generally does not enforce every annotation. 3.4 Liskov substitution and contract qualityThe Liskov Substitution Principle states that objects of a subtype should be usable wherever objects of the supertype are expected without invalidating client assumptions. A subtype should not unexpectedly strengthen input requirements, weaken promised results, or introduce incompatible side effects. For example, if Bird promises fly(), making Penguin inherit from it may produce a misleading abstraction. The issue is not that penguins are biologically unrelated to birds; it is that the software contract is poorly modeled. Prefer capabilities such as Flyable when only some birds can fly. 4. Modular DesignA module is a boundary around related code, often represented by a Java package or Python package/module. Good modular design aims for: - high cohesion: the elements of a module support a focused purpose;
- low coupling: modules depend on as little unnecessary detail as possible;
- explicit dependencies: required collaborators are visible;
- stable boundaries: clients use documented contracts rather than internals;
- replaceability: an implementation can change behind the boundary.
A module should have a clear reason to change. A payment module, for example, should not also contain HTML rendering and user-password hashing merely because all three are used by the same application. 4.1 Cohesion and couplingCohesion concerns internal unity. A module containing invoice calculation, invoice persistence, and invoice formatting may have several related responsibilities, but separating policy from infrastructure often produces clearer boundaries. Coupling is not always bad. A useful dependency is better than duplicated logic. The design goal is appropriate coupling: dependencies should be necessary, small, explicit, and directed toward stable abstractions. Common warning signs include: - clients constructing many internal helper objects;
- public methods exposing database entities or framework classes unnecessarily;
- circular dependencies between packages;
- “god classes” that own unrelated responsibilities;
- modules that must change together for trivial modifications.
4.2 Public APIs and compatibilityA public API includes more than public method names. It may include package paths, constructor signatures, serialized formats, exception types, command-line options, and documented behavior. An API is source-compatible when existing source code can still compile or type-check. It is binary-compatible when previously compiled clients can still link and run. It is behaviorally compatible when observable results and error behavior remain acceptable. A change can preserve one form and break another. For example, changing a Java method’s parameter type may break source and binary compatibility. Changing an API to return a mutable list instead of an immutable view may preserve compilation but break behavioral assumptions. Best practices for stable APIs include: - expose the smallest useful surface;
- prefer domain types over framework-specific types at boundaries;
- document nullability, ownership, mutability, and exceptions;
- use semantic versioning where appropriate;
- deprecate before removing widely used features;
- test public behavior, not private implementation details;
- avoid leaking collections, credentials, database sessions, or internal entities;
- keep constructors manageable; use factories or builders for complex creation.
5. Worked Design Example: A Notification ModuleConsider a web application that sends account alerts by email and text message. A naïve design places provider credentials, message formatting, network calls, and retry logic in a single UserAccount class. That class becomes difficult to test and risky to modify. A modular design separates responsibilities: Alert: immutable domain data;MessageSender: stable sending abstraction;EmailSender and SmsSender: provider-specific adapters;AlertService: application policy and orchestration;- configuration module: credentials and provider settings.
from dataclasses import dataclass
from typing import Protocol
@dataclass(frozen=True)
class Alert:
recipient: str
subject: str
body: str
class MessageSender(Protocol):
def send(self, alert: Alert) -> None:
...
class AlertService:
def __init__(self, sender: MessageSender):
self._sender = sender
def account_locked(self, email: str) -> None:
alert = Alert(
recipient=email,
subject="Account locked",
body="Contact support to restore access."
)
self._sender.send(alert)A fake sender can test the service: class FakeSender:
def __init__(self):
self.sent = []
def send(self, alert: Alert) -> None:
self.sent.append(alert)The service does not know whether delivery uses SMTP, an HTTP provider, or a test double. The immutable Alert prevents a sender from silently changing the message after creation. The protocol defines the required capability without forcing provider classes into an inheritance hierarchy. 6. Case Study: Refactoring an Online BookstoreThe following is an illustrative case based on common software engineering conditions. BackgroundAn online bookstore initially implements checkout in one class. The class calculates discounts, accesses database records, calls a payment provider, sends email, and formats a web response. After two years, small changes frequently cause regressions. ProblemThe team identifies four symptoms: - tests require a live payment account;
- changing the discount policy requires editing database code;
- customers occasionally receive two confirmation emails after a retry;
- mobile and web clients depend on internal database objects.
AnalysisThe class has low cohesion and excessive coupling. Its public methods expose persistence details, and its collaborators are created internally, making substitution difficult. Payment and notification behavior are not abstracted. The duplicate email problem also shows that encapsulation alone cannot guarantee correct distributed behavior; the system needs an explicit idempotency policy. ApproachThe team introduces: - an immutable
OrderSummary value object; - a
DiscountPolicy interface/protocol; - a
PaymentGateway interface/protocol; - a
Notifier interface/protocol; - a checkout application service that receives these dependencies through its constructor;
- adapters for the database, payment provider, and email provider;
- an idempotency key derived from the checkout request identifier.
The public application service returns a stable result object rather than a database entity. Provider credentials remain inside infrastructure modules. Tests use fakes for payment and notification. OutcomeUnit tests run without external services. A new promotional policy can be added without changing checkout orchestration. The provider can be replaced behind the gateway contract. Repeated requests with the same idempotency key do not create a second payment or notification, provided the underlying operations and persistence design correctly record and enforce that key. Learning points- Dependency injection makes abstractions practical.
- Immutable value objects reduce accidental state changes.
- Stable API boundaries protect clients from persistence changes.
- Idempotency is a behavioral requirement, not merely an access-control feature.
- Modular design improves deployability and testing, but it does not remove the need for transaction, security, and failure analysis.
7. Limitations, Ethics, and Contemporary PracticeEncapsulation is not a substitute for security. A private field may prevent accidental misuse but does not by itself provide authorization, encryption, auditability, or protection from a compromised process. Sensitive data should be minimized, encrypted where appropriate, and excluded from logs unless there is a justified operational need. Abstraction can also be harmful when it hides important costs or failure modes. A method named send() may conceal network latency, retries, billing, or data transfer. Public contracts should document significant side effects and failure behavior. Excessive abstraction produces needless interfaces, wrappers, and indirection. The goal is not to maximize the number of classes; it is to place meaningful boundaries around change. A small application may need only a few modules, while a large system may need explicit domain, application, and infrastructure layers. Modern development increasingly uses static analysis, automated API documentation, package dependency checks, and AI-assisted code generation. These tools can suggest access modifiers or generate interfaces, but they do not determine whether an abstraction reflects the domain correctly. AI-generated code must be reviewed for leaked secrets, unsafe mutability, incorrect validation, license obligations, and misleading contracts. Human developers remain responsible for architectural and ethical decisions. Best-Practice ChecklistBefore publishing a class or module, ask: - Can invalid state be created through the public API?
- Are mutable collections and objects safely owned?
- Does each public operation express a meaningful domain action?
- Is the abstraction smaller than the implementation it hides?
- Could a fake or alternative implementation satisfy the contract?
- Does the module have one coherent reason to change?
- Are dependencies explicit and directed toward stable interfaces?
- Are exceptions, side effects, mutability, and security assumptions documented?
- Would a change break source, binary, or behavioral compatibility?
- Are tests checking the contract rather than private representation?
Chapter SummaryEncapsulation combines state with the operations that preserve its invariants, while information hiding prevents clients from depending on unnecessary representation details. Java uses enforced access modifiers; Python relies primarily on conventions, properties, and disciplined interfaces. Properties and validation create controlled boundaries, and immutable objects are valuable for stable domain values and safe sharing. Abstraction defines essential behavior and removes irrelevant implementation detail. Java abstract classes are appropriate for shared implementation and state; interfaces express capabilities and contracts. Python protocols support structural typing and flexible dependency injection. A sound abstraction respects substitutability and does not promise behavior that some implementations cannot provide. Modular design seeks high cohesion, appropriate low coupling, explicit dependencies, and stable public APIs. Dependency injection allows infrastructure to be replaced with fakes or alternative providers. Public API stability includes source, binary, and behavioral compatibility. Encapsulation and abstraction improve security and maintainability but do not replace authorization, privacy controls, failure handling, or careful governance. Key TermsEncapsulation: Combining state and behavior while controlling access to internal representation. Information hiding: Keeping implementation decisions private so clients depend only on stable contracts. Invariant: A condition that must remain true for an object to be valid. Access control: Rules determining which code can use a class member. Property: A controlled object value exposed through read or write behavior. Immutability: The inability of an object’s observable state to change after construction. Abstraction: Representation of essential behavior while omitting unnecessary detail. Contract: The documented inputs, outputs, effects, and constraints of an operation or type. Abstract class: A class intended as a partial implementation or conceptual base that may contain abstract operations. Interface: A contract describing capabilities that implementing types provide. Protocol: In Python, a structural typing specification based on required operations. Cohesion: The degree to which a module’s elements belong together. Coupling: The degree of dependency between software components. Module: A bounded unit of code with defined responsibilities and dependencies. Public API: The externally usable and supported surface of a component. Dependency injection: Supplying collaborators from outside rather than constructing them internally. Defensive copy: A copied value returned or stored to prevent unwanted mutation of internal state. Idempotency: A property in which repeating an operation produces the same intended effect as performing it once. Liskov Substitution Principle: The requirement that subtype objects remain valid substitutes for their declared supertypes. Review QuestionsShort Answer Questions- What is the difference between encapsulation and information hiding?
- Why should a class avoid returning a mutable internal collection directly?
- State one important difference between a Java interface and an abstract class.
- What does high cohesion mean in modular design?
- Why are immutable value objects useful?
Long Answer Questions- Explain how access control, validation, and invariants work together in a well-encapsulated class. Use Java or Python examples.
- Compare Java interfaces, Java abstract classes, and Python protocols. Identify an appropriate use for each.
- Explain source, binary, and behavioral compatibility and describe how an API change might preserve one while breaking another.
- Discuss how dependency injection supports modularity, testing, and replacement of infrastructure.
- Evaluate the claim that “more abstraction always produces better software.” Include benefits, risks, and criteria for deciding when abstraction is justified.
Analytical / Application Questions- A
Report class calculates sales totals, reads database rows, creates PDF files, and emails managers. Identify cohesion and coupling problems and propose a modular redesign. - Design a stable
ShippingCalculator abstraction that supports standard, express, and international shipping. State its contract, validation rules, and how you would test it with substitute implementations. - A Python service exposes its internal list of customer permissions through a property. Analyze the security and maintenance risks, then propose a safer design and explain its limitations.
Multiple Choice Questions with AnswersQuestion 1Which design most directly preserves an object invariant? A. Making every field public B. Allowing clients to modify state freely C. Validating state-changing operations inside the class D. Returning internal collections directly Correct Answer: C. Validating state-changing operations inside the class Question 2What does a Java private field primarily provide? A. Access limited to the declaring class B. Access to every subclass in every package C. Automatic encryption of the field D. Automatic immutability of referenced objects Correct Answer: A. Access limited to the declaring class Question 3Which statement about Python properties is correct? A. They require a database connection B. They can provide controlled access using attribute-like syntax C. They make every object immutable D. They replace all type annotations Correct Answer: B. They can provide controlled access using attribute-like syntax Question 4Which characteristic best describes an immutable object? A. Its observable state cannot change after construction B. It has no methods C. It must contain only integer fields D. It cannot be passed to another function Correct Answer: A. Its observable state cannot change after construction Question 5When is a Java abstract class generally more suitable than an interface? A. When unrelated classes need unrelated behavior B. When shared state and implementation form part of a common abstraction C. When no common contract is required D. When multiple class inheritance is needed Correct Answer: B. When shared state and implementation form part of a common abstraction Question 6What is structural typing in the context of a Python protocol? A. A class must inherit from the protocol directly B. A type conforms by providing the required operations C. Only built-in types can conform D. The protocol stores all implementation state Correct Answer: B. A type conforms by providing the required operations Question 7Which module has the strongest cohesion? A. A module containing unrelated billing, image editing, and authentication logic B. A module focused on applying discount policies C. A module containing every class in the application D. A module that changes whenever any other module changes Correct Answer: B. A module focused on applying discount policies Question 8What is a principal benefit of dependency injection? A. It forces all classes to be singletons B. It allows collaborators to be replaced, including with test doubles C. It removes the need for public contracts D. It guarantees that network calls cannot fail Correct Answer: B. It allows collaborators to be replaced, including with test doubles Question 9Which action is most likely to break behavioral compatibility while preserving compilation? A. Renaming a public method B. Changing a return type incompatibly C. Returning a mutable collection where callers expected read-only behavior D. Deleting a required constructor Correct Answer: C. Returning a mutable collection where callers expected read-only behavior Question 10Why can a private field still fail to provide complete security? A. Private fields are always public at runtime B. Access control does not itself provide authorization, encryption, or auditability C. Private fields cannot store strings D. Private fields prevent all validation Correct Answer: B. Access control does not itself provide authorization, encryption, or auditability
|