Why Private Fields with Public Setters? Understanding Encapsulation and Long vs long
If fields are private but setters are public, isn't that the same as making the field public? And what's the difference between Long and long anyway? Let's clear up these common Java confusions.
The Encapsulation Questionβ
The Code That Sparked the Questionβ
public class User {
private Long id; // Private field
public void setId(Long id) { // Public setter
this.id = id;
}
public long getId() { // Wait... long not Long? π€
return this.id;
}
}
The question: "If I can set the ID through a public method anyway, why bother making it private?"
Valid point! But there's more to encapsulation than hiding.
Encapsulation Isn't About HidingβIt's About Controlβ
Public Field (No Control)β
public class User {
public Long id; // Anyone can do anything!
}
// Usage:
User user = new User();
user.id = -5L; // β Negative ID? No problem! (bad)
user.id = null; // β Null ID? Sure! (bad)
user.id = 999999999L; // β Random huge number? Why not! (bad)
Problem: No validation, no control, chaos!
Private Field with Public Setter (Control)β
public class User {
private Long id;
public void setId(Long id) {
if (id == null) {
throw new IllegalArgumentException("ID cannot be null");
}
if (id <= 0) {
throw new IllegalArgumentException("ID must be positive");
}
this.id = id;
}
}
// Usage:
User user = new User();
user.setId(-5L); // β
Throws exception - caught early!
user.setId(null); // β
Throws exception - validated!
user.setId(100L); // β
Valid - accepted!
Benefit: You control what values are allowed!
The Three Powers of Encapsulationβ
Power 1: Validationβ
Add validation without breaking existing code:
// Version 1: No validation
public void setId(Long id) {
this.id = id;
}
// Version 2: Added validation (API stays the same!)
public void setId(Long id) {
if (id == null || id <= 0) {
throw new IllegalArgumentException("Invalid ID");
}
this.id = id;
}
With public field: You'd have to find every place in your codebase that sets user.id = ... and add checks manually!
Power 2: Flexibility to Change Implementationβ
Change how data is stored internally:
// Version 1: Simple storage
private Long id;
public void setId(Long id) {
this.id = id;
}
// Version 2: Add caching (API unchanged!)
private Long id;
private String cachedIdString;
public void setId(Long id) {
this.id = id;
this.cachedIdString = id.toString(); // Cache string version
}
public String getIdAsString() {
return cachedIdString; // Fast retrieval!
}
External code using setId() doesn't need to change!
Power 3: Enforce Business Rulesβ
Restrict mutations after creation:
private Long id;
private boolean idSet = false;
public void setId(Long id) {
if (idSet) {
throw new IllegalStateException("ID cannot be changed once set");
}
if (id == null || id <= 0) {
throw new IllegalArgumentException("Invalid ID");
}
this.id = id;
idSet = true; // Lock it!
}
Result: IDs can only be set once! (immutability pattern)
What Are We Actually Hiding?β
The key insight: We're hiding the internal implementation, not the functionality.
The Concrete Exampleβ
Scenario: You decide to change how you store the ID.
Version 1: Public Field (BAD - Can't Change)β
public class User {
public Long id; // Everyone accesses this directly
}
// All over your codebase:
User user = new User();
user.id = 123L; // Direct field access everywhere!
Problem: You decide to store ID as a String instead of Long.
public class User {
public String userId; // Changed from Long to String!
}
Result: π₯ EVERYTHING BREAKS!
// This code is everywhere and now ALL fails:
user.id = 123L; // β Compile error: Long can't be assigned to String
Long value = user.id; // β Compile error: String can't be assigned to Long
You have to find and fix EVERY line of code that touches user.id!
Version 2: Private Field (GOOD - Free to Change)β
public class User {
private Long id; // Hidden implementation
public void setId(Long id) {
this.id = id;
}
public Long getId() {
return this.id;
}
}
// All over your codebase:
User user = new User();
user.setId(123L); // Uses setter
Long value = user.getId(); // Uses getter
You decide to change internal storage to String:
public class User {
private String userId; // Changed internal storage!
public void setId(Long id) {
this.userId = String.valueOf(id); // Convert Long β String
}
public Long getId() {
return Long.parseLong(userId); // Convert String β Long
}
}
Result: β NOTHING BREAKS!
// All existing code works exactly the same:
user.setId(123L); // β
Still works
Long value = user.getId(); // β
Still works
No code changes needed anywhere!
What You're Hiding: The Storage Mechanismβ
Users of your class see:
user.setId(123L);
Long id = user.getId();
They DON'T know (and don't care) that internally you:
- Store it as
Long id - Store it as
String userId - Store it as
int id - Calculate it on-the-fly
- Fetch it from a database
- Cache it in memory
That's the power: The interface (setId() and getId()) stays the same, but you're free to change the implementation.
Real-World Scenarioβ
Initial implementation:
public class User {
private Long id;
public void setId(Long id) {
this.id = id;
}
public Long getId() {
return this.id;
}
}
Later, you realize you need to log every ID change:
public class User {
private Long id;
private Logger logger = LoggerFactory.getLogger(User.class);
public void setId(Long id) {
logger.info("Changing ID from {} to {}", this.id, id);
this.id = id;
}
public Long getId() {
return this.id;
}
}
Even later, you need to notify listeners:
public class User {
private Long id;
private List<PropertyChangeListener> listeners = new ArrayList<>();
public void setId(Long id) {
Long oldId = this.id;
this.id = id;
notifyListeners("id", oldId, id); // Notify observers
}
public Long getId() {
return this.id;
}
private void notifyListeners(String property, Long oldValue, Long newValue) {
for (PropertyChangeListener listener : listeners) {
listener.propertyChange(new PropertyChangeEvent(this, property, oldValue, newValue));
}
}
}
All these changes happen inside the class. External code using setId() never needs to change!
The Mental Modelβ
Public field:
External Code β Direct Access to Field
β
[Field Storage]
β
Can't change without breaking everything
Private field with methods:
External Code β setId()/getId() β Stable API
β
[Hidden Logic]
β
[Field Storage] β Can change freely
What's hidden: Everything below the method calls (validation, transformation, storage mechanism, side effects).
What's exposed: Clean, stable API that never changes.
The Bug: Long vs longβ
The Problem Codeβ
public class User {
private Long id; // Object (can be null)
public long getId() { // Primitive (cannot be null)
return this.id; // π₯ NullPointerException if id is null!
}
}
What happens:
User user = new User();
System.out.println(user.getId()); // π₯ CRASH!
// NullPointerException: Cannot unbox null to long
Why the Crash?β
Unboxing fails when null:
Long id = null;
long primitiveId = id; // π₯ Tries to "unbox" null to primitive
// Can't convert null to 0/1/any number
Understanding Primitives vs Wrappersβ
Primitive Types (lowercase)β
byte myByte = 0;
short myShort = 0;
int myInt = 0;
long myLong = 0L;
float myFloat = 0.0f;
double myDouble = 0.0;
boolean myBoolean = false;
char myChar = '\u0000';
Characteristics:
- β Always have a value (never null)
- β Default to 0, false, or '\u0000'
- β Faster (no object overhead)
- β Less memory
- β Cannot be null
- β No utility methods
Wrapper Classes (capitalized)β
Byte myByte = null;
Short myShort = null;
Integer myInt = null;
Long myLong = null;
Float myFloat = null;
Double myDouble = null;
Boolean myBoolean = null;
Character myChar = null;
Characteristics:
- β Can be null
- β
Have utility methods (
.parseInt(),.MAX_VALUE, etc.) - β
Work with generics (
List<Integer>, notList<int>) - β Slower (object overhead)
- β More memory
- β Default to null (can cause NullPointerException)
The Complete Primitive/Wrapper Mappingβ
| Primitive | Wrapper | Default (primitive) | Default (wrapper) |
|---|---|---|---|
byte | Byte | 0 | null |
short | Short | 0 | null |
int | Integer | 0 | null |
long | Long | 0L | null |
float | Float | 0.0f | null |
double | Double | 0.0 | null |
boolean | Boolean | false | null |
char | Character | '\u0000' | null |
The Fix: Keep Types Consistentβ
Option 1: Use Wrapper Types (Recommended for JPA)β
public class User {
private Long id; // Wrapper
public void setId(Long id) {
this.id = id;
}
public Long getId() { // β
Matches field type
return this.id;
}
}
Why wrapper for JPA entities?
- Database IDs can be
nullbefore persistence - JPA uses
nullto detect new entities (not yet saved) - Allows distinction between "no ID yet" (null) and "ID is 0"
Option 2: Use Primitive Typesβ
public class User {
private long id; // Primitive
public void setId(long id) {
this.id = id;
}
public long getId() { // β
Matches field type
return this.id;
}
}
When to use primitives?
- ID will always have a value
- Never need to check for null
- Slight performance gain
When to Use Which?β
Use Primitives (int, long, boolean, etc.)β
β When:
- Value must always exist
- Performance is critical
- Working with calculations
- Simple data types
Example:
public class Product {
private long id; // Always has value
private int quantity; // Always >= 0
private double price; // Always has price
private boolean active; // Always true or false
}
Use Wrappers (Integer, Long, Boolean, etc.)β
β When:
- Value can be absent (null)
- JPA entities (database IDs)
- Collections (
List<Integer>) - Optional fields
Example:
@Entity
public class User {
@Id
@GeneratedValue
private Long id; // null before first save
private Integer age; // null if not provided
private Boolean verified; // null if not yet verified
}
Real-World Examplesβ
Example 1: Age Fieldβ
// β Bad: Age should be optional
public class User {
private int age; // Defaults to 0 if not set
}
User user = new User();
System.out.println(user.age); // 0 (is user 0 years old? or unknown?)
// β
Good: Use wrapper for optional field
public class User {
private Integer age; // null if not provided
}
User user = new User();
System.out.println(user.age); // null (clearly unknown)
Example 2: Database Entityβ
@Entity
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id; // β
Wrapper - null before save
private String name;
private double price; // β
Primitive - always has price
private int stock; // β
Primitive - always has stock
}
// Usage:
Product product = new Product();
System.out.println(product.getId()); // null (not saved yet)
productRepository.save(product);
System.out.println(product.getId()); // 1 (assigned by database)
Example 3: The NullPointerException Trapβ
// β Mixing types - DANGER!
public class User {
private Integer age;
public int getAge() { // Returns primitive!
return this.age; // π₯ NPE if age is null
}
}
// β
Fixed - consistent types
public class User {
private Integer age;
public Integer getAge() { // Returns wrapper!
return this.age; // Safe - can return null
}
}
Autoboxing and Unboxingβ
Java automatically converts between primitives and wrappers:
Autoboxing (Primitive β Wrapper)β
int primitiveInt = 100;
Integer wrapperInt = primitiveInt; // Autoboxing
// Equivalent to: Integer.valueOf(primitiveInt)
Unboxing (Wrapper β Primitive)β
Integer wrapperInt = 100;
int primitiveInt = wrapperInt; // Unboxing
// Equivalent to: wrapperInt.intValue()
The Danger: Unboxing Nullβ
Integer wrapperInt = null;
int primitiveInt = wrapperInt; // π₯ NullPointerException!
// Can't convert null to a number!
Best Practicesβ
β Do Thisβ
1. Keep getter/setter types consistent with field
private Long id;
public Long getId() { return id; } // β
Matches
public void setId(Long id) { this.id = id; } // β
Matches
2. Use wrappers for JPA entity IDs
@Entity
public class User {
@Id
@GeneratedValue
private Long id; // β
Wrapper for database ID
}
3. Use primitives for required fields
public class Product {
private double price; // β
Always required
private int quantity; // β
Always required
}
4. Validate in setters
public void setAge(Integer age) {
if (age != null && age < 0) {
throw new IllegalArgumentException("Age cannot be negative");
}
this.age = age;
}
5. Use Optional for clarity
public Optional<Integer> getAge() {
return Optional.ofNullable(age);
}
// Usage:
user.getAge().ifPresent(age -> System.out.println("Age: " + age));
β Avoid Thisβ
1. Don't mix primitive getters with wrapper fields
private Long id;
public long getId() { return id; } // β NPE waiting to happen!
2. Don't use primitives for optional database fields
@Entity
public class User {
private int age; // β 0 or unknown? Ambiguous!
}
3. Don't make all fields public
public class User {
public Long id; // β No validation, no control!
}
4. Don't forget null checks when unboxing
Integer count = getCount();
int total = count; // β Might throw NPE!
// β
Better:
int total = (count != null) ? count : 0;
Summaryβ
Encapsulation Benefitsβ
Private Field + Public Setter = Control
Powers:
- β Validation (reject invalid values)
- β Flexibility (change implementation)
- β Business rules (enforce constraints)
Primitive vs Wrapperβ
| Aspect | Primitive | Wrapper |
|---|---|---|
| Can be null? | β No | β Yes |
| Default value | 0, false, '\u0000' | null |
| Performance | β Faster | Slower |
| Memory | β Less | More |
| Use in generics? | β No | β Yes |
| Utility methods? | β No | β Yes |
The Golden Ruleβ
Match your getter/setter types with your field type!
// β
Good
private Long id;
public Long getId() { return id; }
// β
Good
private long id;
public long getId() { return id; }
// β Bad - type mismatch!
private Long id;
public long getId() { return id; } // NPE danger!
Remember: Encapsulation gives you control, not just privacy. And keeping types consistent prevents nasty NullPointerExceptions! π‘οΈ
Tags: #java #encapsulation #primitives #wrappers #best-practices #fundamentals
