Java Code Quality in Practice — The Rules That Help and the Ones That Don't

by Eric Hanson, Backend Developer at Clean Systems Consulting

The rules that reliably improve codebases

Name things for what they are, not what they do

Good naming is the most consistent indicator of code quality. Not because naming is easy — it isn't — but because a developer who can name something precisely has understood it precisely.

The practical test: a method or variable name should tell the reader what it represents or returns, not how it achieves it. getActiveOrderCount() is worse than activeOrderCount() — the get prefix adds nothing and implies a side-effect-free computation that might not be true. processData() is useless — every method processes data. enrichOrderWithUserProfile() is precise.

The naming rules that help:

  • Booleans: is, has, can, should prefix — isExpired(), hasAttachments(), canRefund()
  • Collections: plural nouns — orders, pendingInvoices, eligibleUsers
  • Methods that return values: noun phrases — orderTotal(), mostRecentPayment()
  • Methods with side effects: verb phrases — sendConfirmationEmail(), archiveOrder()

The naming rule that doesn't help: enforcing maximum identifier length. Short names in tight scopes — n in a loop, e in a catch block — are conventional and clear. Forcing element where e is idiomatic just adds characters without clarity.

Limit method length — but for the right reason

The "methods should be short" rule is often applied because short methods are easy to test and easy to understand. Both are true. The rule fails when it's applied mechanically — extracting a two-line block into a private method called doStep3() makes the code longer and harder to follow.

The right reason to extract a method: the extracted code has a name that communicates meaningful intent at a higher level of abstraction. validateCardDetails(payment) communicates more than the three lines it contains. doProcessingStep3() communicates less than the code it contains.

The signal to extract: when reading a method, you mentally label a block of code with a description that isn't in the code. That description is the method name. Extract it.

The signal not to extract: when the extraction would require passing in three or four parameters, or when the name would be as long as the code it replaces. Methods that are short because they delegate to well-named private methods are easier to read. Methods that are short because everything was chopped into small pieces are not.

Depend on abstractions, not implementations — where it matters

The dependency inversion principle — high-level modules should not depend on low-level modules; both should depend on abstractions — is genuine and useful. It's also applied too broadly.

Where it matters:

  • Code that depends on I/O, networking, or databases — depend on an interface so tests can substitute without hitting real infrastructure
  • Code shared across modules where one module should be replaceable — depend on an interface defined in the consumer's package, not the provider's

Where it doesn't matter:

  • A service class that calls a value object's methods — Money.add(other) doesn't need an Addable interface
  • An internal utility class used in one place — interface indirection adds nothing if there's only one implementation and no plan to add another

The interface-per-class habit — creating OrderServiceInterface backed by OrderServiceImpl — is the canonical over-application. The interface adds a level of indirection with no benefit unless a second implementation exists or is planned. Name the class OrderService, not OrderServiceImpl.

Immutability by default

