The fastest way to make a Java codebase painful is to treat “method” as “a place to dump logic.” I’ve inherited services where one 300-line method handled validation, persistence, retries, logging, formatting, and business rules. Every change felt risky because the method had no clear contract—just a tangle of side effects.
A well-shaped method is the opposite: a small, named unit of behavior with a clear input/output story. It gives you reuse, but more importantly it gives you readability: your code becomes a set of sentences you can trust. When you get method design right, you ship faster because you can change one behavior without breaking three others.
You’re going to see methods from multiple angles: the syntax and parts that matter in practice, signature design that won’t rot, instance vs static tradeoffs, overloading and overriding hazards, how the call stack actually behaves at runtime, and the mistakes I still see in production code. I’ll keep it practical: runnable examples, design rules you can apply tomorrow, and a few “don’t do this” cases that will save you hours.
Methods as a unit of behavior (not just syntax)
A Java method is a block of code that belongs to a class (or interface) and expresses behavior. That sounds basic, but the design pressure comes from one question:
If I only read the method signature and its name, do I understand what it promises?
A typical method declaration has these components:
- Modifiers: visibility (
public,protected, package-private,private), and other modifiers likestatic,final,synchronized,abstract. - Return type: a concrete type, a generic type, or
void. - Name: usually
lowerCamelCase, and ideally a verb phrase. - Parameters: zero or more inputs.
- Body: the implementation.
Here’s a small example I’d actually accept in a code review, because the name and signature explain the job:
import java.math.BigDecimal;
import java.time.Clock;
import java.time.Instant;
public final class InvoiceService {
private final Clock clock;
public InvoiceService(Clock clock) {
this.clock = clock;
}
public Invoice createInvoice(String customerId, BigDecimal subtotal) {
// Non-obvious: we capture time once so the invoice is internally consistent.
Instant createdAt = clock.instant();
BigDecimal tax = subtotal.multiply(new BigDecimal("0.085"));
BigDecimal total = subtotal.add(tax);
return new Invoice(customerId, subtotal, tax, total, createdAt);
}
public record Invoice(
String customerId,
BigDecimal subtotal,
BigDecimal tax,
BigDecimal total,
Instant createdAt
) {}
}
Notice what I’m doing:
- I keep the method focused on one responsibility: create an invoice snapshot.
- I avoid hidden time dependencies by injecting a
Clock. - I return a value instead of mutating some global state.
That’s not “academic.” It’s what makes the method testable and safe to call.
The “sentence test” for method readability
When I’m scanning unfamiliar code, I want method calls to read like a story:
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new NotFoundException(orderId));
Pricing pricing = pricingService.calculatePricing(order, discounts);
PaymentAuth auth = paymentGateway.authorize(order, pricing.total());
order.markAuthorized(auth);
orderRepository.save(order);
If your method names are vague (process, handle, doStuff) you force the reader to open bodies constantly. If your method signatures are sloppy (too many parameters, unclear ownership, ambiguous null behavior), you force the reader to guess. Both slow teams down.
Method cohesion: one reason to change
A method is cohesive when its lines all serve one purpose. A practical heuristic I use:
- If you can describe the method with one verb phrase, it’s probably cohesive.
- If you need “and then” multiple times, it’s probably doing too much.
Bad:
createUserAndSendWelcomeEmailAndLogAuditEvent()
Better:
createUser()sendWelcomeEmail()auditUserCreated()
You don’t need to split everything into tiny methods, but you do want boundaries where responsibilities change.
Signatures that age well: parameters, returns, and contracts
Most long-term method pain comes from signatures that felt convenient on day one.
Parameter design: fewer, richer inputs
A method with 6–10 parameters is usually a sign your method doesn’t have a stable abstraction. When I see that, I reach for one of these moves:
- Introduce a parameter object (a small type that groups related inputs).
- Move behavior onto the object that owns the data (instance method instead of a utility taking everything).
- Split the method into two or three methods with clearer intent.
Here’s a parameter object approach that keeps call sites readable:
import java.time.LocalDate;
public final class SubscriptionPricing {
public Money price(PriceRequest request) {
// Non-obvious: business rules use request fields consistently.
if (request.plan() == Plan.ENTERPRISE) {
return request.basePrice().multiply(0.9);
}
if (request.billingCycle() == BillingCycle.ANNUAL) {
return request.basePrice().multiply(0.95);
}
return request.basePrice();
}
public record PriceRequest(
Plan plan,
BillingCycle billingCycle,
Money basePrice,
LocalDate startDate
) {}
public enum Plan { STARTER, PRO, ENTERPRISE }
public enum BillingCycle { MONTHLY, ANNUAL }
public record Money(long cents) {
public Money multiply(double factor) {
return new Money(Math.round(cents * factor));
}
}
}
You get a method signature that stays stable when you add a new field later.
Return types: be explicit about “nothing happened”
I prefer return types that force callers to handle important outcomes. A few patterns I use:
- Return a value for computations (
BigDecimal,Money,Result). - Return
voidonly when the method’s whole purpose is a side effect. - Return
Optionalwhen “no value” is a valid outcome. - Avoid returning
nullas a “no result” signal.
Example with Optional:
import java.util.Map;
import java.util.Optional;
public final class FeatureFlags {
private final Map flags;
public FeatureFlags(Map flags) {
this.flags = Map.copyOf(flags);
}
public Optional findFlag(String name) {
return Optional.ofNullable(flags.get(name));
}
}
Document the contract in the signature (and enforce it)
If your method requires a non-empty string, don’t rely on tribal knowledge. Encode it:
- Use stronger types (
CustomerIdinstead ofString). - Validate at boundaries and fail fast.
- Add a test that describes the contract.
A small “strong type” example:
import java.util.Objects;
public record CustomerId(String value) {
public CustomerId {
Objects.requireNonNull(value, "value");
if (value.isBlank()) {
throw new IllegalArgumentException("CustomerId cannot be blank");
}
}
}
Now your method signature becomes self-documenting:
public Invoice createInvoice(CustomerId customerId, BigDecimal subtotal) { ... }
Traditional vs modern signature habits
Here’s the trade I recommend in real teams:
Traditional approach
—
Add more parameters as requirements grow
Return null
Optional or a result type that carries status Return magic codes (-1, 0)
Return internal collections directly
List.copyOf(...) / unmodifiable views doStuff() / process()
calculateTax, reserveSeat, sanitizeEmailAddress Varargs and overload interactions (a subtle footgun)
Varargs (Type...) look friendly but can make overload resolution confusing and hide allocations.
public final class Logger {
public void info(String message) {
System.out.println("INFO: " + message);
}
public void info(String template, Object... args) {
// args becomes an array at the call site
System.out.println("INFO: " + template + " args=" + java.util.Arrays.toString(args));
}
}
At a call site:
logger.info("hello")uses the non-varargs overload (nice).logger.info("hello", 1)uses varargs (allocates anObject[]).
I don’t ban varargs; I just use them intentionally and avoid mixing many overloads with varargs unless I’m confident the API is unambiguous.
Checked vs unchecked exceptions: the signature is part of the contract
A method signature can also include throws, and that choice matters because it shapes every caller.
- Checked exceptions (must be declared/handled) are useful when the caller can reasonably recover and you want to force the decision.
- Unchecked exceptions (
RuntimeException) are useful when failing is exceptional or recovery isn’t realistic.
Example where a checked exception communicates a recoverable boundary:
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
public final class FileLoader {
public String loadUtf8(Path path) throws IOException {
return Files.readString(path);
}
}
Example where unchecked is reasonable (programmer error / invariant violation):
public final class Percent {
private final int value;
public Percent(int value) {
if (value 100) {
throw new IllegalArgumentException("percent must be 0..100");
}
this.value = value;
}
public int value() {
return value;
}
}
Instance methods, static methods, and interface methods
I choose method placement based on ownership: who should “know” how to do the work?
Instance methods: behavior that depends on object state
If a method uses instance fields, it’s naturally an instance method. That’s the core OOP benefit: state + behavior travel together.
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
public final class ShoppingCart {
private final List lines = new ArrayList();
public void addItem(String sku, int quantity) {
if (quantity <= 0) {
throw new IllegalArgumentException("quantity must be positive");
}
lines.add(new CartLine(sku, quantity));
}
public List lines() {
// Non-obvious: protect internal representation.
return Collections.unmodifiableList(lines);
}
public record CartLine(String sku, int quantity) {}
}
Common mistake: returning the mutable list directly. That gives callers the power to break invariants.
Static methods: behavior that doesn’t need object state
Static methods are a great fit for:
- Pure computations
- Parsing/formatting
- Factory helpers
- Small “glue” functions that don’t represent a domain object
import java.math.BigDecimal;
import java.math.RoundingMode;
public final class TaxMath {
private TaxMath() {}
public static BigDecimal applyRate(BigDecimal amount, BigDecimal rate) {
// Non-obvious: keep money math deterministic.
return amount.multiply(rate).setScale(2, RoundingMode.HALF_UP);
}
}
Static can be abused when it becomes a hiding spot for global state. A static method that reads configuration, uses the system clock, and writes logs is still testable, but it pushes you toward brittle tests.
Interface methods: default behavior and shared helpers
Modern Java allows default and private methods in interfaces. I use them carefully:
- Default methods can provide a safe baseline behavior.
- Private interface methods can remove duplication inside the interface.
- Don’t turn interfaces into “utility bags.” Keep them focused.
import java.time.Instant;
public interface Auditable {
Instant createdAt();
default boolean isOlderThanDays(long days, Instant now) {
return createdAt().isBefore(now.minusSeconds(days 24 60 * 60));
}
}
Choosing placement: a quick decision guide I use
When I’m unsure where a method should live, I ask these questions:
- Does it rely on instance fields? If yes, it’s probably an instance method.
- Is it a pure transformation of inputs to output? If yes, consider
static. - Does it express behavior that belongs to a domain object? If yes, put it on that object.
- Does it exist only to “help” many places? If yes, that might be a design smell—consider extracting a dedicated service rather than a grab-bag utility.
A lot of “utility” methods are actually missing domain types.
Overloading and overriding: power tools with sharp edges
Two similar-looking concepts behave very differently:
- Overloading: same method name, different parameter list. Chosen at compile time.
- Overriding: subclass provides a new implementation with the same signature. Chosen at runtime.
Overloading hazards: null, boxing, and surprising matches
Overloading reads nicely at the call site, but it can create ambiguity.
public final class EmailSender {
public void send(String to, String subject) {
System.out.println("Sending plain email to " + to + ": " + subject);
}
public void send(String to, String subject, boolean highPriority) {
System.out.println("Sending email to " + to + " (priority=" + highPriority + "): " + subject);
}
public void send(String to, Object templateModel) {
System.out.println("Sending templated email to " + to + " with model " + templateModel);
}
}
Now imagine a caller passing null for the second argument:
EmailSender sender = new EmailSender();
sender.send("[email protected]", null);
That will compile, but it might bind to a different overload than you intended in more complex cases (especially when overloads involve String, Object, and other reference types). When overload resolution gets tricky, I prefer:
- Different method names (
sendPlain,sendTemplated) - A request object (
SendEmailRequest)
#### Autoboxing pitfalls
Overloads mixing primitives and wrappers can surprise you:
public final class Metrics {
public void record(int value) {
System.out.println("int=" + value);
}
public void record(Integer value) {
System.out.println("Integer=" + value);
}
}
If a caller passes null, only record(Integer) is applicable. If a caller passes a value that triggers boxing/unboxing, you may get a different overload than you expected. I avoid primitive/wrapper overload pairs unless I have a very strong reason.
Overriding rules that matter in practice
When overriding:
- The signature must match (including parameter types).
- The return type can be covariant (return a subtype).
- You can’t weaken visibility (a
publicmethod can’t becomeprotected).
public class PaymentProcessor {
public String authorize(String paymentToken) {
return "AUTH-" + paymentToken;
}
}
public final class SandboxPaymentProcessor extends PaymentProcessor {
@Override
public String authorize(String paymentToken) {
// Non-obvious: sandbox uses a predictable token for testability.
return "SANDBOX-AUTH";
}
}
I always add @Override. It prevents accidental overloading when you meant to override.
Dynamic dispatch: why overrides change behavior at runtime
Overriding is resolved at runtime based on the actual object type, not the reference type.
PaymentProcessor p = new SandboxPaymentProcessor();
System.out.println(p.authorize("tok"));
That prints SANDBOX-AUTH because the runtime type is SandboxPaymentProcessor.
This is powerful, but it also means base classes and interfaces should have tight contracts. If a base method is vague (“does some processing”), every override becomes a gamble.
Final methods: when I use them
Marking a method final prevents overriding. I use it when:
- The method enforces an invariant I don’t want subclasses to break.
- The method is part of a template method pattern and should not change.
But I don’t default to final everywhere; overusing it can make testing and extension painful.
What happens when a method runs: stack frames, returns, and exceptions
When you call a method, the JVM creates a stack frame for that method call. The frame holds local variables, intermediate calculations, and where to return when the method finishes.
The call stack is LIFO: last call in, first call out.
Here’s a runnable example that makes stack flow visible:
public class CallStackStory {
static void loadUserProfile() {
System.out.println("loadUserProfile");
fetchFromDatabase();
System.out.println("loadUserProfile done");
}
static void fetchFromDatabase() {
System.out.println("fetchFromDatabase");
mapRow();
System.out.println("fetchFromDatabase done");
}
static void mapRow() {
System.out.println("mapRow");
}
public static void main(String[] args) {
loadUserProfile();
System.out.println("main done");
}
}
Typical output:
loadUserProfilefetchFromDatabasemapRowfetchFromDatabase doneloadUserProfile donemain done
Exceptions: how control returns when something fails
An exception unwinds the stack until it finds a matching catch, or it terminates the thread.
public class ExceptionUnwindDemo {
static void parseConfiguration() {
throw new IllegalStateException("Missing required setting: DATABASE_URL");
}
static void bootApplication() {
parseConfiguration();
}
public static void main(String[] args) {
try {
bootApplication();
} catch (IllegalStateException e) {
System.out.println("Boot failed: " + e.getMessage());
}
}
}
This is why I avoid catching exceptions too early “just to keep going.” If a method can’t fulfill its contract, it should say so loudly.
finally and resource safety: methods as cleanup boundaries
A method is often the right place to manage resources (files, sockets, locks). The most common modern pattern is try-with-resources:
import java.io.BufferedReader;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
public final class FirstLineReader {
public String readFirstLine(Path path) throws IOException {
try (BufferedReader br = Files.newBufferedReader(path)) {
return br.readLine();
}
}
}
This creates a tight contract: acquire resource, do work, release resource.
Recursion: elegant, but mind the stack
Recursion is a method calling itself. It’s readable for tree structures, parsing, and divide-and-conquer logic. But each recursive call adds a stack frame, and deep recursion can throw StackOverflowError.
If you’re processing user-controlled input (like nested JSON), recursion depth becomes a real risk. In that case, I switch to an explicit stack or iterative approach.
A safe iterative alternative example for factorial (toy, but illustrates the point):
public final class MathFns {
public static long factorialIterative(int n) {
if (n = 0");
long acc = 1;
for (int i = 2; i <= n; i++) {
acc *= i;
}
return acc;
}
}
Java does not guarantee tail-call optimization, so I don’t rely on recursion to be stack-safe.
Side effects: how to keep methods predictable in real systems
A method with side effects changes state outside its local variables: it writes to a database, modifies an object field, sends a message, or mutates a passed-in collection.
Side effects are not “bad.” They’re how apps do work. The problem is hidden side effects.
A simple rule I use: make side effects obvious
- Name side-effecting methods with action verbs:
save,publish,send,delete. - Keep pure computations named like calculations:
calculate,normalize,toSlug. - Don’t surprise callers: a method named
getUser()shouldn’t also refresh caches and write audit logs.
Avoid mutating inputs unless the name says so
This is a classic footgun:
import java.util.Collections;
import java.util.List;
public final class Sorting {
public static List sortedCopy(List names) {
// Non-obvious: copy so the caller‘s list stays unchanged.
List copy = new java.util.ArrayList(names);
Collections.sort(copy);
return List.copyOf(copy);
}
}
If you mutate the caller’s list inside a method that sounds like it returns a new list, you’ll get bugs that look random.
Command-query separation (CQS): a mental model that helps
I often design methods so they are either:
- Queries: return data, no side effects (
calculateTotal,findById). - Commands: perform side effects, return little or nothing (
save,publish,markPaid).
It’s not a strict law, but it’s a helpful default because it makes the call graph easier to reason about.
Concurrency: methods and thread safety
In multi-threaded code, method design becomes a safety boundary. A few rules I follow:
- Prefer immutable objects and pure methods for shared data.
- Avoid mutable static fields as shared state.
- If you must share state, define clear synchronization.
Here’s a small cache method that stays thread-safe by delegating to a concurrent map and using computeIfAbsent:
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
public final class UserDisplayNameCache {
private final ConcurrentMap cache = new ConcurrentHashMap();
private final UserDirectory directory;
public UserDisplayNameCache(UserDirectory directory) {
this.directory = Objects.requireNonNull(directory, "directory");
}
public String getDisplayName(String userId) {
Objects.requireNonNull(userId, "userId");
// Non-obvious: mapping function may run more than once under rare races,
// but ConcurrentHashMap ensures the stored value is consistent.
return cache.computeIfAbsent(userId, directory::lookupDisplayName);
}
public interface UserDirectory {
String lookupDisplayName(String userId);
}
}
Two practical notes I keep in mind:
- A “thread-safe method” is not just about adding
synchronized. It’s about defining what can be shared and what can’t. - If the mapping function is expensive or has side effects (like making an API call), you may want a different approach (like memoizing futures) to prevent duplicate in-flight work.
Don’t lie with method names in concurrent code
If a method is named getOrCreateSession, callers will assume it’s safe under concurrency. If it’s not, you’ll see rare, impossible-to-reproduce bugs. Either make it safe or rename it to reflect the risk.
Method modifiers that matter in practice
It’s easy to memorize modifiers. What’s harder (and more valuable) is knowing how they shape design.
Visibility: the smallest surface area wins
I default to:
privatemethods for internal helpers- package-private for internal modules (when packages represent boundaries)
publiconly for stable APIs
A method’s visibility is part of its contract. If it’s public, someone will call it in a way you didn’t anticipate.
static: avoid hidden dependencies
A static method isn’t evil. A static method with hidden dependencies is.
If static code reaches out to:
- the system clock
- random number generators
- environment variables
- global singletons
…then tests get flaky and behavior becomes harder to predict.
When I need static convenience, I like patterns like:
- dependency injection for time (
Clock) - dependency injection for randomness (
RandomorSecureRandompassed in) - passing configuration explicitly
synchronized: correctness first, then performance
I treat synchronized as a correctness tool. The performance cost depends on contention and JVM optimizations, and it can range from “negligible” to “painful.” The bigger cost is often complexity: once you lock, you have to think about lock ordering, potential deadlocks, and long critical sections.
When I do use it, I try to keep the synchronized method short and obviously safe.
final parameters and locals: clarity, not dogma
Marking everything final can reduce accidental reassignment and make code easier to read (especially in older codebases). With modern Java, I focus more on making data immutable by type (records, unmodifiable collections) than sprinkling final everywhere.
Overriding with inheritance vs composition: what I prefer
Inheritance is tempting because overriding feels like a quick customization point. In production systems, I often prefer composition:
- Inheritance couples you to base class behavior and internal details.
- Composition keeps dependencies explicit and easier to test.
A composition example:
public interface PaymentGateway {
String authorize(String paymentToken);
}
public final class PaymentService {
private final PaymentGateway gateway;
public PaymentService(PaymentGateway gateway) {
this.gateway = gateway;
}
public String authorizePayment(String paymentToken) {
return gateway.authorize(paymentToken);
}
}
Now you can swap implementations without subclassing.
I still use overriding when:
- I’m implementing an interface with multiple concrete types.
- I’m extending a well-designed framework base class with clear hooks.
But I avoid “inheritance just to reuse code.” That usually ends in fragile overrides.
Methods and generics: making contracts precise
Generics can make method contracts dramatically clearer.
Generic methods: keep caller code clean
A generic utility method can remove casting and make intent obvious:
import java.util.List;
import java.util.Optional;
public final class Lists {
private Lists() {}
public static Optional first(List items) {
if (items == null || items.isEmpty()) return Optional.empty();
return Optional.ofNullable(items.get(0));
}
}
This is not about being clever. It’s about returning a type-safe result that forces the caller to handle absence.
Bounded type parameters: encode constraints
Sometimes you want a method that accepts “any comparable thing”:
public final class Max {
public static <T extends Comparable> T max(T a, T b) {
if (a.compareTo(b) >= 0) return a;
return b;
}
}
The bound (extends Comparable) tells the reader the constraint and tells the compiler to enforce it.
Don’t over-genericize
If a method signature becomes unreadable because of type gymnastics, you might be optimizing for compiler satisfaction instead of human understanding. I’ll take a slightly less generic method with a clean contract over a hyper-generic one that nobody wants to touch.
Performance considerations: what method design changes in hot paths
Most of the time, method design is about correctness and clarity. Sometimes it’s also about speed.
Small methods aren’t automatically slow
A common fear is “if I split this into methods, it will be slower.” In many cases, the JVM can inline small methods, especially in hot paths. The actual outcome varies by JVM, workload, and code shape, so I don’t make performance promises based on vibes.
My approach:
- Design methods for clarity first.
- Measure if performance matters.
- Optimize the real bottleneck, not the imagined one.
Allocation and hidden work
Method signatures can create hidden costs:
- Varargs allocate arrays.
- Returning new collections can allocate.
- Using streams in tiny hot loops can allocate intermediate objects.
None of those are always bad, but in performance-critical code I’ll choose simpler loops and explicit parameters.
Exceptions are expensive when used as control flow
Throwing exceptions is not free. The real problem is not “exceptions are slow,” it’s “exceptions used for normal logic are noisy and hard to reason about.” If “not found” is common, return Optional or a status type.
Testing and maintainability: methods as seams
When I say a method has a “clear contract,” I mean I can test it without building a whole universe.
Design for injection: time, randomness, I/O
Two quick examples:
- Time: take a
Clock(as shown earlier). - Randomness: take a
Random.
import java.util.Objects;
import java.util.Random;
public final class CouponCodes {
private final Random random;
public CouponCodes(Random random) {
this.random = Objects.requireNonNull(random, "random");
}
public String generateCode() {
int n = 100000 + random.nextInt(900000);
return "CPN-" + n;
}
}
Now you can test deterministically with a seeded random.
Prefer returning values over mutating collaborators
A method that returns a value is often easier to test than one that mutates an object graph.
If you must mutate, keep mutation local and obvious, and consider returning something meaningful (an ID, a result object, a status enum).
My method-level checklist for production code
Before I merge code, I usually mentally check:
- Does the name match what it does?
- Are side effects obvious from the name/signature?
- Are parameter types strong enough to prevent misuse?
- Does it validate inputs at the boundary?
- Does it avoid surprising mutation?
- Does it have a clear error strategy (exceptions vs status)?
If any answer is “no,” I either adjust the method or add tests/documentation to make the contract explicit.
Common pitfalls I still see in production (and what I do instead)
1) “Helper” methods that hide domain decisions
Bad:
Utils.format(x)
Why it’s bad: formatting rules are often business rules (rounding, localization, display standards). Those rules belong in a named domain concept.
Instead:
MoneyFormatter.format(Money money, Locale locale)
2) Boolean parameters that don’t explain themselves
Bad:
sendEmail(to, subject, true);
What does true mean? High priority? HTML enabled? Track opens?
Instead, I prefer:
- an enum (
Priority.HIGH) - a request object (
SendEmailRequest)
3) Methods that return null for “not found”
Bad:
- forces every caller to remember to null-check
Instead:
Optionalfor “not found”- a thrown exception if absence is exceptional
4) Catching and swallowing exceptions
Bad:
try {
doWork();
} catch (Exception e) {
// ignore
}
If the method can’t fulfill its contract, it should not pretend it did. If you must continue, return a status or log and rethrow a meaningful exception.
5) “God methods” that do everything
My fix is not just “split it.” My fix is:
- identify responsibilities
- give each responsibility a method with a strong contract
- push behavior to the right owner (domain object or service)
Practical refactor: turning a painful method into readable behavior
Here’s a simplified “before” example (not huge, but shows the shape of the problem):
import java.math.BigDecimal;
public final class Checkout {
public void run(String userId, BigDecimal subtotal, boolean isVip) {
if (userId == null || userId.isBlank()) {
throw new IllegalArgumentException("bad user");
}
BigDecimal discount = BigDecimal.ZERO;
if (isVip) {
discount = subtotal.multiply(new BigDecimal("0.10"));
}
BigDecimal taxed = subtotal.subtract(discount).multiply(new BigDecimal("1.085"));
System.out.println("charging " + userId + " total=" + taxed);
// pretend we charged and persisted here
}
}
Problems:
- the method mixes validation, pricing rules means, tax calculation, and side effects (charging/logging)
- boolean parameter hides meaning
- no explicit return value
A more “contract-first” direction:
import java.math.BigDecimal;
import java.util.Objects;
public final class CheckoutService {
private final PricingService pricing;
private final PaymentGateway payments;
public CheckoutService(PricingService pricing, PaymentGateway payments) {
this.pricing = Objects.requireNonNull(pricing, "pricing");
this.payments = Objects.requireNonNull(payments, "payments");
}
public Receipt checkout(CheckoutRequest request) {
Objects.requireNonNull(request, "request");
PricingResult priced = pricing.calculate(request);
String authId = payments.authorize(request.userId().value(), priced.total());
return new Receipt(request.userId(), priced.total(), authId);
}
public record CheckoutRequest(CustomerId userId, BigDecimal subtotal, CustomerTier tier) {}
public record PricingResult(BigDecimal total) {}
public record Receipt(CustomerId userId, BigDecimal totalCharged, String authorizationId) {}
public enum CustomerTier { STANDARD, VIP }
public interface PricingService {
PricingResult calculate(CheckoutRequest request);
}
public interface PaymentGateway {
String authorize(String userId, BigDecimal total);
}
public record CustomerId(String value) {
public CustomerId {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException("CustomerId cannot be blank");
}
}
}
}
Now methods read like a workflow with explicit contracts:
PricingService.calculateis pure-ish (compute total)PaymentGateway.authorizeis an external side effectcheckoutties them together and returns a meaningful result
That’s the kind of method boundary that makes systems maintainable.
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
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
Keep existing structure. Add new H2 sections naturally. Use first-person voice.
A final note I keep repeating to myself
A method is not just a way to make code compile. It’s a promise.
If your method names are precise, your signatures are intentional, and your side effects are obvious, you’ll end up with code that’s easier to test, easier to change, and easier to trust. And that’s the real win: when methods are designed well, your future self stops being afraid of your own code.


