A few years into maintaining a Java codebase, you stop thinking of modifiers as “keywords you add to make the compiler happy” and start treating them like API contracts. When a production incident happens at 2 a.m., the root cause is often not a fancy algorithm—it’s accidental reach: someone wrote to a field they shouldn’t have touched, subclassed a type that wasn’t designed for inheritance, or used a concurrency primitive with the wrong memory semantics.
Modifiers are how you communicate intent to other developers and to the JVM: what is allowed to call this, what can change, what must be implemented, what is shared, what is safe to publish across threads, and what should never be serialized.
I’m going to walk through access modifiers (public/private/package-private/protected) and the non-access modifiers you’ll use constantly (static, final, abstract, synchronized, transient, volatile, native). Along the way I’ll show runnable examples, common mistakes I see in code reviews, and the mental models that help you make decisions quickly—especially in modern Java projects that use modules, records, sealed types, and automated refactoring tools.
Modifiers Are Contracts, Not Decorations
When I review Java code, I read modifiers the way I read method names: they’re part of the meaning. If a method is public, you’ve promised stability (or at least compatibility) to any caller who can see it. If a field is private, you’re protecting invariants and giving yourself room to refactor without breaking callers. If something is volatile, you’re making a statement about cross-thread visibility—not “thread safety” in the broad sense.
A simple analogy I use when explaining this to new teammates: access modifiers are doors in a building, and non-access modifiers are the building’s rules. A public door lets anyone walk in; a private door is behind a locked badge reader; a package-private door is for people on the same floor; a protected door is for your team and for approved apprentices (subclasses) even if they work in a different building.
The building rules are the non-access modifiers: “this room is shared by everyone” (static), “this wall is load-bearing and cannot be moved” (final), “this area is under construction and must be finished by contractors” (abstract), “only one person may be in this room at a time” (synchronized), “don’t include this item when packing for travel” (transient), “everyone sees the latest whiteboard notes” (volatile), “this is handled by a contractor outside the company” (native).
Once you see modifiers as contracts, your design decisions get easier: you pick the smallest surface area that still supports your use case.
Access Modifiers: Your Visibility Lattice
Java gives you four access levels. I recommend memorizing them as a lattice—from most restrictive to most permissive:
- private: only the declaring class
- package-private (default): any type in the same package
- protected: same package, plus subclasses in other packages (with important rules)
- public: anywhere
Here’s the practical visibility matrix (what can access a member?):
Same class
Subclass (different package)
—:
—:
Yes
No
Yes
No
Yes
Yes (with caveats)
Yes
Yes
A few constraints people forget:
- Top-level classes can be public or package-private only. You cannot declare a top-level class as private or protected.
- Constructors follow the same access rules as methods.
- Fields and methods in interfaces are implicitly public (and fields are also static final). This is a big reason I avoid “constant interfaces” as a pattern.
A runnable example: seeing access boundaries
Create this structure (two packages) and run app.Main:
// file: app/Main.java
package app;
import payroll.Employee;
import payroll.payments.ExternalPayrollAdapter;
public class Main {
public static void main(String[] args) {
Employee employee = new Employee("Ava", 120_000);
// Public API: intended for external use.
System.out.println(employee.publicProfile());
// This class is public, but it can only do what the payroll package allows it to.
ExternalPayrollAdapter adapter = new ExternalPayrollAdapter();
System.out.println(adapter.exportForPayments(employee));
// Uncommenting any of these should fail to compile:
// employee.salary; // private
// employee.internalTaxCategory(); // package-private
}
}
// file: payroll/Employee.java
package payroll;
public class Employee {
private final String name;
private int salary;
public Employee(String name, int salary) {
this.name = name;
this.salary = salary;
}
public String publicProfile() {
return "Employee{name=‘" + name + "‘}";
}
// Package-private: accessible only to payroll.*
String internalTaxCategory() {
return salary >= 100_000 ? "HIGH" : "STANDARD";
}
// Private: class invariant stays under this type‘s control.
private void applyRaise(int delta) {
salary += delta;
}
// Public: safe mutation via a validated path.
public void giveAnnualRaise(int delta) {
if (delta <= 0) {
throw new IllegalArgumentException("delta must be positive");
}
applyRaise(delta);
}
}
// file: payroll/payments/ExternalPayrollAdapter.java
package payroll.payments;
import payroll.Employee;
public class ExternalPayrollAdapter {
public String exportForPayments(Employee employee) {
// Cannot call employee.internalTaxCategory() here because this is a different package.
return "EXPORT:" + employee.publicProfile();
}
}
This tiny setup demonstrates the core idea: access modifiers create boundaries that are enforced by the compiler, not by “developer discipline.”
Access modifiers also apply to nested types (and that matters)
Another thing I see missed in reviews: nested classes, enums, and records can be private/protected/package-private inside a top-level class. That’s huge for keeping helper types close to where they’re used without exporting them as part of your package surface area.
A practical example is a parser with a private token type:
public final class QueryParser {
private QueryParser() {}
private enum TokenType {
IDENT, NUMBER, LPAREN, RPAREN
}
// Public API stays small; implementation details stay private.
public static Object parse(String input) {
// … tokenization uses TokenType internally
return new Object();
}
}
If I only need that enum as an implementation detail, making it private prevents “creative reuse” elsewhere that later becomes a dependency you can’t delete.
A note on reflection: modifiers are real, but can be bypassed
In day-to-day Java, the compiler enforces access. But reflection can bypass certain access checks, especially in older code that uses setAccessible(true). In modern Java with modules, reflective access across module boundaries may be restricted unless you explicitly open packages.
My mental model: modifiers protect you by default in normal code paths; if you also rely on reflection, treat that as a special integration surface and keep it quarantined.
Package-Private (Default): The Most Underrated Tool in Large Codebases
If you take only one recommendation from me, take this: use package-private more often.
Why? Because “internal” in Java is naturally expressed as “same package.” When you set no modifier, you’re explicitly choosing package-private access. That gives you a clean separation:
- public: stable API surface
- package-private: internal collaboration inside a package
In practice, I like packages that represent a cohesive subsystem:
billingcontains billing domain logicbilling.internalcontains helpers not meant for other packagesbilling.apicontains the public types meant for other modules
With this arrangement, package-private methods become a powerful alternative to “just make it public so tests can reach it.” Instead, tests can sit in the same package and access the internal members without widening the production API.
Modern Java note: modules don’t replace access modifiers
In JPMS (the module system), a module can export a package (or not). That’s a second layer of visibility, but it’s not a substitute:
- Access modifiers control type/member access at compile time within the language.
- Module exports control what packages are readable from other modules.
I treat modules as “shipping boundaries” and access modifiers as “design boundaries.” You often want both.
A practical pattern I’ve used on multi-module codebases:
- Export only
*.apipackages frommodule-info.java. - Keep implementation packages non-exported.
- Inside exported packages, prefer small public types with private/package-private guts.
This makes “what is public” explicit twice: once at the module boundary, and again at the class/member level.
When I avoid package-private
I avoid package-private when the package is a dumping ground (for example util with unrelated classes). If the package isn’t cohesive, package-private becomes accidental coupling.
If your package already sprawls, fix the package structure first. Modifiers can’t save a messy namespace.
Common pitfall: “package-private is basically public”
It’s not. It’s “public to the package,” which is often a small team-defined boundary. But it becomes effectively public if:
- your package is huge, or
- many unrelated components live in the same package, or
- you treat “package” as a folder rather than a design concept.
If you see a package with 80+ classes and mixed responsibilities, I’d rather spend a day restructuring packages than keep sprinkling public everywhere.
protected: Inheritance-Friendly, But Easy to Misuse
The keyword protected looks like it means “subclasses can access this,” and that’s partially true. The tricky part is how protected access works across packages.
Protected members are accessible:
- from any class in the same package (even non-subclasses)
- from subclasses in other packages, but only through the subclass instance (not arbitrary instances)
This often surprises people. Here’s a runnable example demonstrating the “through the subclass” rule.
// file: domain/Account.java
package domain;
public class Account {
protected int balance;
protected void deposit(int amount) {
if (amount <= 0) throw new IllegalArgumentException("amount must be positive");
balance += amount;
}
public int balance() {
return balance;
}
}
// file: app/PremiumAccount.java
package app;
import domain.Account;
public class PremiumAccount extends Account {
public void bonusDeposit(int amount) {
// OK: access protected member on this.
deposit(amount);
}
public void copyBalanceFrom(PremiumAccount other) {
// OK: other is PremiumAccount (same subclass type).
this.balance = other.balance;
}
public void tryCopyFromBase(Account other) {
// This is NOT OK across packages:
// this.balance = other.balance;
// Because other is typed as Account, not PremiumAccount.
}
}
So when do I use protected?
- I use it when I am intentionally designing for inheritance.
- I document the subclassing contract (what must be called, what invariants must hold).
- I keep the number of protected members small.
Safer alternatives I prefer in 2026-era Java
Inheritance is still useful, but modern Java offers tools that often reduce the need:
- Composition: inject collaborators rather than subclassing
- Package-private “extension points” within a subsystem
- Sealed classes/interfaces: explicitly control who may extend a type
- Interfaces with default methods: provide behavior without exposing state
If you’re tempted to mark a field as protected, pause. Protected state is a common way to let subclasses break invariants. If you must allow subclass customization, prefer protected methods that enforce validation and keep fields private.
A practical rule I use: protected for behavior, not for data
If you want subclasses to customize behavior, give them a protected hook method:
protected BigDecimal computeFee(Transaction tx) {
return BigDecimal.ZERO;
}
If you want subclasses to read state, consider a protected final getter that returns a safe view.
If you think subclasses need to write internal state directly, that’s often a sign your type isn’t stable enough to be a base class.
private and public: where most APIs win or lose
People talk about public vs private like it’s just “visibility.” In practice, it’s an economic choice.
- Every public method is a promise you must keep compatible (or at least behaviorally stable).
- Every private method is freedom: you can rename, delete, reorder, inline, refactor, and no external caller can complain.
I prefer to default to private and then “open up” with the smallest access level that satisfies real callers.
Design trick: keep fields private, expose behavior
If you expose fields (even as public final), you hard-code representation. Expose behavior and you can change representation later.
Even with records, which are great, the “representation” is part of the record components. That’s fine when you truly want transparent carriers. But for domain entities with invariants and lifecycle, I still prefer classes with private fields and explicit methods.
Constructors are part of the contract too
If you make a constructor public, you’re allowing anyone to create instances and possibly violate invariants unless you validate.
Two patterns I use constantly:
- private constructor + public static factory
- package-private constructor for “internal wiring”
That lets you keep creation policies centralized without making callers memorize “the right way to build this object.”
static and final: Shared State, Constants, and Immutability
Static and final are the modifiers I see most in everyday business code, and also the ones that cause the most subtle bugs when misunderstood.
static: belongs to the class, not the instance
Use static for:
- stateless utility functions
- factory methods
- shared caches (with care)
- constants (usually with final)
Avoid static for:
- storing request/session/user-specific data
- “global mutable state” that makes tests flaky
Runnable example: a safe, lazy singleton using the holder idiom:
// file: infra/ClockService.java
package infra;
import java.time.Clock;
public final class ClockService {
private ClockService() {}
// Lazy init without explicit synchronization.
private static final class Holder {
private static final Clock INSTANCE = Clock.systemUTC();
}
public static Clock utc() {
return Holder.INSTANCE;
}
}
This pattern stays simple and typically works well for truly shared, immutable dependencies.
#### The static pitfall I see most: shared mutable collections
Something like this:
public final class FeatureFlags {
public static final Map FLAGS = new HashMap();
}
Even though the reference is final, the map is mutable and now global. That’s a recipe for:
- race conditions
- tests that depend on execution order
- “works locally, fails in CI” behavior
If you need shared state, make the API explicit: enable(String flag), isEnabled(String flag), and choose a thread-safe structure with clear lifecycle.
final: “cannot be changed” (but be precise about what)
Final has three major meanings depending on where you apply it:
- final variable: the reference cannot be reassigned
- final method: cannot be overridden
- final class: cannot be subclassed
A critical nuance: final on an object reference does not make the object itself immutable.
// file: example/FinalNuance.java
package example;
import java.util.ArrayList;
import java.util.List;
public class FinalNuance {
public static void main(String[] args) {
final List names = new ArrayList();
names.add("Ava"); // Allowed: object is mutable
// names = new ArrayList(); // Not allowed: reference reassignment
System.out.println(names);
}
}
In modern Java, I reach for records and unmodifiable collections when I want true immutability:
recordfor data carriers with value semanticsList.copyOf(...),Map.copyOf(...)when exposing collections
#### Final and thread safety: safe publication is the real prize
Final fields have special guarantees in the Java Memory Model when construction is done safely (i.e., this doesn’t escape during construction). The practical takeaway I use:
- If an object is immutable and its fields are final, you can share it across threads without extra synchronization.
This is one reason immutable “configuration snapshot” objects are so reliable in concurrent systems.
Traditional vs modern patterns (what I recommend)
Traditional approach
—
public static final fields
mutable POJOs with setters
record (and validate in compact constructor) subclassing + protected fields
synchronized increment
AtomicInteger / LongAdder when contention is real I’m not saying “never use the traditional approach.” I’m saying: if you start modern-first, you avoid a lot of accidental complexity.
abstract, synchronized, volatile, transient, native: Behavioral Modifiers the JVM Takes Seriously
These modifiers change how code behaves at runtime or how the language expects you to complete a type.
abstract: incomplete by design
Abstract classes are great when you have shared implementation plus required customization points.
Runnable example: a template method pattern with protected hooks:
// file: payments/PaymentProcessor.java
package payments;
public abstract class PaymentProcessor {
public final String process(int cents) {
validate(cents);
String authorizationId = authorize(cents);
capture(authorizationId);
return authorizationId;
}
protected void validate(int cents) {
if (cents <= 0) throw new IllegalArgumentException("cents must be positive");
}
protected abstract String authorize(int cents);
protected void capture(String authorizationId) {
// Default capture behavior. Subclasses may override if needed.
}
}
// file: payments/StripeLikeProcessor.java
package payments;
public class StripeLikeProcessor extends PaymentProcessor {
@Override
protected String authorize(int cents) {
return "AUTH-" + cents;
}
}
Notice the design choice: the public process method is final. That’s intentional. If subclasses override the orchestration method, invariants and logging often break.
#### Abstract vs interface (and why I use both)
A rule of thumb that’s served me well:
- I use an interface when I want a capability contract and multiple implementations.
- I use an abstract class when I want to share implementation and enforce a workflow.
Modern interfaces also support default methods and private helper methods inside the interface, which can reduce the need for abstract base classes when the shared logic is small and doesn’t require shared state.
synchronized: mutual exclusion and visibility
Synchronized provides:
- mutual exclusion (one thread at a time in the critical section)
- a happens-before relationship (memory visibility guarantees)
Runnable example: safe increments.
// file: concurrency/SynchronizedCounter.java
package concurrency;
public class SynchronizedCounter {
private int value;
public synchronized void increment() {
value++;
}
public synchronized int get() {
return value;
}
}
If you’re doing high-throughput counters, this can become a bottleneck under contention. In many services, that bottleneck shows up as increased tail latency (p95/p99) rather than average latency.
#### Synchronized methods vs synchronized blocks
I often prefer synchronized blocks because they’re more explicit about what lock you’re using:
public class Inventory {
private final Object lock = new Object();
private int available;
public void add(int count) {
synchronized (lock) {
available += count;
}
}
public int getAvailable() {
synchronized (lock) {
return available;
}
}
}
This also prevents “accidentally locking on this” when the object might be used as a lock elsewhere.
#### Common mistake: synchronized isn’t a magic “thread-safe” stamp
Synchronization protects a critical section. It doesn’t automatically make everything in the class thread-safe if:
- you expose internal mutable state
- you call out to external code while holding the lock
- you lock on different monitors for related state
One of the nastiest patterns is calling a callback while holding a lock; that’s how deadlocks and weird priority inversions creep in.
volatile: visibility and ordering, not atomicity
Volatile is the modifier that I think causes the most confusion because it sounds like “this variable is dangerous,” which is… not wrong, but not helpful.
Here’s my mental model:
- A volatile read always observes the most recently written value by any thread (visibility).
- A volatile write happens-before subsequent volatile reads of the same variable (ordering / synchronization edge).
- Volatile does not make compound actions atomic.
The “not atomic” piece is what bites people. count++ is a read-modify-write sequence, and volatile does not make that sequence atomic.
#### Correct use case: a shutdown flag
Volatile shines for state flags and publication where you don’t need compound atomicity.
public final class Worker implements Runnable {
private volatile boolean running = true;
public void stop() {
running = false;
}
@Override
public void run() {
while (running) {
// do work
}
}
}
This works because:
- One thread writes
running = false. - Another thread reads
runningin a loop. - There’s no “increment” or multi-step invariant that needs atomicity.
#### Incorrect use case: volatile counter
public final class BadCounter {
private volatile int count;
public void increment() {
count++; // not atomic
}
public int get() {
return count;
}
}
This may appear to work under light load and then fail under real concurrency.
If you need a counter, choose based on contention:
AtomicInteger/AtomicLongfor atomic increments with moderate contentionLongAdderfor very hot counters with high contention
#### Volatile and safe publication
Another correct use: publish a fully-constructed immutable object.
public final class ConfigHolder {
private volatile AppConfig config = AppConfig.defaultConfig();
public void reload(AppConfig newConfig) {
// If AppConfig is immutable, this is a clean swap.
config = newConfig;
}
public AppConfig current() {
return config;
}
}
This is a powerful pattern when AppConfig is immutable (often a record or a class with final fields). You avoid locks, and readers always see a valid snapshot.
transient: serialization boundary control
Transient tells Java serialization mechanisms (notably built-in Serializable) to skip a field.
Even if you don’t use built-in serialization directly, transient can still matter because many frameworks either:
- honor transient, or
- have similar “ignore this field” annotation support
I use transient for fields that are:
- derived (cache) and can be recomputed
- sensitive and must not leave process memory (tokens, passwords)
- non-serializable resources (threads, locks, file handles)
Example: cache + rebuild after deserialization:
import java.io.Serial;
import java.io.Serializable;
import java.util.Objects;
public final class UserProfile implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
private final String firstName;
private final String lastName;
private transient String cachedDisplayName;
public UserProfile(String firstName, String lastName) {
this.firstName = Objects.requireNonNull(firstName);
this.lastName = Objects.requireNonNull(lastName);
}
public String displayName() {
String local = cachedDisplayName;
if (local == null) {
local = firstName + " " + lastName;
cachedDisplayName = local;
}
return local;
}
}
Two warnings I keep in mind:
- Transient does not equal “secure.” It prevents certain serialization paths, not memory inspection or logging leaks.
- If you rely on transient for correctness, you should ensure your type behaves correctly after deserialization (reinitialize transient fields as needed).
native: crossing the Java boundary
Native means the method is implemented outside Java, typically via JNI (or similar mechanisms). It’s rare in typical application code, but it’s critical to understand why it exists:
- integrating with OS capabilities
- using platform-specific libraries
- performance-sensitive primitives (though many are now in core JDK)
Example declaration:
public final class CryptoBindings {
private CryptoBindings() {}
public static native byte[] sha256(byte[] input);
static {
// System.loadLibrary("crypto_bindings");
}
}
When I review code with native methods, my concerns go beyond Java semantics:
- crash risk (native bugs can take down the JVM)
- memory safety
- deployment complexity (platform-specific binaries)
- observability (native failures can be harder to diagnose)
If you can avoid native, I usually do—unless the value is very clear.
Expansion Strategy
Add new sections or deepen existing ones with:
- Deeper code examples: More complete, real-world implementations
- Edge cases: What breaks and how to handle it
- Practical scenarios: When to use vs when NOT to use
- Performance considerations: Before/after comparisons (use ranges, not exact numbers)
- Common pitfalls: Mistakes developers make and how to avoid them
- Alternative approaches: Different ways to solve the same problem
Designing a Clean API Surface with Modifiers
When I’m designing a package or module, I think in layers:
1) What must be public? (the smallest usable API)
2) What should be internal? (package-private collaborators)
3) What must be private? (invariants, representation, sharp edges)
This leads to a consistent pattern:
- public types are few and intentionally named
- most helpers are package-private
- fields are private
- constructors are constrained (public only when safe)
The “public surface area budget” rule
I treat public members like a budget. Every new public method increases:
- documentation burden
- backward compatibility constraints
- testing surface area
- security review scope
If you’re building internal services, you might think compatibility doesn’t matter—but it does. Internal callers become “external” the moment another team depends on you.
Records and access modifiers
Records are fantastic for immutable data carriers. Two practical rules I follow:
- Use records for “data across boundaries” (API DTOs, event payloads, config snapshots).
- Avoid records for “domain entities with behavior + invariants that evolve.”
Records also let you control access like any class (public or package-private top-level; components are part of the record’s API). If a record is public, its components are public accessors by design. That’s perfect for DTOs; it’s often too transparent for complex domain state.
Sealed types and access modifiers
Sealed classes/interfaces change the inheritance conversation. If you want a stable hierarchy with a known set of implementations, sealed is a better tool than “leave it open and hope nobody subclasses it.”
The practical benefit I feel in maintenance:
- you can reason about exhaustiveness (especially with
switch) - you can refactor internals without worrying about unknown subclasses
In that world, I end up using protected less. I either:
- seal the hierarchy and keep state private, or
- avoid inheritance and use composition.
Modifiers in Real Code Reviews: Common Mistakes and Fixes
Here are patterns I regularly correct.
Mistake 1: public fields (even if final)
Symptom:
public final List items;
Problem:
- you exposed representation
- callers can keep references and observe internal changes
Fix:
- keep fields private
- expose safe accessors (defensive copy or unmodifiable view)
Mistake 2: protected fields for convenience
Symptom:
protected Connection connection;
Problem:
- subclasses can violate invariants and lifecycle
Fix:
- keep fields private
- expose protected methods that enforce lifecycle (
open(),close(),executeQuery(...))
Mistake 3: static mutable state “because it’s easy”
Symptom:
static Map cache = new HashMap();
Problem:
- race conditions, memory leaks, test coupling
Fix:
- prefer instance scope
- if you need shared cache, make it explicit, thread-safe, and bounded
Mistake 4: volatile as a substitute for synchronization
Symptom:
- multiple fields updated together, with only volatile on one
Problem:
- readers can see torn state across fields
Fix:
- publish an immutable snapshot (single volatile reference)
- or use synchronized/locks
- or use atomic structures designed for the invariant you need
Mistake 5: package-private abuse due to package sprawl
Symptom:
- “everything is default access” in a giant package
Problem:
- weak boundaries, implicit coupling
Fix:
- restructure packages around cohesive subsystems
- then use package-private intentionally
Performance Considerations (Practical, Not Theoretical)
I don’t pick modifiers for micro-optimizations, but some modifiers influence performance under load.
synchronized and contention
Under low contention, synchronized is often fine. Under high contention:
- throughput can drop noticeably
- tail latency can increase significantly
I usually start with the simplest correct thing (often synchronized), and only upgrade to atomics or lock-free structures if profiling shows contention.
volatile reads in hot loops
Volatile reads are more expensive than plain reads because they prevent certain compiler and CPU reorderings. In most business code, this doesn’t matter. But if you’re in a very tight loop (high-frequency trading, telemetry pipelines), it can.
A strategy I use:
- keep volatile reads out of inner loops when possible
- snapshot volatile state into a local variable once per iteration batch
final and optimization
Final methods/classes can enable better inlining and optimization because the JVM has stronger guarantees about override behavior. I don’t mark things final solely for performance, but it’s a nice side effect when the design already wants “no subclassing.”
Practical Scenarios: Picking the Right Modifier Quickly
When I’m moving fast, I use heuristics.
Scenario: internal helper method
- Start with package-private.
- If it’s truly internal to the class, make it private.
- Avoid public unless there’s a real cross-package caller.
Scenario: library/API method used by others
- Make it public.
- Keep it small, document it, test it thoroughly.
- Hide complexity behind private/package-private methods.
Scenario: extension point for other teams
- Prefer composition + interface.
- If inheritance is required, consider sealed types (if the implementation set is controlled) or carefully documented protected methods.
Scenario: configuration used across threads
- Use an immutable config object (often a record).
- Publish it via a volatile reference swap.
- Avoid many volatile fields that must change together.
Scenario: field that must never leave the process
- Mark it transient (if serialization is in play).
- Also avoid logging it and consider using specialized secret-handling patterns.
Alternative Approaches (When Modifiers Aren’t Enough)
Modifiers are powerful, but sometimes they’re the wrong layer.
Modules as “shipping” boundaries
Use modules to control which packages are accessible to other modules. It complements access modifiers and can prevent entire packages from being used externally even if types are public.
Encapsulation through factories
A private constructor plus factories can enforce invariants and return interfaces instead of implementations. This keeps callers from depending on concrete classes.
Composition to avoid protected
Instead of subclassing:
- inject strategies
- pass collaborators
- use small interfaces
In practice, composition tends to produce more testable and refactor-friendly code.
Quick Reference: What I Default To
If I had to summarize my defaults:
- Fields:
private(almost always) - Methods:
privateor package-private unless there’s a clear external caller - Classes: package-private unless it’s a deliberately public type
- Inheritance: avoid by default; if used, keep
protectedsmall and behavior-focused - Immutability: prefer
finalfields + immutable types; use records for DTOs - Concurrency:
synchronizedfor simple invariants,Atomic*/LongAdderfor counters,volatilefor flags/snapshot publication - Serialization: use
transientfor caches/secrets/non-serializable resources - Native: only when the value is undeniable
If Relevant to Topic
- Modern tooling and AI-assisted workflows (for infrastructure/framework topics)
- Comparison tables for Traditional vs Modern approaches
- Production considerations: deployment, monitoring, scaling
The punchline I want you to keep: modifiers are not ceremonial. They’re the most cost-effective, compiler-enforced way to communicate boundaries and invariants. If you choose them intentionally, you’ll prevent entire classes of bugs before they exist—and you’ll make your future refactors feel like routine maintenance instead of archaeology.