Make fields final unless mutation is explicitly required. Make method parameters effectively final (Java enforces this for lambdas, but it's good practice generally). Return defensive copies from methods that return mutable objects.

This isn't about functional programming purity — it's about reducing the number of states a reader needs to track. A class with ten final fields has exactly one state after construction. A class with ten mutable fields can be in any combination of states at any point in time. The final class is easier to reason about, easier to test, and immune to the class of bugs where a field is modified unexpectedly.

The practical application: when adding a field to a class, start with private final. Remove final only if there's a specific reason the field needs to change after construction — not because it might change, but because it does.

The rules that create friction without payoff

Test coverage percentage as a quality metric

100% code coverage does not mean the code is well-tested. It means every line was executed by some test. A test that calls every method but asserts nothing has 100% coverage and catches no bugs.

Coverage is a useful floor — untested code is certainly at risk. Coverage above a certain threshold tells you diminishing amounts. The metric that matters is not "what percentage of lines were executed" but "would this test suite catch the bugs that would actually occur." That can't be measured by a tool.

The friction caused by coverage mandates: tests written to satisfy a coverage threshold rather than to verify behavior. These tests are brittle, test-the-implementation tests that break on every refactor and provide no confidence in correctness. They make the coverage number go up and the codebase harder to maintain.

Single Responsibility applied to individual methods

"Each method should do one thing" is useful at the class level — a class that handles HTTP parsing, business logic, and database persistence is doing too much. Applied to individual methods, "one thing" becomes ambiguous enough to be useless.

Is a method that validates input, transforms it, and returns the result doing "one thing" (processing the input) or three things (validating, transforming, returning)? The decomposition depends on the level of abstraction you're working at. A method at the service layer is allowed to coordinate multiple sub-operations. That coordination is the one thing it does.

Javadoc on every public method

Mandatory Javadoc for every public method produces three outcomes: good Javadoc on methods that warrant it, empty boilerplate on methods that don't, and outdated documentation on methods that were changed without updating their comments.

The methods that benefit from documentation: complex algorithms, non-obvious side effects, thread safety guarantees, subtle parameter contracts. /** Returns the order total. */ on getOrderTotal() adds noise.

The rule that helps: document the non-obvious. A method named processOrder() might warrant a comment explaining what "processed" means, what side effects occur, and what exceptions can be thrown. A method named isExpired() that returns whether the token has passed its expiry time needs no comment.

Avoiding all null

The "avoid null" principle — use Optional, throw exceptions, use null objects — is valuable for API boundaries where null return values create ambiguity. It's over-applied when taken to mean that no method should ever return null internally.

Optional has a cost: allocation, boxing/unboxing, API verbosity. For a private method that returns null when no result is found, where the caller is in the same class and handles both cases explicitly, null is cleaner than Optional. Optional is for public API boundaries where callers must be forced to handle the absent case.

The specific rule that helps: public methods that might return no result should return Optional<T>, not null. Private methods can return null when it's clear and local. Parameters should not be null where possible — validate at construction or accept specific types that exclude null.

The structural rules that matter most

Two structural properties that consistently separate maintainable from unmaintainable Java codebases:

Dependency direction. Dependencies should point inward — toward more stable, more abstract code, away from volatile, concrete infrastructure. Business logic should not depend on database drivers, HTTP clients, or framework specifics. Infrastructure code depends on domain interfaces; domain code doesn't know infrastructure exists. When this is violated, changing a database driver requires modifying business logic.

Package organization by domain, not by layer. Packages named com.example.service, com.example.repository, com.example.controller group by technical role — all services together, all repositories together. This makes it easy to find all services but hard to understand what any single feature involves.

Packages named com.example.orders, com.example.users, com.example.payments group by domain — all the code for a feature in one place. Adding a new feature means adding a package. Deleting a feature means deleting a package. The feature's scope is visible from its package contents.

This doesn't mean every project needs domain packages. A small application with ten classes benefits from no package organization at all. The domain packaging rule applies when the application is large enough that navigating it requires understanding how it's organized.

The underlying principle

Code quality rules are heuristics, not laws. The useful ones share a property: they make the code easier for the next developer to understand and modify correctly. Naming rules help because names carry intent. Immutability helps because it reduces state. Dependency direction helps because it isolates change.

Rules that fail this test — coverage percentages, mandatory Javadoc, mechanical method extraction, interface-per-class — add process without improving the outcome. Applied to a real project under deadline, they create resistance without payoff.

The question to ask before enforcing any rule: if I apply this, does the code become easier to understand and modify? If the honest answer is no, the rule is not worth the friction.

Scale Your Backend - Need an Experienced Backend Developer?

We provide backend engineers who join your team as contractors to help build, improve, and scale your backend systems.

We focus on clean backend design, clear documentation, and systems that remain reliable as products grow. Our goal is to strengthen your team and deliver backend systems that are easy to operate and maintain.

We work from our own development environments and support teams across US, EU, and APAC timezones. Our workflow emphasizes documentation and asynchronous collaboration to keep development efficient and focused.

  • Production Backend Experience. Experience building and maintaining backend systems, APIs, and databases used in production.
  • Scalable Architecture. Design backend systems that stay reliable as your product and traffic grow.
  • Contractor Friendly. Flexible engagement for short projects, long-term support, or extra help during releases.
  • Focus on Backend Reliability. Improve API performance, database stability, and overall backend reliability.
  • Documentation-Driven Development. Development guided by clear documentation so teams stay aligned and work efficiently.
  • Domain-Driven Design. Design backend systems around real business processes and product needs.

Tell us about your project

Our offices

  • Copenhagen
    1 Carlsberg Gate
    1260, København, Denmark
  • Magelang
    12 Jalan Bligo
    56485, Magelang, Indonesia

More articles

The Best Ways to Organize Your Freelance Workflow

Freelancing can feel like juggling a dozen balls while riding a unicycle. With the right workflow, you can keep everything moving smoothly—and stay sane.

Read more

HTTP Status Codes Are Not Suggestions. Use Them Correctly.

Misusing HTTP status codes leads to broken retries, misleading metrics, and fragile clients. Treating them as part of your API contract improves reliability and reduces hidden complexity.

Read more

PostgreSQL for Java Developers — The Features You Should Be Using

Most Java applications use PostgreSQL as a dumb key-value store with SQL syntax. PostgreSQL has capabilities that eliminate entire categories of application code — JSONB for flexible schemas, full-text search, window functions, advisory locks, and LISTEN/NOTIFY for real-time events.

Read more

Stop Designing APIs for Yourself. Design Them for the Person Calling Them.

APIs often reflect how the backend is built instead of how they are used. Shifting the perspective to the consumer leads to simpler integrations, fewer errors, and more durable systems.

Read more