Demo: reorganizing Bank Accounts into feature packages
On This Lesson 11 topics
Lesson Overview
This is a hands-on lesson. You have learned four ideas in this module: classes live in packages that match folders, imports connect packages, package-private hides helpers, and feature packages make hiding practical. Now we apply all four to a small Bank Accounts project, step by step, and you can follow along on your own machine.
The starting point is the project most beginners produce: every class in one folder, no package line, everything public. The finish line is three feature packages (account, customer, notification) plus an app package for the entry point, with only the intended classes public. The behavior will not change by one character; only the structure does. That is what refactoring means: improving the structure without changing what the program does.
This lesson prepares the lab "Bank Accounts v5", where you do the same on your own version of the project. The classes here are a small stand-in for your earlier work, so use your own classes in the lab.
Everything below was compiled and run with JDK 21. Terminal commands are shown for a plain terminal; if you use an IDE, it has "move class" and "new package" features whose menus differ by product and version, so check its help. An IDE's move refactoring also fixes package lines and imports for you, which is a good reason to prefer it on big projects.
Learning Objectives
By the end of this lesson you will be able to:
- Plan a package structure for an existing project before touching any file.
- Move classes into packages and correct each
packageline and folder. - Add the imports that the new structure requires.
- Decide, class by class, what must stay
publicand what can become package-private. - Offer a public way to obtain an object whose class is hidden (a small public factory).
- Use the compiler's errors as a checklist during a refactoring.
- Verify that the program's output did not change.
Written Explanation
Step 0: the starting point
The project is eight small files in one folder, all in the default package. Here is its shape:
before/
Account.java public: balance, deposit, interest
Customer.java public: a name
Notifier.java public interface: send(to, message)
EmailNotifier.java public: prints "email to ..."
InterestCalculator.java public: the interest formula
Main.java public: builds an account and uses itAccount holds a Customer owner and a Notifier; deposit sends a notification; addMonthlyInterest uses InterestCalculator. We compile and run it to record the behavior we must preserve:
javac -d bout before/*.java
java -cp bout Mainemail to Sara: deposited 100.0
1302.1666666666667Always capture the "before" output first. It is your test for the whole refactoring.
Step 1: plan the packages
Ask three questions for each class: which feature does it serve, who outside that feature needs it, and what does it depend on?
| Class | Feature | Needed by other features? |
|---|---|---|
Account | account | yes: Main creates it |
InterestCalculator | account | no, only Account uses it |
Customer | customer | yes: Account holds one, Main creates it |
Notifier | notification | yes: Account is given one |
EmailNotifier | notification | no, only needs to be constructed |
Main | the entry point | none |
Main is a special case. It builds objects from every feature, so it sits in its own package bank.app. Our packages all start with bank; in a real project you would use a reversed-domain prefix such as com.example.bank, as in Lesson 1. We use the short bank to keep the lines short.
Step 2 and 3: create packages, move classes, add imports
Create the folders bank/account, bank/customer, bank/notification and bank/app. Move each file and add a package line. Then fix the imports. The only cross-package uses are in Account (it uses Customer and Notifier) and Main (it uses Account, Customer and EmailNotifier).
Step 4: make the surface small
Remove public wherever no other package needs the class. This is where the compiler becomes your guide: if you remove public from something that is used from another package, it tells you with "is not public ... cannot be accessed from outside package". Then ask the question from Lesson 3: should that code really be using this class?
Two decisions deserve discussion:
InterestCalculatoris used only byAccount, in the same package. It becomes package-private, along with its method.EmailNotifieris only constructed inMain. If it becomes package-private,Maincannot writenew EmailNotifier(). We offer a small public classNotifierswith astaticmethodemail()that returns aNotifier.Mainthen depends on the public interface and the factory, and the concrete class stays hidden. The email class can later be replaced by a different channel without touchingMain. The constructor ofNotifiersisprivatebecause nobody should create one.
Step 5: the planted mistake
Before you finish, test the boundary. In bank/app, write a class that uses EmailNotifier directly. The compiler says:
error: EmailNotifier is not public in bank.notification; cannot be accessed from outside package
import bank.notification.EmailNotifier;
^The wrong fix is to make EmailNotifier public again. The right fix is to use the public surface: Notifiers.email().
Step 6: compile, run, compare
Compile from the after folder and run with the full name of Main. The output must match the "before" output exactly. If it does, the refactoring is done.
Examples
Example 1: the moved and trimmed classes (simple)
The smaller classes. bank/customer/Customer.java keeps public because Account and Main use it from other packages:
package bank.customer;
public class Customer {
private final String name;
public Customer(String name) { this.name = name; }
public String name() { return name; }
}bank/notification/Notifier.java stays a public interface, because other packages program against it:
package bank.notification;
public interface Notifier {
void send(String to, String message);
}bank/notification/EmailNotifier.java loses public. Interface methods are implicitly public, so send stays public in the class too (an implementation of an interface method must be public):
package bank.notification;
class EmailNotifier implements Notifier {
public void send(String to, String message) {
System.out.println("email to " + to + ": " + message);
}
}bank/notification/Notifiers.java is the public way to get one:
package bank.notification;
public final class Notifiers {
private Notifiers() { }
public static Notifier email() { return new EmailNotifier(); }
}bank/account/InterestCalculator.java, now hidden:
package bank.account;
class InterestCalculator {
double monthlyInterest(double balance) { return balance * 0.02 / 12; }
}Example 2: Account and Main with their imports (practical)
Account is the public front desk of its feature. Its only changes are the package line and two imports:
package bank.account;
import bank.customer.Customer;
import bank.notification.Notifier;
public class Account {
private final Customer owner;
private final Notifier notifier;
private double balance;
private final InterestCalculator calculator = new InterestCalculator();
public Account(Customer owner, double balance, Notifier notifier) {
this.owner = owner;
this.balance = balance;
this.notifier = notifier;
}
public void deposit(double amount) {
if (amount > 0) {
balance += amount;
notifier.send(owner.name(), "deposited " + amount);
}
}
public void addMonthlyInterest() {
balance += calculator.monthlyInterest(balance);
}
public double balance() { return balance; }
}InterestCalculator needs no import: same package. bank/app/Main.java:
package bank.app;
import bank.account.Account;
import bank.customer.Customer;
import bank.notification.Notifiers;
public class Main {
public static void main(String[] args) {
Account a = new Account(new Customer("Sara"), 1200, Notifiers.email());
a.deposit(100);
a.addMonthlyInterest();
System.out.println(a.balance());
}
}Example 3: compile, compare and review the result (realistic)
cd after
javac -d out -sourcepath . bank/app/Main.java
java -cp out bank.app.Mainemail to Sara: deposited 100.0
1302.1666666666667The output is identical to the "before" run. The compiler produced this class tree, one folder per package:
out/bank/account/Account.class
out/bank/account/InterestCalculator.class
out/bank/app/Main.class
out/bank/customer/Customer.class
out/bank/notification/EmailNotifier.class
out/bank/notification/Notifier.class
out/bank/notification/Notifiers.classNow review the public surface of each package:
| Package | Public | Hidden (package-private) |
|---|---|---|
bank.account | Account | InterestCalculator |
bank.customer | Customer | none |
bank.notification | Notifier, Notifiers | EmailNotifier |
bank.app | Main | none |
Of seven classes, five are public instead of seven, and every one of them is public for a reason you can state. Imports also tell the dependency story: account depends on customer and notification; neither depends back on account. That one-way pattern is what we asked for in Lesson 4.
Backend Insight
Refactoring a project into packages is routine work. Real codebases begin as a few classes and grow; someone must eventually say "this is now three features". The process in this lesson is how it is done safely: record behavior, move in small steps, let the compiler list what broke, and re-run to compare.
The small public factory (Notifiers.email()) is a pattern you will meet again. It lets a package decide how an object is built while callers see only an interface. Later, when the notification feature gains SMS and push channels, only bank.notification changes. In a modular monolith, as later modules in the roadmap will show, packages like these become the boundaries between modules, and the public classes become each module's API.
Common Mistakes
Mistake 1: moving everything at once
Move one package, compile, then the next. A pile of errors from twenty files at once is hard to read, and the compiler's first errors are often the root cause of the rest.
Mistake 2: forgetting to update package lines after moving files
A moved file whose package line still says the old name produces "package does not exist" errors elsewhere. Folder and package line change together.
Mistake 3: reaching for public to silence errors
Each time you do, record why the class must be public. If you cannot give a reason, find another solution.
Mistake 4: changing behavior while refactoring
If you fix a bug or rename a method at the same time, you cannot tell whether a changed output came from the move. Refactor first, compare, then make other changes in a separate step.
Mistake 5: leaving Main and helpers in the default package
Code in the default package can import classes from named packages, but named-package classes cannot import anything from the default package. A leftover Main there is therefore a dead end for any other class that wants to use it. Keep Main in a named package so the whole project is consistent and can be launched with a full class name.
Exercises
Try each exercise yourself before opening the solutions.
Level A: Foundation
A1. For class Customer in package bank.customer, give the file path under the source root and its package line.
Show solution A1
A1. bank/customer/Customer.java with package bank.customer;.
A2. After the refactoring, which class does Main use to obtain a Notifier, and why can it not write new EmailNotifier()?
Show solution A2
A2. It uses Notifiers.email(), which returns the public Notifier interface. EmailNotifier is package-private in bank.notification, so code in bank.app cannot name or construct it.
A3. Name the two cross-package imports in Account.
Show solution A3
A3. import bank.customer.Customer; and import bank.notification.Notifier;.
Level B: Application
B1. Add a ReportPrinter class to the project that prints an account's balance. Put it in the package where it fits best, decide its modifier, and say which imports it needs. Assume only Main will call it.
Show solution B1
B1. It fits bank.app if it is only a printing helper for Main: class ReportPrinter with no modifier (package-private, since only Main in the same package calls it), importing bank.account.Account. If other features will print reports later, move it to its own report feature package and make the entry class public.
B2. You decide InterestCalculator should also be used by a new bank.report package. Describe two ways to handle that, and which you prefer.
Show solution B2
B2. Option 1: make InterestCalculator public and import it, which widens the public surface. Option 2: give Account a public method (for example projectedInterest()) that the report package calls, keeping the calculator hidden. Prefer option 2: the formula stays free to change.
B3. The refactored project compiles, but the output now says 1302.17 instead of 1302.1666666666667. List two steps to find out whether the move caused this.
Show solution B3
B3. Re-run the "before" build and compare outputs, which shows whether the difference exists before the move. Then check whether the formatting code changed between versions (a stray edit to a print statement, for instance). If the numbers differ in the before run as well, the cause is not the move.
Level C: Challenge
C1. Add an SmsNotifier in bank.notification and a Notifiers.sms() factory. List every file you change, and say whether Account changes.
Show solution C1
C1. Add SmsNotifier.java (package-private, implements Notifier) and edit Notifiers.java to add public static Notifier sms(). Account does not change, because it depends only on the Notifier interface. Main changes only if you want it to use SMS.
C2. Write the two-sentence justification the lab asks for ("why feature over layer packaging") using the Bank Accounts project as your example.
Show solution C2
C2. For example: "Feature packages keep everything about accounts, customers or notifications together, so a change touches one folder. They let helpers like InterestCalculator and EmailNotifier be package-private, so only the classes other features really need are public."
Mini Challenge
Reorganize your own Bank Accounts project (or the eight-file project above) from scratch without looking at the solution code.
Check your work: the program prints the same output as before; every class sits in a folder that matches its package line; no class remains in the default package; at least one helper is package-private; and you can state, for every public class, who outside its package needs it. Write your public-surface table like the one in Example 3.
Summary & Next Lesson
Key takeaways
- Refactoring changes structure, not behavior: record the output first, compare at the end.
- Plan by asking, for each class: which feature, who needs it, what does it use?
- Move files and
packagelines together, then add imports for cross-package uses. - Make a class
publiconly when another package needs it. Use a small public factory to hide a concrete class behind an interface. - Compiler errors guide the work. A "not public" error is a prompt to think, not an instruction to add
public. - The result is small public surfaces and one-way dependencies between features.
Next lesson
This is the last lesson of the module on packages and organization. Apply it in the lab "Bank Accounts v5", then check yourself with the module quiz.
Self-check
- What does refactoring promise about the program's behavior?
- Why is
InterestCalculatorpackage-private? - How can
Mainget an email notifier without seeingEmailNotifier? - What should you do before you start moving files?
- What is the compiler's complaint when code in another package uses a hidden class?
Show answers
- That it does not change: the same input gives the same output.
- Only
Account, in the same package, uses it. - Through the public factory
Notifiers.email(), which returns theNotifierinterface. - Compile and run the project, and record its output to compare later.
- "is not public in <package>; cannot be accessed from outside package".
References
- Oracle, The Java Tutorials: Controlling Access to Members of a Class. Supports package-private meaning, and the advice to use the most restrictive level that makes sense.
- Oracle, javac command reference (JDK 21). Supports the use of
-dfor the class output folders and-sourcepathfor finding other sources. - Oracle, The Java Language Specification, SE 21, Chapter 7. Supports the unnamed package's import restriction and the package-to-folder mapping.
- All code in this lesson was compiled and run with JDK 21; the outputs and compiler messages are real. The rule that interface implementations must be public comes from general Java knowledge that we did not cite here.
Practice
Apply this lesson with the module's lab, exercises and quiz.
Marking a lesson complete is optional and you can undo it any time. Your progress stays in this browser.