A class is a blueprint for creating objects. It defines what data an object holds (fields) and what actions an object can perform (methods). Think of a class as an architectural blueprint for a house: the blueprint itself is not a house, but it describes exactly how to build one. You can use the same blueprint to build many houses, each with its own paint color, furniture, and residents.
In Java, everything revolves around classes. Every piece of code you write lives inside a class. Understanding classes deeply is the gateway to mastering object-oriented programming (OOP).
The distinction between a class and an object is one of the most important concepts in Java:
| Class | Object |
|---|---|
| A blueprint / template / definition | A concrete instance created from the blueprint |
| Defines fields and methods | Holds actual values in those fields |
| Exists at compile time | Exists at runtime (lives in memory) |
| Declared once | Can be instantiated many times |
| No memory allocated for fields | Memory allocated on the heap for each instance |
Example: Dog class |
Example: a specific dog named “Rex” |
Let us map a real-world concept to a Java class. Consider a Dog:
Attributes (fields) — the data that describes a dog: breed, color, age, name, weight
Behaviors (methods) — the actions a dog can perform: bark, fetch, sleep, eat
The Dog class is the general idea of “what a dog is.” An object is a specific dog, like a 3-year-old golden retriever named Max.
// The class: a blueprint for dogs
public class Dog {
// Attributes (fields) -- what a dog HAS
String name;
String breed;
String color;
int age;
// Behaviors (methods) -- what a dog DOES
public void bark() {
System.out.println(name + " says: Woof! Woof!");
}
public void fetch(String item) {
System.out.println(name + " fetches the " + item);
}
public void sleep() {
System.out.println(name + " is sleeping... Zzz");
}
}
// Creating objects (instances) from the blueprint
Dog dog1 = new Dog();
dog1.name = "Max";
dog1.breed = "Golden Retriever";
dog1.age = 3;
dog1.bark(); // Max says: Woof! Woof!
dog1.fetch("ball"); // Max fetches the ball
Dog dog2 = new Dog();
dog2.name = "Luna";
dog2.breed = "German Shepherd";
dog2.age = 5;
dog2.bark(); // Luna says: Woof! Woof!
dog2.sleep(); // Luna is sleeping... Zzz
A well-organized Java class follows a consistent structure. Here is the complete anatomy of a class, from top to bottom:
1. Package declaration — what package this class belongs to
2. Import statements — external classes this class depends on
3. Class declaration — the class name with access modifier
4. Static fields (constants first) — shared across all instances
5. Instance fields — unique to each object
6. Constructors — how to create instances
7. Public methods — the class’s API
8. Private/helper methods — internal implementation details
9. Nested classes (if any) — classes defined inside this class
This ordering is a widely followed convention (used by Google, Oracle, and most enterprise codebases). It makes classes predictable and easy to navigate.
package com.lovemesomecoding.model; // 1. Package declaration
import java.util.Objects; // 2. Import statements
import java.time.LocalDate;
public class Employee { // 3. Class declaration
// 4. Static fields (constants first)
private static final String COMPANY = "LoveMeSomeCoding";
private static int employeeCount = 0;
// 5. Instance fields
private String firstName;
private String lastName;
private String email;
private double salary;
private LocalDate hireDate;
// 6. Constructors
public Employee(String firstName, String lastName, String email, double salary) {
this.firstName = firstName;
this.lastName = lastName;
this.email = email;
this.salary = salary;
this.hireDate = LocalDate.now();
employeeCount++;
}
// 7. Public methods
public String getFullName() {
return firstName + " " + lastName;
}
public double getAnnualSalary() {
return salary * 12;
}
// 8. Private/helper methods
private String formatEmail() {
return email.toLowerCase().trim();
}
// 9. toString, equals, hashCode (standard overrides)
@Override
public String toString() {
return "Employee{name='" + getFullName() + "', email='" + email + "'}";
}
}
Fields (also called instance variables or member variables) are the data that each object carries. Every time you create a new object with new, Java allocates memory for all the fields declared in the class, and each object gets its own independent copy.
Fields are declared inside the class body but outside any method or constructor. The syntax is:
accessModifier type fieldName = optionalInitialValue;
public class Product {
// Field declarations with explicit initial values
private String name = "Unknown";
private double price = 0.0;
private int quantity = 0;
private boolean inStock = false;
// Field declaration without initial values (uses defaults)
private String category; // default: null
private double discount; // default: 0.0
private int reviewCount; // default: 0
private boolean featured; // default: false
}
Unlike local variables (which must be initialized before use), instance fields receive default values automatically if you do not assign one. This is a critical difference.
| Field Type | Default Value |
|---|---|
byte, short, int, long |
0 |
float, double |
0.0 |
char |
'\u0000' (null character) |
boolean |
false |
| Any object type (String, arrays, etc.) | null |
You control who can see and modify a field using access modifiers:
| Modifier | Same Class | Same Package | Subclass | Everywhere |
|---|---|---|---|---|
private |
Yes | No | No | No |
| (default / no modifier) | Yes | Yes | No | No |
protected |
Yes | Yes | Yes | No |
public |
Yes | Yes | Yes | Yes |
Best practice: Always make fields private and provide public getters and setters. This is the principle of encapsulation — you control how your data is accessed and modified. We will cover this in detail in the Getters and Setters section.
Java field names follow camelCase: start with a lowercase letter, capitalize each subsequent word. Choose names that clearly describe the data they hold.
// Good -- descriptive, camelCase private String firstName; private double accountBalance; private int totalOrderCount; private boolean isActive; // boolean fields often start with "is", "has", "can" private boolean hasPermission; // Bad -- vague, wrong conventions private String s; // too short, meaningless private double Account_Balance; // wrong: uses underscores and uppercase start private int x; // what does x represent? private boolean flag; // flag for what?
A constructor is a special method that is called when you create a new object. Its job is to initialize the object’s fields with meaningful values so the object is ready to use immediately after creation.
Key facts about constructors:
void. If you add a return type, Java treats it as a regular method, not a constructornew keywordIf you do not write any constructor, Java provides a default no-argument constructor automatically. It does nothing except create the object with default field values. However, the moment you write any constructor yourself, Java stops providing the default one.
// Java provides a default constructor automatically here
// because we did not write any constructor
public class User {
private String name;
private int age;
}
// This works because the default constructor exists
User user = new User();
// user.name is null, user.age is 0 (default values)
A parameterized constructor accepts arguments so you can set field values at the time of object creation. This is the most common kind of constructor in real-world code.
public class User {
private String name;
private String email;
private int age;
// Parameterized constructor
public User(String name, String email, int age) {
this.name = name;
this.email = email;
this.age = age;
}
}
// Creating an object with the parameterized constructor
User user = new User("Folau", "folau@email.com", 30);
// This will NOT compile -- no default constructor exists anymore
// User user2 = new User(); // Compilation error!
You can define multiple constructors with different parameter lists. This gives callers flexibility in how they create objects. Java determines which constructor to call based on the arguments you pass.
public class User {
private String name;
private String email;
private int age;
// Constructor 1: all fields
public User(String name, String email, int age) {
this.name = name;
this.email = email;
this.age = age;
}
// Constructor 2: name and email only, default age
public User(String name, String email) {
this.name = name;
this.email = email;
this.age = 0; // default
}
// Constructor 3: no arguments
public User() {
this.name = "Unknown";
this.email = "none@example.com";
this.age = 0;
}
}
// All three ways to create a User
User user1 = new User("Folau", "folau@email.com", 30);
User user2 = new User("Lisa", "lisa@email.com");
User user3 = new User();
this()When you have multiple constructors, you often see repeated initialization code. Constructor chaining solves this by having one constructor call another using this(). The this() call must be the first statement in the constructor.
The idea is to funnel all constructors through a single “primary” constructor that contains the actual initialization logic. This avoids code duplication.
public class User {
private String name;
private String email;
private int age;
// Primary constructor -- all initialization happens here
public User(String name, String email, int age) {
this.name = name;
this.email = email;
this.age = age;
System.out.println("User created: " + name);
}
// Chains to the primary constructor with a default age
public User(String name, String email) {
this(name, email, 0); // calls User(String, String, int)
}
// Chains to the two-argument constructor, which chains to the primary
public User() {
this("Unknown", "none@example.com"); // calls User(String, String)
}
}
// All three constructors eventually call the primary constructor
User user1 = new User("Folau", "folau@email.com", 30); // User created: Folau
User user2 = new User("Lisa", "lisa@email.com"); // User created: Lisa
User user3 = new User(); // User created: Unknown
A copy constructor creates a new object by copying the field values from an existing object. Java does not provide this automatically (unlike C++), but it is a useful pattern when you need a duplicate of an object.
public class User {
private String name;
private String email;
private int age;
// Regular constructor
public User(String name, String email, int age) {
this.name = name;
this.email = email;
this.age = age;
}
// Copy constructor -- creates a new User from an existing one
public User(User other) {
this.name = other.name;
this.email = other.email;
this.age = other.age;
}
}
User original = new User("Folau", "folau@email.com", 30);
User copy = new User(original); // independent copy
System.out.println(original == copy); // false -- different objects in memory
// But they have the same field values
new KeywordYou create an object (an instance of a class) using the new keyword. Here is what happens step by step when you write User user = new User("Folau", "folau@email.com", 30):
1. Memory allocation — Java allocates space on the heap (the area of memory for objects) large enough to hold all the instance fields of the User class.
2. Field initialization — All fields are set to their default values (null, 0, false).
3. Constructor execution — The matching constructor runs and sets the fields to the values you provided.
4. Reference returned — The new expression returns a reference (a memory address) to the newly created object. This reference is stored in the variable user.
The variable user does not contain the object itself. It contains a reference (like a pointer) to where the object lives on the heap.
// Creating objects
User user1 = new User("Folau", "folau@email.com", 30);
User user2 = new User("Lisa", "lisa@email.com", 28);
// user1 and user2 are separate objects on the heap
System.out.println(user1 == user2); // false -- different objects
A variable of a class type holds a reference to an object, not the object itself. This has important consequences.
// Two references pointing to the SAME object
User user1 = new User("Folau", "folau@email.com", 30);
User user2 = user1; // user2 now points to the same object as user1
// Modifying through user2 also affects user1 -- they share the same object
user2.setName("Folau K.");
System.out.println(user1.getName()); // Output: Folau K.
System.out.println(user1 == user2); // Output: true -- same reference
// Null references
User user3 = null; // user3 does not point to any object
// Calling a method on null throws NullPointerException
// user3.getName(); // NullPointerException at runtime!
// Always check for null before using a reference
if (user3 != null) {
System.out.println(user3.getName());
} else {
System.out.println("user3 is null"); // Output: user3 is null
}
Think of references like remote controls and objects like TVs. The remote (reference) lets you interact with the TV (object), but the remote is not the TV. You can have two remotes controlling the same TV, and changing the channel with either remote affects the same TV. Setting a remote to null is like throwing away the remote — the TV still exists until Java’s garbage collector reclaims it.
// Three references, but how many objects?
User a = new User("Alice", "alice@email.com", 25); // Object 1 created
User b = new User("Bob", "bob@email.com", 30); // Object 2 created
User c = a; // c points to Object 1 (same as a)
// Answer: 2 objects, 3 references
// a and c -> Object 1
// b -> Object 2
a = null; // a no longer points to Object 1, but c still does
// Object 1 is NOT garbage collected because c still references it
c = null; // Now nothing references Object 1
// Object 1 is eligible for garbage collection
this KeywordThe this keyword refers to the current object — the specific instance on which a method or constructor is being called. It is one of the most frequently used keywords in Java class code.
The most common use of this is to distinguish between a field and a constructor/method parameter that share the same name.
public class User {
private String name;
private int age;
public User(String name, int age) {
// Without "this", Java thinks you are assigning the parameter to itself
// name = name; // WRONG: does nothing useful
// "this.name" refers to the field, "name" refers to the parameter
this.name = name;
this.age = age;
}
public void setName(String name) {
this.name = name; // this.name = field, name = parameter
}
}
this for Method Chaining (Fluent API)A powerful pattern is to return this from setter methods. This enables method chaining, also called a fluent API, where you can call multiple methods in a single statement. Many popular libraries (builders, query builders, testing frameworks) use this pattern.
public class UserBuilder {
private String name;
private String email;
private int age;
public UserBuilder setName(String name) {
this.name = name;
return this; // return the current object
}
public UserBuilder setEmail(String email) {
this.email = email;
return this;
}
public UserBuilder setAge(int age) {
this.age = age;
return this;
}
public User build() {
return new User(name, email, age);
}
}
// Method chaining -- clean and readable
User user = new UserBuilder()
.setName("Folau")
.setEmail("folau@email.com")
.setAge(30)
.build();
this as an ArgumentYou can pass this as an argument to another method or constructor when the other object needs a reference to the current object.
public class Order {
private int orderId;
public Order(int orderId) {
this.orderId = orderId;
}
public void process(OrderProcessor processor) {
// Pass the current Order object to the processor
processor.process(this);
}
public int getOrderId() {
return orderId;
}
}
public class OrderProcessor {
public void process(Order order) {
System.out.println("Processing order #" + order.getOrderId());
}
}
// Usage
Order order = new Order(1001);
OrderProcessor processor = new OrderProcessor();
order.process(processor); // Output: Processing order #1001
When you declare a class at the top level (not nested inside another class), you have two options:
| Modifier | Visibility | Rules |
|---|---|---|
public |
Accessible from any other class, any package | The file name must match the class name. Only one public class per .java file. |
| (default / no modifier) | Package-private — accessible only within the same package | File name does not have to match. You can have multiple default classes in one file. |
// File: User.java
// This public class MUST be in a file named "User.java"
public class User {
private String name;
public User(String name) {
this.name = name;
}
}
// This package-private class can also be in User.java
// but it is only visible within the same package
class UserValidator {
public boolean isValid(User user) {
return user != null;
}
}
// Classes in other packages CANNOT see UserValidator
// Only User is accessible everywhere
Encapsulation is the practice of keeping fields private and providing controlled access through public methods called getters and setters. This is one of the four pillars of object-oriented programming.
If you make fields public, any code anywhere can change them to anything, including invalid values. There is no way to:
By using getters and setters, you put a gatekeeper in front of your data.
Java follows strict conventions for getter and setter names. These conventions are used by many frameworks (Spring, Jackson, Hibernate) to automatically discover properties.
| Field Type | Getter Pattern | Setter Pattern |
|---|---|---|
| Non-boolean fields | getFieldName() |
setFieldName(value) |
| Boolean fields | isFieldName() |
setFieldName(value) |
public class User {
private String name;
private String email;
private int age;
private boolean active;
public User(String name, String email, int age) {
this.name = name;
setEmail(email); // use the setter for validation even in the constructor
setAge(age);
this.active = true;
}
// --- Getters ---
public String getName() {
return name;
}
public String getEmail() {
return email;
}
public int getAge() {
return age;
}
// Boolean getter uses "is" prefix
public boolean isActive() {
return active;
}
// --- Setters with Validation ---
public void setName(String name) {
if (name == null || name.isBlank()) {
throw new IllegalArgumentException("Name cannot be null or blank");
}
this.name = name;
}
public void setEmail(String email) {
if (email == null || !email.contains("@")) {
throw new IllegalArgumentException("Invalid email: " + email);
}
this.email = email.toLowerCase().trim();
}
public void setAge(int age) {
if (age < 0 || age > 150) {
throw new IllegalArgumentException("Age must be between 0 and 150, got: " + age);
}
this.age = age;
}
public void setActive(boolean active) {
this.active = active;
}
}
// Usage
User user = new User("Folau", "Folau@Email.com", 30);
System.out.println(user.getEmail()); // Output: folau@email.com (trimmed and lowercased)
System.out.println(user.isActive()); // Output: true
// Validation prevents invalid data
// user.setAge(-5); // throws IllegalArgumentException: Age must be between 0 and 150, got: -5
// user.setEmail("bad"); // throws IllegalArgumentException: Invalid email: bad
Every class in Java inherits from java.lang.Object, which provides default implementations of toString(), equals(), and hashCode(). The defaults are almost never what you want, so you should override them in your classes.
The default toString() returns something like User@1a2b3c4d (class name + memory address hash). That is useless for debugging. Override it to return a meaningful string representation of the object.
public class User {
private String name;
private String email;
private int age;
// Constructor omitted for brevity
@Override
public String toString() {
return "User{name='" + name + "', email='" + email + "', age=" + age + "}";
}
}
User user = new User("Folau", "folau@email.com", 30);
// Without override: User@1a2b3c4d
// With override:
System.out.println(user); // Output: User{name='Folau', email='folau@email.com', age=30}
The default equals() compares memory addresses (same as ==). Two different objects with identical field values would be considered “not equal.” You override equals() to compare the actual field values.
The contract: If you override equals(), you must also override hashCode(). Objects that are equal must return the same hash code. If you break this contract, objects will behave incorrectly in HashMap, HashSet, and other hash-based collections.
The easiest way to implement these correctly is to use Objects.equals() and Objects.hash() from java.util.Objects.
import java.util.Objects;
public class User {
private String name;
private String email;
private int age;
public User(String name, String email, int age) {
this.name = name;
this.email = email;
this.age = age;
}
@Override
public boolean equals(Object obj) {
// 1. Same reference? Must be equal.
if (this == obj) return true;
// 2. Null or different class? Cannot be equal.
if (obj == null || getClass() != obj.getClass()) return false;
// 3. Cast and compare fields
User other = (User) obj;
return age == other.age
&& Objects.equals(name, other.name)
&& Objects.equals(email, other.email);
}
@Override
public int hashCode() {
return Objects.hash(name, email, age);
}
@Override
public String toString() {
return "User{name='" + name + "', email='" + email + "', age=" + age + "}";
}
}
// Demonstration
User user1 = new User("Folau", "folau@email.com", 30);
User user2 = new User("Folau", "folau@email.com", 30);
User user3 = new User("Lisa", "lisa@email.com", 28);
System.out.println(user1 == user2); // false -- different objects in memory
System.out.println(user1.equals(user2)); // true -- same field values
System.out.println(user1.equals(user3)); // false -- different field values
// Now they work correctly in collections
Set users = new HashSet<>();
users.add(user1);
users.add(user2); // duplicate, not added
System.out.println(users.size()); // Output: 1
The static keyword means “this belongs to the class, not to any specific instance.” Static members are shared across all instances of the class. There is exactly one copy in memory, regardless of how many objects you create.
A static field is shared by all instances. A common use case is tracking how many instances have been created, or storing a constant value that applies to the whole class.
public class User {
// Static field -- shared across ALL User objects
private static int userCount = 0;
// Static constant -- shared and immutable
public static final int MAX_USERS = 10000;
// Instance fields -- unique to each object
private String name;
private int userId;
public User(String name) {
this.name = name;
userCount++; // increment the shared counter
this.userId = userCount; // assign a unique ID
}
// Static method -- access through the class name, not an object
public static int getUserCount() {
return userCount;
}
public String getName() {
return name;
}
public int getUserId() {
return userId;
}
}
User alice = new User("Alice");
User bob = new User("Bob");
User charlie = new User("Charlie");
System.out.println(User.getUserCount()); // Output: 3 (shared counter)
System.out.println(User.MAX_USERS); // Output: 10000
System.out.println(alice.getUserId()); // Output: 1
System.out.println(bob.getUserId()); // Output: 2
System.out.println(charlie.getUserId()); // Output: 3
A static method belongs to the class and can be called without creating an instance. Static methods cannot access instance fields or use this, because there is no “current object” when you call a method on a class.
public class MathHelper {
// Static method -- called on the class, not an instance
public static int add(int a, int b) {
return a + b;
}
public static double celsiusToFahrenheit(double celsius) {
return (celsius * 9.0 / 5.0) + 32;
}
public static boolean isEven(int number) {
return number % 2 == 0;
}
}
// Called using the class name -- no object needed
int sum = MathHelper.add(10, 20); // 30
double temp = MathHelper.celsiusToFahrenheit(100); // 212.0
boolean even = MathHelper.isEven(7); // false
A static block runs once when the class is first loaded into memory, before any objects are created. It is used to initialize complex static fields.
public class AppConfig {
private static final Map CONFIG;
// Static block -- runs once when the class loads
static {
CONFIG = new HashMap<>();
CONFIG.put("app.name", "LoveMeSomeCoding");
CONFIG.put("app.version", "2.0");
CONFIG.put("app.env", "production");
System.out.println("AppConfig loaded.");
}
public static String get(String key) {
return CONFIG.getOrDefault(key, "unknown");
}
}
// The static block runs when this class is first referenced
String name = AppConfig.get("app.name"); // Output (first call): AppConfig loaded.
System.out.println(name); // Output: LoveMeSomeCoding
String version = AppConfig.get("app.version");
System.out.println(version); // Output: 2.0
| Use Static When… | Example |
|---|---|
| The value is the same for all instances | Constants (MAX_SIZE, PI) |
| The method does not need instance data | Utility methods (Math.abs(), Collections.sort()) |
| You need a class-level counter or registry | Instance counters, object pools |
| You need to run initialization code once | Loading configuration, setting up caches |
Do not use static when: the data or behavior is different per object. If two users have different names, name must be an instance field, not static.
Java allows you to define a class inside another class. These are called inner classes or nested classes. They are useful for grouping helper classes with the class that uses them, improving encapsulation and readability.
A static nested class is declared with the static keyword. It does not have access to the outer class’s instance fields or methods — it behaves like a regular top-level class that happens to be scoped inside another class.
public class User {
private String name;
private Address address;
public User(String name, Address address) {
this.name = name;
this.address = address;
}
// Static nested class -- does NOT need a User instance
public static class Address {
private String street;
private String city;
private String state;
private String zip;
public Address(String street, String city, String state, String zip) {
this.street = street;
this.city = city;
this.state = state;
this.zip = zip;
}
@Override
public String toString() {
return street + ", " + city + ", " + state + " " + zip;
}
}
@Override
public String toString() {
return name + " - " + address;
}
}
// Creating the static nested class -- no User instance needed
User.Address address = new User.Address("123 Main St", "Orem", "UT", "84058");
User user = new User("Folau", address);
System.out.println(user); // Output: Folau - 123 Main St, Orem, UT 84058
A non-static inner class has access to the outer class’s instance fields and methods, including private ones. It requires an instance of the outer class to exist.
public class ShoppingCart {
private String customerName;
private List- items = new ArrayList<>();
public ShoppingCart(String customerName) {
this.customerName = customerName;
}
public Item addItem(String productName, double price, int quantity) {
Item item = new Item(productName, price, quantity);
items.add(item);
return item;
}
public double getTotal() {
double total = 0;
for (Item item : items) {
total += item.getSubtotal();
}
return total;
}
// Non-static inner class -- has access to ShoppingCart's fields
public class Item {
private String productName;
private double price;
private int quantity;
public Item(String productName, double price, int quantity) {
this.productName = productName;
this.price = price;
this.quantity = quantity;
}
public double getSubtotal() {
return price * quantity;
}
@Override
public String toString() {
// Can access the outer class's customerName field
return customerName + "'s item: " + productName
+ " ($" + price + " x " + quantity + ")";
}
}
}
ShoppingCart cart = new ShoppingCart("Folau");
ShoppingCart.Item item1 = cart.addItem("Java Book", 49.99, 1);
ShoppingCart.Item item2 = cart.addItem("Coffee Mug", 12.50, 3);
System.out.println(item1); // Output: Folau's item: Java Book ($49.99 x 1)
System.out.println(item2); // Output: Folau's item: Coffee Mug ($12.5 x 3)
System.out.println(cart.getTotal()); // Output: 87.49
An anonymous class is a one-time-use class defined and instantiated in a single expression. You will commonly see them used with interfaces or abstract classes when you need a quick implementation without creating a separate file.
// Suppose we have this interface
public interface Greeting {
void greet(String name);
}
// Anonymous class -- define and instantiate in one expression
Greeting formalGreeting = new Greeting() {
@Override
public void greet(String name) {
System.out.println("Good day, " + name + ". How do you do?");
}
};
formalGreeting.greet("Folau"); // Output: Good day, Folau. How do you do?
// Since Java 8, for single-method interfaces, you can use a lambda instead:
Greeting casualGreeting = name -> System.out.println("Hey, " + name + "!");
casualGreeting.greet("Folau"); // Output: Hey, Folau!
| Type | When to Use | Example |
|---|---|---|
| Static nested class | Helper class that does not need outer instance data | Builder pattern, DTO grouping (User.Address) |
| Non-static inner class | Tightly coupled to outer class, needs outer instance | Iterator inside a collection class |
| Anonymous class | One-time implementation of an interface/abstract class | Event handlers, comparators, callbacks |
Writing a class that works is one thing. Writing a class that is clean, maintainable, and professional is what separates junior developers from senior ones. Here are the practices used in production codebases.
A class should have one reason to change. If your class handles user data, email sending, database queries, and PDF generation, it is doing too much. Break it into focused classes: User, EmailService, UserRepository, PdfGenerator.
Always declare fields as private. Expose them through getters and setters only when necessary. If a field does not need to be changed from outside, do not provide a setter.
Never allow an object to be created in an invalid state. Validate parameters in constructors and throw IllegalArgumentException for bad input.
The default toString() is useless for debugging. Override it in every class to show meaningful state.
If your objects will be compared or placed in collections, always override both. Use Objects.equals() and Objects.hash() for clean, null-safe implementations.
An immutable class cannot be changed after creation. Immutable objects are inherently thread-safe, easy to reason about, and safe to share. To make a class immutable:
final (cannot be subclassed)private finalClass names should be nouns in PascalCase: User, OrderService, BankAccount. Avoid generic names like Data, Info, Manager unless truly appropriate. The name should tell you what the class represents.
Follow the order described in Section 2 (constants, static fields, instance fields, constructors, public methods, private methods). Every class in your codebase should follow the same pattern.
// Example: An immutable class following best practices
public final class Money {
private final double amount;
private final String currency;
public Money(double amount, String currency) {
if (amount < 0) {
throw new IllegalArgumentException("Amount cannot be negative: " + amount);
}
if (currency == null || currency.isBlank()) {
throw new IllegalArgumentException("Currency cannot be null or blank");
}
this.amount = amount;
this.currency = currency.toUpperCase();
}
// Only getters -- no setters (immutable)
public double getAmount() {
return amount;
}
public String getCurrency() {
return currency;
}
// Operations return NEW objects instead of modifying this one
public Money add(Money other) {
if (!this.currency.equals(other.currency)) {
throw new IllegalArgumentException(
"Cannot add " + this.currency + " and " + other.currency
);
}
return new Money(this.amount + other.amount, this.currency);
}
@Override
public String toString() {
return String.format("%s %.2f", currency, amount);
}
@Override
public boolean equals(Object obj) {
if (this == obj) return true;
if (obj == null || getClass() != obj.getClass()) return false;
Money other = (Money) obj;
return Double.compare(other.amount, amount) == 0
&& Objects.equals(currency, other.currency);
}
@Override
public int hashCode() {
return Objects.hash(amount, currency);
}
}
Money price = new Money(29.99, "usd");
Money tax = new Money(2.40, "usd");
Money total = price.add(tax);
System.out.println(price); // Output: USD 29.99
System.out.println(tax); // Output: USD 2.40
System.out.println(total); // Output: USD 32.39
// price, tax, and total are all separate, immutable objects
// None of them can ever be changed after creation
Let us put everything together in a complete, production-style class that demonstrates all the concepts from this tutorial: fields, multiple constructors, constructor chaining, encapsulation with getters/setters, validation, toString/equals/hashCode, static members, and best practices.
import java.util.Objects;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
public class BankAccount {
// --- Static fields ---
private static final double MINIMUM_BALANCE = 0.0;
private static final double MAX_WITHDRAWAL = 10000.0;
private static final DateTimeFormatter FORMATTER =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private static int accountCounter = 0;
// --- Instance fields ---
private final String accountNumber;
private String ownerName;
private String email;
private double balance;
private boolean active;
private final LocalDateTime createdAt;
private final List transactionHistory;
// --- Constructors ---
// Primary constructor -- all initialization happens here
public BankAccount(String ownerName, String email, double initialDeposit) {
if (ownerName == null || ownerName.isBlank()) {
throw new IllegalArgumentException("Owner name cannot be null or blank");
}
if (email == null || !email.contains("@")) {
throw new IllegalArgumentException("Invalid email: " + email);
}
if (initialDeposit < 0) {
throw new IllegalArgumentException(
"Initial deposit cannot be negative: " + initialDeposit
);
}
accountCounter++;
this.accountNumber = "ACC-" + String.format("%06d", accountCounter);
this.ownerName = ownerName;
this.email = email.toLowerCase().trim();
this.balance = initialDeposit;
this.active = true;
this.createdAt = LocalDateTime.now();
this.transactionHistory = new ArrayList<>();
this.transactionHistory.add(formatTransaction("Account opened", initialDeposit));
}
// Constructor chaining -- zero initial deposit
public BankAccount(String ownerName, String email) {
this(ownerName, email, 0.0);
}
// Copy constructor
public BankAccount(BankAccount other) {
this(other.ownerName, other.email, other.balance);
}
// --- Static methods ---
public static int getTotalAccounts() {
return accountCounter;
}
public static double getMinimumBalance() {
return MINIMUM_BALANCE;
}
// --- Public methods (business logic) ---
public void deposit(double amount) {
validateActive();
if (amount <= 0) {
throw new IllegalArgumentException("Deposit amount must be positive: " + amount);
}
balance += amount;
transactionHistory.add(formatTransaction("Deposit", amount));
}
public void withdraw(double amount) {
validateActive();
if (amount <= 0) {
throw new IllegalArgumentException("Withdrawal amount must be positive: " + amount);
}
if (amount > MAX_WITHDRAWAL) {
throw new IllegalArgumentException(
"Withdrawal exceeds max limit of $" + MAX_WITHDRAWAL + ": $" + amount
);
}
if (balance - amount < MINIMUM_BALANCE) {
throw new IllegalStateException(
"Insufficient funds. Balance: $" + balance + ", Requested: $" + amount
);
}
balance -= amount;
transactionHistory.add(formatTransaction("Withdrawal", -amount));
}
public void transfer(BankAccount target, double amount) {
if (target == null) {
throw new IllegalArgumentException("Target account cannot be null");
}
this.withdraw(amount);
target.deposit(amount);
this.transactionHistory.add(
formatTransaction("Transfer to " + target.accountNumber, -amount)
);
target.transactionHistory.add(
formatTransaction("Transfer from " + this.accountNumber, amount)
);
}
public void deactivate() {
this.active = false;
transactionHistory.add(formatTransaction("Account deactivated", 0));
}
// --- Getters ---
public String getAccountNumber() {
return accountNumber;
}
public String getOwnerName() {
return ownerName;
}
public String getEmail() {
return email;
}
public double getBalance() {
return balance;
}
public boolean isActive() {
return active;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
// Return an unmodifiable view -- defensive copy for encapsulation
public List getTransactionHistory() {
return Collections.unmodifiableList(transactionHistory);
}
// --- Setters (only for fields that can change) ---
public void setOwnerName(String ownerName) {
if (ownerName == null || ownerName.isBlank()) {
throw new IllegalArgumentException("Owner name cannot be null or blank");
}
this.ownerName = ownerName;
}
public void setEmail(String email) {
if (email == null || !email.contains("@")) {
throw new IllegalArgumentException("Invalid email: " + email);
}
this.email = email.toLowerCase().trim();
}
// No setters for accountNumber, balance, createdAt -- these are
// controlled internally or are immutable
// --- Private helper methods ---
private void validateActive() {
if (!active) {
throw new IllegalStateException("Account " + accountNumber + " is deactivated");
}
}
private String formatTransaction(String type, double amount) {
return String.format("[%s] %s: $%.2f | Balance: $%.2f",
LocalDateTime.now().format(FORMATTER), type, amount, balance);
}
// --- toString, equals, hashCode ---
@Override
public String toString() {
return "BankAccount{" +
"accountNumber='" + accountNumber + '\'' +
", owner='" + ownerName + '\'' +
", balance=$" + String.format("%.2f", balance) +
", active=" + active +
'}';
}
@Override
public boolean equals(Object obj) {
if (this == obj) return true;
if (obj == null || getClass() != obj.getClass()) return false;
BankAccount other = (BankAccount) obj;
return Objects.equals(accountNumber, other.accountNumber);
}
@Override
public int hashCode() {
return Objects.hash(accountNumber);
}
}
Here is a complete main method that exercises every feature of the BankAccount class.
public class BankApp {
public static void main(String[] args) {
// --- Creating accounts (constructors) ---
BankAccount folau = new BankAccount("Folau", "folau@email.com", 5000.00);
BankAccount lisa = new BankAccount("Lisa", "lisa@email.com", 3000.00);
BankAccount guest = new BankAccount("Guest", "guest@email.com"); // zero balance
System.out.println("=== Accounts Created ===");
System.out.println(folau);
System.out.println(lisa);
System.out.println(guest);
System.out.println("Total accounts: " + BankAccount.getTotalAccounts());
// --- Deposits and withdrawals ---
System.out.println("\n=== Transactions ===");
folau.deposit(1500.00);
System.out.println("After deposit: " + folau.getBalance()); // 6500.00
folau.withdraw(2000.00);
System.out.println("After withdrawal: " + folau.getBalance()); // 4500.00
// --- Transfer ---
System.out.println("\n=== Transfer ===");
folau.transfer(lisa, 1000.00);
System.out.println("Folau's balance: $" + folau.getBalance()); // 3500.00
System.out.println("Lisa's balance: $" + lisa.getBalance()); // 4000.00
// --- Encapsulation: setter validation ---
System.out.println("\n=== Update Email ===");
folau.setEmail(" FOLAU.K@Email.com ");
System.out.println("New email: " + folau.getEmail()); // folau.k@email.com
// --- toString ---
System.out.println("\n=== toString ===");
System.out.println(folau);
// --- equals and hashCode ---
System.out.println("\n=== equals ===");
BankAccount folauCopy = new BankAccount(folau); // copy constructor
System.out.println("folau == folauCopy: " + (folau == folauCopy));
System.out.println("folau.equals(folauCopy): " + folau.equals(folauCopy));
// false -- copy constructor creates a new account number
// --- Transaction history (defensive copy) ---
System.out.println("\n=== Transaction History ===");
for (String tx : folau.getTransactionHistory()) {
System.out.println(" " + tx);
}
// --- Deactivation ---
System.out.println("\n=== Deactivation ===");
guest.deactivate();
System.out.println(guest);
// This would throw IllegalStateException:
// guest.deposit(100.00); // Account ACC-000003 is deactivated
// --- Static members ---
System.out.println("\n=== Static Info ===");
System.out.println("Total accounts created: " + BankAccount.getTotalAccounts());
System.out.println("Minimum balance: $" + BankAccount.getMinimumBalance());
}
}
/*
* Output:
* === Accounts Created ===
* BankAccount{accountNumber='ACC-000001', owner='Folau', balance=$5000.00, active=true}
* BankAccount{accountNumber='ACC-000002', owner='Lisa', balance=$3000.00, active=true}
* BankAccount{accountNumber='ACC-000003', owner='Guest', balance=$0.00, active=true}
* Total accounts: 3
*
* === Transactions ===
* After deposit: 6500.0
* After withdrawal: 4500.0
*
* === Transfer ===
* Folau's balance: $3500.0
* Lisa's balance: $4000.0
*
* === Update Email ===
* New email: folau.k@email.com
*
* === toString ===
* BankAccount{accountNumber='ACC-000001', owner='Folau', balance=$3500.00, active=true}
*
* === equals ===
* folau == folauCopy: false
* folau.equals(folauCopy): false
*
* === Transaction History ===
* [2026-02-28 10:00:00] Account opened: $5000.00 | Balance: $5000.00
* [2026-02-28 10:00:01] Deposit: $1500.00 | Balance: $6500.00
* [2026-02-28 10:00:01] Withdrawal: $-2000.00 | Balance: $4500.00
* [2026-02-28 10:00:01] Transfer to ACC-000002: $-1000.00 | Balance: $3500.00
*
* === Deactivation ===
* BankAccount{accountNumber='ACC-000003', owner='Guest', balance=$0.00, active=false}
*
* === Static Info ===
* Total accounts created: 4
* Minimum balance: $0.0
*/
A Java class is the fundamental building block of object-oriented programming. Here is what you should take away from this tutorial:
A class is a blueprint that defines what data an object holds (fields) and what actions it can perform (methods). An object is a concrete instance created from that blueprint using the new keyword.
Fields should always be private. Use getters and setters to provide controlled access with validation. This is the principle of encapsulation.
Constructors initialize objects. Use constructor overloading for flexibility and constructor chaining (this()) to avoid code duplication.
The this keyword refers to the current object. Use it to disambiguate fields from parameters, enable method chaining, and pass the current object to other methods.
Override toString() for readable debugging output. Override equals() and hashCode() together if your objects will be compared or stored in hash-based collections.
Static members belong to the class, not to instances. Use them for shared constants, utility methods, and counters.
Inner classes help group related classes. Use static nested classes for helpers that do not need outer instance data, and non-static inner classes when tight coupling with the outer class is required.
Follow best practices: single responsibility, meaningful names, immutable objects where possible, consistent class structure, and always validate input. Write classes as if the next developer to read your code is a senior engineer reviewing your pull request -- because they probably are.