JAVA / CLASSES, CONSTRUCTORS AND ENCAPSULATION
Designing small classes with a single responsibility
Split a class that does two jobs into cohesive ones by tracking which fields each method reads, and keep layout and printing out of data classes.
What you will learn
- Find a hidden second class by mapping which methods read which fields
- Keep formatting and printing out of data classes, in a class that owns the layout
- Reject a field whose value changes for a different reason than the rest
- Name extracted classes after what they know, not Helper or Manager
Understanding Designing small classes with a single responsibility
The useful test for "this class is too big" is not line count, it is how many unrelated reasons force you to open the file. A class that holds an order's item, quantity and price and also builds the receipt text has two: pricing rules change when the business changes, and the receipt layout changes when someone wants a different look. In Java the tell is mechanical rather than aesthetic: list the fields, then mark for each method which fields it actually reads. If the marks fall into groups that never overlap, you already have two classes sharing one pair of braces.
Behaviour belongs next to the data it reads. subtotal() belongs on Order because it reads quantity and unitPrice, both private, so nothing has to be exposed for the calculation to happen. A method that reads nothing from this and only shuffles its parameters is a passenger: it belongs either on the class whose data it uses, or on a new class named after the thing it does own, such as a tax rate or an output format. The same reasoning says pagesPerDay does not belong on Book, because pace is a fact about a reader, and a Book would need one field per reader to store it.
The payoff shows up in concrete Java terms. Fields stay private because fewer classes need to see them, so getters appear only where a collaborator genuinely needs a value. You can write new Order("Keyboard", 2, 49.50) in a test without a printer, a tax table or a console, and when the receipt layout changes, Order is not edited, so nothing that relies on its invariants needs rechecking. You also gain names: new TaxPolicy(0.20) says what 0.20 means at the call site, which a bare double parameter never does.
import java.util.Locale;
class Order {
private final String item;
private final int quantity;
private final double unitPrice;
Order(String item, int quantity, double unitPrice) {
this.item = item;
this.quantity = quantity;
this.unitPrice = unitPrice;
}
String item() {
return item;
}
int quantity() {
return quantity;
}
double subtotal() {
return quantity * unitPrice;
}
}
class TaxPolicy {
private final double rate;
TaxPolicy(double rate) {
this.rate = rate;
}
double taxOn(double amount) {
return amount * rate;
}
}
class ReceiptPrinter {
String render(Order order, TaxPolicy policy) {
double subtotal = order.subtotal();
double tax = policy.taxOn(subtotal);
return String.format(Locale.US,
"%s x%d%nsubtotal %.2f%ntax %.2f%ntotal %.2f",
order.item(), order.quantity(), subtotal, tax, subtotal + tax);
}
}
public class Main {
public static void main(String[] args) {
Order order = new Order("Keyboard", 2, 49.50);
TaxPolicy vat = new TaxPolicy(0.20);
System.out.println(new ReceiptPrinter().render(order, vat));
}
}A class earns its existence by having exactly one reason to change, so it should hold only the data and behaviour that serve a single idea.
Worked examples
Two output formats, one data class
Shows that a display change adds a class instead of editing the class that holds the state.
class Task {
private final String name;
private final boolean done;
Task(String name, boolean done) {
this.name = name;
this.done = done;
}
String name() {
return name;
}
boolean done() {
return done;
}
}
class ChecklistView {
String render(Task task) {
return (task.done() ? "[x] " : "[ ] ") + task.name();
}
}
class CsvView {
String render(Task task) {
return task.name() + "," + task.done();
}
}
public class Main {
public static void main(String[] args) {
Task task = new Task("Write tests", true);
System.out.println(new ChecklistView().render(task));
System.out.println(new CsvView().render(task));
}
}Example explained
Line 1Task stores what is true about the task and holds no decision about how it looks.
Line 2ChecklistView.render owns exactly one layout rule: the [x] or [ ] prefix.
Line 3CsvView.render owns a different rule, and neither view can break the other.
Line 4A third format means one more class; Task is never reopened, so its state cannot be disturbed by a display change.
A field that belongs to the other class
Demonstrates deciding where a field lives by asking whose fact it is.
class Book {
private final String title;
private final int pages;
Book(String title, int pages) {
this.title = title;
this.pages = pages;
}
String title() {
return title;
}
int pages() {
return pages;
}
}
class ReadingPlan {
private final int pagesPerDay;
ReadingPlan(int pagesPerDay) {
this.pagesPerDay = pagesPerDay;
}
int daysFor(Book book) {
return (book.pages() + pagesPerDay - 1) / pagesPerDay;
}
}
public class Main {
public static void main(String[] args) {
Book book = new Book("Dune", 604);
System.out.println(book.title() + " at 50 pages/day: " + new ReadingPlan(50).daysFor(book) + " days");
System.out.println(book.title() + " at 80 pages/day: " + new ReadingPlan(80).daysFor(book) + " days");
}
}Example explained
Line 1pages is printed on the book itself, so pages() stays on Book.
Line 2pagesPerDay describes a reader's habit, so it is a field of ReadingPlan and can differ per instance.
Line 3daysFor reads only its own field plus book.pages(), which is why the rounding-up arithmetic lives in ReadingPlan.
Line 4Both plans share one Book with no duplicated state; adding a third pace touches neither Book nor the existing plans.
Important notes
Small is not the same as anemic: if a class ends up as fields plus getters while every calculation happens elsewhere, the split went too far and the data no longer protects itself.
Extract when a second reason to change actually appears, such as a second receipt layout or a second tax rule; a split with one use is indirection you read past forever.
Common mistakes
Splitting by kind instead of by responsibility, as in OrderData plus OrderLogic: the data half then needs a public getter for every field, so you end up with more classes and less encapsulation, and every change edits both.
Leaving System.out.println inside the data class: the computed value cannot be reused for a CSV export or asserted in a test without capturing console output, and every layout tweak reopens the class that guards the invariants.
Reading "one responsibility" as "one method" and producing PriceCalculator, TaxAdder and TotalRounder: main now has to know the exact order to call them, so the complexity moved to the caller instead of disappearing.
Try it yourself
Change, predict, then run
Start with one Student class that stores a name and three scores and also prints a formatted report line, then split it so Student exposes average() while a new ReportCard class builds the text. Run main and confirm the printed line is unchanged.
Open the Java workspaceCheck your understanding
A Book class holds title and pages. You need to know how many days finishing it takes at 50 pages a day. Why is adding a pagesPerDay field to Book the wrong move?
- Book already has two fields, and a well-designed class should never hold more than two.
- A method cannot combine one of its own fields with a value passed as a parameter, so the calculation has to move out.
- Pace is a fact about a reader rather than about the book, so it changes for a different reason and every new pace would force a new Book object.
- Private fields cannot be read by other methods of the same class, so pagesPerDay would be unusable inside Book.
Show answer
Responsibilities separate along reasons to change: page count comes from the publisher, pace comes from the reader, so a ReadingPlan holds pagesPerDay and takes a Book as a parameter, letting one Book serve any number of plans. The field-count answer is tempting because small classes are the goal, but size is not the test: five fields that all describe one idea are perfectly cohesive, while two fields describing two ideas are not.