Abstract Classes, Interfaces, and Code Organization
In the previous lesson, Vehicle had a quiet problem: nothing stops you from writing new Vehicle("Ford"). And a “generic vehicle” does not exist in the real world. It is a concept, not a thing.
Worse still: Vehicle.start() had to invent a default implementation ("The vehicle starts.") that no subclass actually uses. We wrote code purely so it would compile.
Java has two tools to fix this, and choosing badly between them is one of the design decisions you pay for the longest:
- Abstract class: an incomplete mold. It brings state and already-solved behavior, and leaves gaps the subclass is forced to fill.
- Interface: a pure contract. It says nothing about how anything is done, only what whoever signs it must be able to do.
1. Abstract classes: molds that cannot be instantiated
A class marked abstract cannot be instantiated. It exists only to be extended:
public abstract class Shape {
protected final String name;
protected Shape(String name) { // yes, abstract classes have constructors
this.name = name;
}
// Abstract method: no body. Every subclass MUST implement it.
public abstract double area();
public abstract double perimeter();
// Concrete method: already solved, inherited as is.
public void describe() {
System.out.printf("%s → area %.2f, perimeter %.2f%n",
name, area(), perimeter());
}
}
Look carefully at describe(), because that is where the whole point lives: it calls two methods that do not exist yet. The abstract class writes the general algorithm once and delegates the concrete steps to whoever extends it.
public class Circle extends Shape {
private final double radius;
public Circle(double radius) {
super("Circle");
if (radius <= 0) {
System.out.println("Invalid radius, used 1 by default.");
radius = 1;
}
this.radius = radius;
}
@Override
public double area() { return Math.PI * radius * radius; }
@Override
public double perimeter() { return 2 * Math.PI * radius; }
}
public class Rectangle extends Shape {
private final double width, height;
public Rectangle(double width, double height) {
super("Rectangle");
this.width = width;
this.height = height;
}
@Override
public double area() { return width * height; }
@Override
public double perimeter() { return 2 * (width + height); }
}
And now new Shape(...) does not even compile. The compiler stopped allowing the object that made no sense. That is exactly what we were after.
// Shape s = new Shape("something"); // ERROR: Shape is abstract; cannot be instantiated
Shape[] shapes = { new Circle(3), new Rectangle(4, 5) };
for (Shape s : shapes) {
s.describe(); // polymorphism, same as the previous lesson
}
If a subclass does not implement every abstract method it inherits, it must be declared
abstractas well. Java will not let you have a concrete class with holes in it.
2. Interfaces: contracts anyone can sign
An interface describes what can be done, never how:
public interface Payable {
// Every method is public abstract by default: no need to write it
boolean pay(double amount);
boolean isAvailable();
}
Any class can sign it with implements, and the compiler forces it to honor the whole thing:
public class CreditCard implements Payable {
private final String number;
private double availableCredit;
public CreditCard(String number, double availableCredit) {
this.number = number;
this.availableCredit = availableCredit;
}
// true if the charge went through, false if it was rejected for insufficient credit
@Override
public boolean pay(double amount) {
if (amount > availableCredit) {
System.out.println("Insufficient credit.");
return false;
}
availableCredit -= amount;
System.out.println("Paid with card " + number);
return true;
}
@Override
public boolean isAvailable() { return availableCredit > 0; }
}
The key point: CreditCard inherits from nobody. The interface does not consume the single extends you have. That is its decisive advantage.
default and static methods
Since Java 8, an interface can carry implementations:
public interface Payable {
boolean pay(double amount);
boolean isAvailable();
// default: inheritable implementation that classes may override or not
default void payIfPossible(double amount) {
if (isAvailable()) {
pay(amount);
} else {
System.out.println("Payment method unavailable.");
}
}
// static: a utility belonging to the interface, not to the classes
static boolean isValidAmount(double amount) {
return amount > 0 && amount < 1_000_000;
}
}
default methods exist for a very concrete reason: they let you add a new method to an interface without breaking the thousand classes already implementing it. Before Java 8, adding a method to a public interface broke the whole ecosystem depending on it.
Use them sparingly. An interface full of default methods stops being a contract and starts being a badly disguised abstract class.
3. Implementing several interfaces at once
This is where you see why interfaces are not simply “abstract classes without code”:
public class Duck extends Bird implements Swimmer, Flyer {
@Override public void swim() { System.out.println("The duck swims."); }
@Override public void fly() { System.out.println("The duck flies."); }
}
And now the same object can be seen from different angles depending on what each method needs:
Duck duck = new Duck();
Bird b = duck; // as a bird
Swimmer s = duck; // as something that swims
Flyer f = duck; // as something that flies
// A method that only needs something to swim does not need to know it is a duck:
public void swimmingContest(Swimmer[] participants) {
for (Swimmer participant : participants) {
participant.swim();
}
}
That array can hold a Duck, a Fish, and a Submarine — three classes that share absolutely no ancestor. The interface is the only thing they have in common, and it is enough.
4. Which one to pick
| Criterion | Abstract class | Interface |
|---|---|---|
| Relationship it expresses | ”is a” — shared identity | ”is capable of” — shared capability |
| How many you can use | Only one (extends) | As many as you want (implements) |
| Instance state | Yes, ordinary fields | No, only static final constants |
| Constructors | Yes | No |
| Method visibility | Anything, including protected | Always public |
| Adding a method later | Breaks subclasses if it is abstract | Breaks nothing if it is default |
The practical rule that holds in 90% of cases:
Interface by default. Abstract class only when there is genuinely shared state or code you do not want to repeat.
And the two combine perfectly well — that is the most common pattern in serious libraries:
public interface CourseRepository {
void save(Course course);
Course findById(long id);
}
// Abstract base that solves the repetitive part: searching the internal store
public abstract class InMemoryCourseRepository implements CourseRepository {
protected final Course[] store = new Course[100];
protected int count = 0;
@Override
public Course findById(long id) {
for (int i = 0; i < count; i++) {
if (store[i].getId() == id) {
return store[i];
}
}
return null; // not found
}
// save() stays abstract: each concrete subclass decides what to validate before saving
}
5. Code Organization: Packages and the Namespace Concept in Java
What is a namespace?
In computer science and software engineering, a namespace is an abstract container designed to group identifiers (classes, interfaces, types) and provide them with a unique context to prevent name collisions.
As a project grows and pulls in third-party libraries (for instance database drivers, HTTP clients, or domain models), name overlap becomes inevitable: two developers might declare an Order class, or your internal domain might define Date while your SQL driver also supplies its own Date. Without namespaces, all types would compete in a single flat global scope, leaving the compiler unable to determine which one you meant.
Java has no namespace keyword: it uses package
Unlike languages like C++, C#, or PHP, Java does not have a dedicated namespace keyword. Instead, the language natively implements the namespace concept through three complementary pillars:
- The
packagedeclaration: establishes the namespace for every type declared in the source file. - The Fully Qualified Class Name (FQCN): the canonical, unambiguous identity of a class to the JVM.
- The
importstatement: a lexical shortcut so you do not have to type the full FQCN on every single reference.
Furthermore, while namespaces in C# or C++ are purely logical constructs decoupled from physical storage, Java enforces a strict physical rule: the namespace declared in package must match the directory hierarchy on the classpath exactly.
package com.facundouferer.shop.domain; // first line, mandatory
public class Product { ... }
Fully Qualified Class Name (FQCN) and type identity
To the JVM, a class’s true identity is never just its short name (Product). Its absolute identity to the ClassLoader is its Fully Qualified Class Name (FQCN):
com.facundouferer.shop.domain.Product
Thanks to this hierarchical namespace system, two homonymous classes can coexist without any ambiguity within the same application:
com.facundouferer.shop.domain.Product(internal core domain entity)com.supplier.catalog.Product(DTO imported from an external supplier API)
Resolving name collisions with import
The import statement does not load bytecode into memory; it merely registers an alias so you do not have to write out the full FQCN every time you reference a class.
What happens if you need to use two classes with the same short name from different namespaces/packages within the same source file?
// ERROR: Java does not allow importing two classes with the same short name in the same compilation unit
import java.util.Date;
import java.sql.Date; // Compile error: 'Date is already defined in a single-type import'
Java rejects this ambiguity. To resolve the namespace collision:
- Import the class you use most frequently (it will use its short name).
- Disambiguate the second class by writing its full FQCN directly in your declarations or instantiations:
package com.facundouferer.shop.service;
import java.util.Date; // Unqualified Date resolves to java.util.Date
public class AuditService {
// Uses the short name from the imported namespace:
private Date operationDate = new Date();
// Explicit disambiguation using the full FQCN to prevent namespace collision:
private java.sql.Date databaseDate = new java.sql.Date(System.currentTimeMillis());
public void record() {
System.out.println("In-memory date (java.util): " + operationDate);
System.out.println("Database date (java.sql): " + databaseDate);
}
}
Namespace-level encapsulation: package-private
Namespaces in Java are more than organizational folders; they are architectural trust boundaries.
As introduced in the Constructors, Access Modifiers, and Getters/Setters lesson, Java’s default access level (no modifier, or package-private) restricts visibility strictly to types living in the exact same package/namespace. This allows you to expose a clean public API (public) while keeping implementation helpers and internal domain mechanics completely shielded from external code.
Core rules for package and namespace design
- Reverse domain convention: Always begin package names with your inverted internet domain (
com.facundouferer), guaranteeing that your namespaces remain globally unique across public repositories like Maven Central. - All lowercase: No uppercase letters, underscores, or hyphens (
shop.domain, notshop_domain). - One
.javafile per public class: And the filename must match the class name exactly. - Group by responsibility, not artifact type:
shop.domainandshop.serviceexpress clean architecture;shop.interfacesandshop.classesmerely duplicate technical mechanics without architectural meaning. - Judicious static imports:
import static java.lang.Math.PI;pulls static members directly into the file’s lexical namespace, but use it sparingly to avoid obscuring where methods come from.
6. Modeling relationships: association, aggregation, composition
Inheritance is not the only way to connect classes, nor the most common. In practice most relationships are containment, and they are told apart by a single question: what happens to the part when the whole disappears?
import java.util.Arrays;
// ASSOCIATION: they know each other, neither owns the other
public class Teacher {
private Course[] courses = new Course[10];
private int courseCount = 0;
public void assign(Course c) {
if (courseCount == courses.length) {
courses = Arrays.copyOf(courses, courses.length * 2);
}
courses[courseCount] = c;
courseCount++;
}
}
// AGGREGATION: the team receives players that already existed and outlives them
public class Team {
private final Player[] players;
public Team(Player[] players) {
this.players = Arrays.copyOf(players, players.length); // defensive copy, see Arrays of Objects
}
}
// COMPOSITION: the house CREATES its rooms and never lets them go
public class House {
private final Room[] rooms;
public House(int roomCount) {
rooms = new Room[roomCount];
for (int i = 0; i < roomCount; i++) {
rooms[i] = new Room(i + 1); // creates them right here
}
}
public int roomCount() { return rooms.length; }
// There is no getRooms(): nobody outside touches the parts
}
Notice the pattern in the code: in composition, the container creates the parts in its own constructor and does not expose them. In aggregation, it receives them from outside. That difference in the code is exactly the conceptual difference.
7. Common mistakes
| Mistake | What happens | How to fix it |
|---|---|---|
| Using an abstract class where an interface belongs | Burns the single available extends and the class can no longer inherit from what it actually needs. | Always start with the interface; add an abstract class only if there is shared state. |
An interface with a default for every method | It stops being a contract and becomes an abstract class with no constructor and no state. | default is for evolving an interface without breaking implementations, not for writing logic. |
| Declaring mutable fields in an interface | Every field in an interface is public static final: a shared global constant, not object state. | If you need state, you need a class (abstract or otherwise). |
| Package that does not match the folder | Confusing compile errors about classes that “do not exist”. | The package declaration must mirror the exact path. |
Namespace collision when importing homonymous classes (e.g. two Date or Order classes) | Compile error: is already defined in a single-type import. | Disambiguate the namespace by importing one and using the Fully Qualified Class Name (FQCN) for the other. |
| Modeling as aggregation something that is composition | The part gets exposed and someone outside mutates it, or shares it between two containers. | If the part cannot live without the whole: create it inside and do not expose it. |
| One giant package holding every class | package-private protects nothing and the architecture is unreadable. | Split by responsibility from day one. |
8. Guided hands-on exercise
Challenge: payment methods
- Define the
Payableinterface withboolean pay(double amount)(trueif the charge succeeds,falseif it is rejected),boolean isAvailable(), and adefaultmethodpayIfPossible(double amount)that only charges when the method is available. - Implement it in
CreditCard(with available credit) and inDigitalWallet(with an account balance). - Create an abstract class
DigitalPaymentMethod implements Payablethat stores the holder’semailand resolvesisAvailable()using anactiveflag, leavingpay()abstract. - Make
DigitalWalletextend that abstract class. - In
main, build aPayable[]and charge the same amount to every one of them in a single loop, with noinstanceofand no casting.
See suggested solution
public interface Payable {
boolean pay(double amount);
boolean isAvailable();
default boolean payIfPossible(double amount) {
if (amount <= 0) {
System.out.println(" ✗ Invalid amount, charge skipped.");
return false;
}
if (isAvailable()) {
return pay(amount);
}
System.out.println(" ✗ Method unavailable, charge skipped.");
return false;
}
}
// Abstract class: contributes the STATE and the shared behavior.
public abstract class DigitalPaymentMethod implements Payable {
protected final String email;
protected boolean active;
protected DigitalPaymentMethod(String email) {
if (email == null || !email.contains("@")) {
System.out.println("Invalid email, used \"no-email@example.com\" by default.");
email = "no-email@example.com";
}
this.email = email;
this.active = true;
}
@Override
public boolean isAvailable() {
return active;
}
public void deactivate() { this.active = false; }
// pay() stays abstract: each digital method charges its own way.
}
public class DigitalWallet extends DigitalPaymentMethod {
private double balance;
public DigitalWallet(String email, double balance) {
super(email);
this.balance = balance;
}
@Override
public boolean isAvailable() {
return super.isAvailable() && balance > 0; // reuses and refines
}
@Override
public boolean pay(double amount) {
if (amount > balance) {
System.out.printf(" ✗ Wallet (%s) — insufficient balance.%n", email);
return false;
}
balance -= amount;
System.out.printf(" ✓ Wallet (%s) — remaining balance $%.2f%n", email, balance);
return true;
}
}
// Inherits from nobody: it only signs the contract.
public class CreditCard implements Payable {
private final String lastFour;
private double availableCredit;
public CreditCard(String lastFour, double availableCredit) {
this.lastFour = lastFour;
this.availableCredit = availableCredit;
}
@Override
public boolean isAvailable() { return availableCredit > 0; }
@Override
public boolean pay(double amount) {
if (amount > availableCredit) {
System.out.printf(" ✗ Card ****%s — insufficient credit.%n", lastFour);
return false;
}
availableCredit -= amount;
System.out.printf(" ✓ Card ****%s — remaining credit $%.2f%n",
lastFour, availableCredit);
return true;
}
}
public class MainCharges {
public static void main(String[] args) {
DigitalWallet emptyWallet = new DigitalWallet("empty@mail.com", 0);
Payable[] methods = {
new CreditCard("4417", 50000),
new DigitalWallet("facu@mail.com", 30000),
emptyWallet // no balance: will be skipped
};
System.out.println("Charging $12,500 to every method:");
for (Payable method : methods) {
method.payIfPossible(12500); // the default decides; nobody asks for the type
}
}
}
Two things to look at here.
First: CreditCard and DigitalWallet share no ancestor whatsoever, and still live in the same Payable[]. The interface was enough.
Second: DigitalWallet.isAvailable() calls super.isAvailable() and adds its own condition on top. It reuses the abstract class’s rule instead of repeating it — exactly the pattern you used with super.computeSalary() in the previous lesson.
Key takeaways
- An abstract class is an incomplete mold: it contributes state and code, forces the gaps to be filled, and cannot be instantiated.
- An interface is a contract: it says what can be done, not how, and it does not consume your single
extends. - Practical rule: start with the interface; add an abstract class only when there is genuinely shared state or logic.
defaultmethods exist so an interface can evolve without breaking the classes already implementing it.- A class extends one class but implements every interface it needs. That is Java’s answer to multiple inheritance.
- The
packageis Java’s native implementation of the namespace concept: it prevents collisions via FQCNs, must strictly match the folder hierarchy, and should be grouped by responsibility, not by artifact type. - Association, aggregation, and composition are told apart by one question: if I destroy the whole, does the part still exist?