Java Good Class Design: Packages, Access Control & SOLID Principles
Java Package System ยท Package Declaration & import ยท Package-Private vs Public API Design ยท Naming Conventions ยท Single Responsibility Principle ยท Cohesion vs Coupling ยท Encapsulation Violation Patterns (Anti-Patterns) ยท Access Modifier Decision Flowchart
Mastering professional class design in Java: the package system for organizing and isolating code, package-private boundary enforcement, access modifier decision frameworks, coupling vs cohesion trade-offs, and recognizing common encapsulation anti-patterns that create fragile, hard-to-maintain software.
1. The Java Package System
A Package is a namespace mechanism that groups related classes and interfaces together, serving two purposes:
1. Organization: Hierarchically categorizes classes (like folders on a filesystem).
2. Access Control: The default (package-private) modifier creates an internal API boundary visible only within the package.
Package Naming Convention (Reverse Domain):
package com.ourcompiler.java.tutorial.phase10;
// com = top-level domain (reversed)
// ourcompiler = company name
// java.tutorial.phase10 = product and feature hierarchyDeclaring and Importing:
// In Animal.java:
package com.zoo.animals;
public class Animal { ... }
// In Main.java (different package):
package com.zoo.management;
import com.zoo.animals.Animal; // Import specific class
import com.zoo.animals.*; // Import all classes in package (Discouraged in production)
2. Package-Private: Building Internal API Boundaries
Using default (package-private) access strategically creates clean internal API boundaries:
// Package: com.company.payments
// INTERNAL implementation (package-private: only used inside the package)
class EncryptionUtil {
static String encrypt(String data) { ... }
}
class FraudDetector {
boolean isSuspicious(double amount) { ... }
}
// PUBLIC API (the only surface exposed to external packages)
public class PaymentProcessor {
public boolean processPayment(String card, double amount) {
String encrypted = EncryptionUtil.encrypt(card); // OK: same package
if (new FraudDetector().isSuspicious(amount)) return false; // OK: same package
// ... actual processing
return true;
}
}
// External packages ONLY see PaymentProcessor; EncryptionUtil and FraudDetector are hidden!
3. Good Class Design Principles
1. Single Responsibility Principle (SRP):
A class should have exactly one reason to change. Avoid God Classes that do everything:
Bad: class UserManager { register() + sendEmail() + saveToDatabase() + generateReport() }
Good: class UserRegistrar, class EmailSender, class UserRepository, class ReportGenerator2. High Cohesion โ Related Concepts Together:
All methods and fields in a class should relate to a single, well-defined concept. If a class has fields for both "employee details" and "payroll calculation rules", consider splitting them.
3. Low Coupling โ Minimize Dependencies:
A class should know as little as possible about other classes. Prefer working with abstract interfaces rather than concrete implementations.
4. Encapsulation as a First Principle:
Always default to private. Only expose a member when you have a specific, justified reason to do so.
4. The Access Modifier Decision Flowchart
When choosing an access modifier for a field or method, ask these questions in order:
Q1: Is this part of the PUBLIC API used by external packages?
YES โ public
NO โ Q2
Q2: Is this needed by SUBCLASSES in other packages?
YES โ protected
NO โ Q3
Q3: Is this shared among MULTIPLE classes in the SAME package?
YES โ default (no modifier)
NO โ private
The Default Answer is ALWAYS: private!
5. Encapsulation Anti-Patterns to Avoid
Anti-Pattern 1: Public Fields (God Mode Access)
// TERRIBLE!
class User { public String password; } // Anyone can read/write password!Anti-Pattern 2: Getter-Setter for Everything (Anemic Domain Model)
// Technically encapsulated but semantically broken:
account.setBalance(account.getBalance() - withdrawAmount); // Logic leaked outside!
// Better: account.withdraw(amount) โ keeps logic inside the class!Anti-Pattern 3: Returning Internal Mutable Collections
public List<String> getTags() { return tags; } // External code can add/remove tags!
// Better:
public List<String> getTags() { return Collections.unmodifiableList(tags); }Beginner Example & Code Anatomy
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
// ---------------------------------------------------------------
// WELL-DESIGNED ENCAPSULATED CLASS: CourseEnrollment System
// ---------------------------------------------------------------
class Course {
private final String courseId;
private final String title;
private final int maxCapacity;
private final List<String> enrolledStudentIds; // Mutable internally
public Course(String courseId, String title, int maxCapacity) {
if (maxCapacity <= 0) throw new IllegalArgumentException("Capacity must be positive.");
this.courseId = courseId;
this.title = title;
this.maxCapacity = maxCapacity;
this.enrolledStudentIds = new ArrayList<>();
}
// Read-only getters for immutable fields
public String getCourseId() { return courseId; }
public String getTitle() { return title; }
public int getMaxCapacity() { return maxCapacity; }
// Computed properties
public int getEnrolledCount() { return enrolledStudentIds.size(); }
public boolean isFullyBooked() { return enrolledStudentIds.size() >= maxCapacity; }
public int getAvailableSeats() { return maxCapacity - enrolledStudentIds.size(); }
// Return UNMODIFIABLE view โ prevents external mutation!
public List<String> getEnrolledStudents() {
return Collections.unmodifiableList(enrolledStudentIds);
}
// Business operation (logic stays inside the class)
public boolean enroll(String studentId) {
if (studentId == null || studentId.isBlank()) return false;
if (isFullyBooked()) {
System.out.println(" [ENROLL FAIL] Course '" + title + "' is fully booked!");
return false;
}
if (enrolledStudentIds.contains(studentId)) {
System.out.println(" [ENROLL FAIL] Student " + studentId + " already enrolled.");
return false;
}
enrolledStudentIds.add(studentId);
System.out.printf(" [ENROLL OK] %s enrolled in '%s'. Seats remaining: %d%n",
studentId, title, getAvailableSeats());
return true;
}
public boolean unenroll(String studentId) {
boolean removed = enrolledStudentIds.remove(studentId);
if (removed) System.out.printf(" [UNENROLL] %s removed from '%s'.%n", studentId, title);
return removed;
}
@Override
public String toString() {
return String.format("Course{id='%s', title='%s', seats=%d/%d, booked=%b}",
courseId, title, getEnrolledCount(), maxCapacity, isFullyBooked());
}
}
public class Main {
public static void main(String[] args) {
System.out.println("=== Course Enrollment System ===");
Course javaAdvanced = new Course("CS-401", "Advanced Java Programming", 3);
System.out.println("Initial State: " + javaAdvanced);
System.out.println("
--- Enrollment Phase ---");
javaAdvanced.enroll("STU-001");
javaAdvanced.enroll("STU-002");
javaAdvanced.enroll("STU-001"); // Duplicate!
javaAdvanced.enroll("STU-003"); // Last seat!
javaAdvanced.enroll("STU-004"); // Should FAIL โ fully booked
System.out.println("
Full State: " + javaAdvanced);
System.out.println("
--- Unmodifiable List Protection ---");
List<String> students = javaAdvanced.getEnrolledStudents();
System.out.println("Enrolled students: " + students);
try {
students.add("STU-HACKER"); // Attempt external mutation!
} catch (UnsupportedOperationException e) {
System.out.println("External mutation BLOCKED! Unmodifiable list protected internal state.");
}
System.out.println("
--- Unenrollment ---");
javaAdvanced.unenroll("STU-002");
System.out.println("After unenroll: " + javaAdvanced);
System.out.println("
--- Good Design Check ---");
System.out.printf("Available seats: %d | Is full: %b%n",
javaAdvanced.getAvailableSeats(), javaAdvanced.isFullyBooked());
}
}
๐ Line-by-Line Code Explanation
Collections.unmodifiableList(enrolledStudentIds)
Wraps the internal list in an immutable view โ callers can read but cannot add, remove, or clear elements.
public boolean enroll(String studentId)
Business logic lives INSIDE the class (not in caller code), keeping rules cohesive and centralized.
public boolean isFullyBooked()
Computed boolean property derived from comparing list size and capacity โ no backing field needed.
private final List<String> enrolledStudentIds
Private final reference prevents reassigning the list variable, but internal add/remove operations still work.
Practical Real-World Example
// Industry Simulation: Configuration Management Module
// Internal helper (package-private โ hidden from external packages)
class ConfigValidator {
static void validateKey(String key) {
if (key == null || !key.matches("[A-Z_]+"))
throw new IllegalArgumentException("Invalid config key: " + key);
}
}
// Public API class
public class PracticalApplication {
// Simulated config store
static java.util.Map<String, String> config = new java.util.HashMap<>();
public static void setConfig(String key, String value) {
ConfigValidator.validateKey(key); // Internal utility โ same package
config.put(key, value);
}
public static String getConfig(String key) {
return config.getOrDefault(key, "NOT_SET");
}
public static void main(String[] args) {
setConfig("DB_HOST", "localhost");
setConfig("DB_PORT", "5432");
System.out.println("DB_HOST: " + getConfig("DB_HOST"));
System.out.println("DB_PORT: " + getConfig("DB_PORT"));
System.out.println("DB_PASS: " + getConfig("DB_PASS")); // Not set
}
}
- Returning a mutable
ListorMapdirectly from a getter โ useCollections.unmodifiableList()or return a copy. - Using wildcard imports (
import com.company.*;) in production code โ always import specific classes for clarity. - Putting all classes in the default (unnamed) package โ this prevents using the default access modifier effectively.
- Designing "Anemic Domain Models" where classes have only getters/setters and all logic lives in external "Service" classes.
Test your understanding by writing the code directly in your editor or running in our online Java compiler:
// Coding Challenge:
// Create a Playlist class:
// 1. private final String playlistName (read-only).
// 2. private List<String> songs (mutable internally, unmodifiable externally).
// 3. Public methods: addSong(String song), removeSong(String song), getSongs(), contains(String).
// 4. Return an unmodifiable view from getSongs().
import java.util.*;
class Playlist {
private final String playlistName;
private final List<String> songs = new ArrayList<>();
public Playlist(String name) {
if (name == null || name.isBlank()) throw new IllegalArgumentException("Name required.");
this.playlistName = name.trim();
}
public String getPlaylistName() { return playlistName; }
public List<String> getSongs() { return Collections.unmodifiableList(songs); }
public boolean contains(String song) { return songs.contains(song); }
public void addSong(String song) {
if (song != null && !song.isBlank() && !contains(song)) {
songs.add(song.trim());
}
}
public void removeSong(String song) { songs.remove(song); }
}
public class Challenge {
public static void main(String[] args) {
Playlist p = new Playlist("Coding Vibes");
p.addSong("Lo-Fi Hip Hop"); p.addSong("Chill Beats"); p.addSong("Focus Zone");
System.out.println("Playlist: " + p.getSongs());
}
}
๐ก Frequently Asked Questions & Interview Insights
โ What is the unnamed default package in Java?
Classes without a `package` declaration belong to the unnamed default package. While fine for quick experiments, production code should always use named packages for proper access control and modularity.
โ What does Collections.unmodifiableList() do?
It wraps an existing List in an unmodifiable view. Any attempt to call `add()`, `remove()`, or `clear()` on the returned view throws `UnsupportedOperationException` at runtime. The underlying list itself is still modifiable via the original reference.
โ What is the difference between high cohesion and low coupling?
Cohesion measures how closely related the responsibilities within a single class are (high = good). Coupling measures how dependent a class is on other classes (low = good). Good design achieves both simultaneously.
๐ Quick Chapter Recap
- Java packages provide hierarchical namespacing and package-level access boundaries.
- Package-private (default) access creates clean internal API isolation without exposing internals.
- Always return
Collections.unmodifiableList()or copies when exposing mutable collection fields. - Business logic should live inside the class (cohesive), not scattered in external caller code.
- Default access modifier choice:
privateโ widen only when there is a justified architectural need.