Skip to main content

Why Private Fields with Public Setters? Understanding Encapsulation and Long vs long

Β· 10 min read
Mahmut Salman
Software Developer

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>, not List<int>)
  • ❌ Slower (object overhead)
  • ❌ More memory
  • ❌ Default to null (can cause NullPointerException)

The Complete Primitive/Wrapper Mapping​

PrimitiveWrapperDefault (primitive)Default (wrapper)
byteByte0null
shortShort0null
intInteger0null
longLong0Lnull
floatFloat0.0fnull
doubleDouble0.0null
booleanBooleanfalsenull
charCharacter'\u0000'null

The Fix: Keep Types Consistent​

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 null before persistence
  • JPA uses null to 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:

  1. βœ… Validation (reject invalid values)
  2. βœ… Flexibility (change implementation)
  3. βœ… Business rules (enforce constraints)

Primitive vs Wrapper​

AspectPrimitiveWrapper
Can be null?❌ Noβœ… Yes
Default value0, false, '\u0000'null
Performanceβœ… FasterSlower
Memoryβœ… LessMore
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