1. Introduction
Production Java code is rarely maintained only by the developer who originally wrote it.
During the lifetime of an application, the same code may be reviewed, debugged, extended, migrated, optimized, and fixed by many different developers.
A service written today may be modified months later by someone who:
- Did not participate in the original implementation
- Does not know the original requirement
- Is unfamiliar with the surrounding module
- Is investigating a production incident under time pressure
- Needs to add a new business rule without breaking existing behavior
Because of this, maintainable code should communicate its intent clearly without requiring the original developer to explain it.
Writing maintainable code means designing classes, methods, names, control flow, dependencies, and business rules so that another developer can understand:
- What the code does
- Why the code exists
- What assumptions it makes
- Which parts are safe to change
- Which operations have side effects
- How failures are handled
- Where new behavior should be added
The objective is not to make code excessively abstract or heavily documented.
The objective is to make normal Java code predictable, readable, testable, and safe to modify.
2. What This Topic Means
Writing maintainable code for other developers means treating readability and future change as part of the implementation requirement.
A developer should not write code only for the compiler.
The code should also be understandable to another engineer reviewing a Pull Request or maintaining the feature later.
Consider this method:
public void handle(Order o) {
if (o != null && o.getS() == 1) {
repo.save(o);
client.send(o);
}
}The compiler can understand it.
Another developer must guess:
- What does
getS()mean? - Why must it equal
1? - What does
client.send()send? - Does saving happen before notification intentionally?
- What happens if
send()fails? - Should null orders be ignored or rejected?
A maintainable version makes those decisions visible:
public void confirmOrder(Order order) {
validateOrder(order);
if (!order.isReadyForConfirmation()) {
throw new InvalidOrderStateException(order.getId());
}
order.markConfirmed();
orderRepository.save(order);
orderNotificationService.sendConfirmation(order);
}The second version is easier to understand because its structure communicates business intent.
3. Why It Matters in Real Projects
Readability
Developers spend significant time reading existing code before changing it.
Readable code reduces the time required to understand:
- Business rules
- Dependencies
- Failure paths
- Data flow
- Side effects
Maintainability
Requirements continually evolve.
Maintainable code allows developers to modify one area without having to understand an entire module.
Debugging
Production debugging becomes easier when:
- Methods have meaningful names
- Important states are explicit
- Exceptions communicate the failure
- Side effects are separated
- Logging provides useful context
Reliability
Code that is easy to understand is less likely to be modified incorrectly.
Poor maintainability increases regression risk because developers may not understand hidden assumptions.
Team Development
Software development is collaborative.
Maintainable code helps:
- Pull Request reviewers
- New team members
- Developers working across modules
- On-call engineers
- QA engineers
- Architects
Scalability of Development
Maintainability also affects how efficiently a team can grow.
If only one developer understands a module, that module becomes a knowledge bottleneck.
Clean, predictable code distributes knowledge across the team.
4. Core Concept
The main principle is:
Write code so that another competent Java developer can understand and safely modify it without depending on the original author.
Maintainability comes from several characteristics working together.
Clear Intent
Names should explain the business purpose.
Prefer:
calculateRefundAmount()over:
calc()Focused Responsibilities
Methods and classes should have understandable responsibilities.
Explicit Business Rules
Important business decisions should be visible.
Prefer:
if (customer.isBlocked()) {
throw new CustomerBlockedException(customer.getId());
}over hiding the same rule inside a complex expression.
Predictable Side Effects
A method called:
validatePayment()should not secretly save an entity or send an email.
Clear Failure Behavior
Exceptions should communicate the actual problem.
Testable Design
Important business behavior should be testable without excessive setup.
Limited Coupling
A change in one component should not unnecessarily require changes in several unrelated components.
5. Important Rules
- Use meaningful domain-specific names.
- Keep methods focused on a clear responsibility.
- Avoid hidden side effects.
- Keep business rules explicit.
- Avoid magic numbers and unexplained strings.
- Use constants or enums for important domain values.
- Prefer straightforward control flow.
- Keep database operations visible.
- Keep external API calls identifiable.
- Handle exceptional cases deliberately.
- Do not silently ignore invalid states unless the requirement explicitly allows it.
- Avoid excessive method parameters.
- Avoid shared mutable state where unnecessary.
- Keep dependencies explicit through constructor injection.
- Do not expose internal implementation details unnecessarily.
- Keep important transformations easy to trace.
- Avoid premature abstraction.
- Avoid copy-paste business logic.
- Keep comments focused on why, not obvious what.
- Preserve consistent patterns across similar modules.
- Design for future modification, not hypothetical future requirements.
- Make logging useful for operational debugging.
- Write tests around business behavior.
- Keep Pull Requests small enough to review where practical.
6. Bad Code Example
The following Spring Boot service handles employee account activation.
@Service
public class EmployeeService {
@Autowired
EmployeeRepository repo;
@Autowired
EmailClient client;
public String doIt(Long id, boolean x) {
Employee e = repo.findById(id).orElse(null);
if (e == null) {
return "ERR";
}
if (x) {
if (e.getStatus().equals("0")) {
e.setStatus("1");
e.setUpdatedAt(LocalDateTime.now());
repo.save(e);
try {
client.send(
e.getEmail(),
"Account",
"Your account is active"
);
} catch (Exception ex) {
System.out.println(ex.getMessage());
}
return "OK";
} else {
return "INVALID";
}
} else {
e.setStatus("0");
repo.save(e);
return "DONE";
}
}
}7. Problems in the Bad Code
Unclear Method Name
doIt() provides no information about the business operation.
A developer searching for employee activation behavior would not know that this method contains it.
Unclear Parameter
The boolean parameter:
boolean xdoes not reveal whether true means:
- Activate
- Enable
- Verify
- Notify
Boolean flags often hide multiple behaviors inside one method.
Cryptic Variable Names
Variables such as:
e
repo
clientprovide weak context.
Magic Status Values
The method uses:
"0"
"1"without explaining what those values represent.
A reviewer must infer that they probably mean inactive and active.
Multiple Responsibilities
The method handles:
- Employee lookup
- Activation
- Deactivation
- State validation
- Persistence
- Email notification
- Error handling
- Response status creation
Generic Return Strings
Values such as:
"ERR"
"OK"
"INVALID"
"DONE"do not communicate a stable API contract.
Swallowed Notification Failure
The method catches:
Exceptionand prints the message.
The failure is effectively hidden from the application.
System.out.println() in Server Code
Production Spring Boot applications should normally use structured logging rather than direct console printing.
Field Injection
Field injection hides required dependencies and makes unit testing more difficult.
Constructor injection makes dependencies explicit.
Null Handling Is Ambiguous
Missing employees return "ERR" instead of a meaningful exception or business result.
Maintenance Risk
A developer changing activation behavior must also understand deactivation, notification, persistence, and return-code rules.
8. Code Review Findings
A senior reviewer should notice:
doIt()does not communicate its domain purpose.- The boolean parameter selects unrelated behaviors.
- Magic status values should be replaced with domain-specific representation.
- Activation and deactivation should be explicit operations.
- Field injection should be replaced with constructor injection.
- Returning arbitrary strings creates an unclear service contract.
- Notification failure is swallowed.
System.out.println()is inappropriate for production diagnostics.Exceptionis too broad.- The service mixes account state management and notification failure handling.
- Business states should be represented consistently.
- The current method will become harder to maintain as employee lifecycle rules grow.
9. Reviewer Comment Example
A useful PR comment could be:
doIt()currently handles both activation and deactivation based on a boolean flag. Could we expose explicit methods such as activateEmployee() and deactivateEmployee() so the service API communicates the business operation directly?
Another comment:
The
"0"and"1"status values are difficult to interpret during review. Please consider using an enum such as EmployeeStatus.ACTIVE/INACTIVE so invalid states become harder to introduce.
Another:
The notification exception is currently swallowed with System.out.println(). Please use the application logger and define whether notification failure should fail the activation request or be handled asynchronously.
10. Improved Code
@Service
public class EmployeeAccountService {
private static final Logger log =
LoggerFactory.getLogger(EmployeeAccountService.class);
private final EmployeeRepository employeeRepository;
private final EmployeeNotificationService notificationService;
public EmployeeAccountService(EmployeeRepository employeeRepository,
EmployeeNotificationService notificationService) {
this.employeeRepository = employeeRepository;
this.notificationService = notificationService;
}
@Transactional
public EmployeeAccountResult activateEmployee(Long employeeId) {
Employee employee = findEmployee(employeeId);
if (employee.getStatus() == EmployeeStatus.ACTIVE) {
throw new EmployeeAlreadyActiveException(employeeId);
}
employee.setStatus(EmployeeStatus.ACTIVE);
employee.setUpdatedAt(LocalDateTime.now());
Employee savedEmployee = employeeRepository.save(employee);
sendActivationNotification(savedEmployee);
return new EmployeeAccountResult(
savedEmployee.getId(),
savedEmployee.getStatus()
);
}
@Transactional
public EmployeeAccountResult deactivateEmployee(Long employeeId) {
Employee employee = findEmployee(employeeId);
if (employee.getStatus() == EmployeeStatus.INACTIVE) {
throw new EmployeeAlreadyInactiveException(employeeId);
}
employee.setStatus(EmployeeStatus.INACTIVE);
employee.setUpdatedAt(LocalDateTime.now());
Employee savedEmployee = employeeRepository.save(employee);
return new EmployeeAccountResult(
savedEmployee.getId(),
savedEmployee.getStatus()
);
}
private Employee findEmployee(Long employeeId) {
if (employeeId == null) {
throw new IllegalArgumentException("Employee ID is required");
}
return employeeRepository.findById(employeeId)
.orElseThrow(() -> new EmployeeNotFoundException(employeeId));
}
private void sendActivationNotification(Employee employee) {
try {
notificationService.sendActivationEmail(employee);
} catch (NotificationException ex) {
log.error(
"Failed to send activation email for employeeId={}",
employee.getId(),
ex
);
}
}
}The status is represented explicitly:
public enum EmployeeStatus {
ACTIVE,
INACTIVE
}The response is also explicit:
public record EmployeeAccountResult(
Long employeeId,
EmployeeStatus status
) {
}11. Improved Code Explanation
Business Operations Are Explicit
The original method:
doIt(id, true)has been replaced with:
activateEmployee(employeeId)Similarly:
doIt(id, false)becomes:
deactivateEmployee(employeeId)A caller can understand the operation immediately.
Boolean Flag Was Removed
A boolean parameter was controlling two different flows.
Separate methods make each use case independently understandable.
Domain Status Uses an Enum
Instead of:
"0"
"1"the code now uses:
EmployeeStatus.ACTIVE
EmployeeStatus.INACTIVEThis improves:
- Readability
- Type safety
- Refactoring safety
- IDE navigation
Dependencies Are Explicit
Constructor injection shows exactly what the class requires.
It also makes unit testing easier.
Missing Employee Has Clear Behavior
Instead of returning:
"ERR"the method throws:
EmployeeNotFoundExceptionThe failure is explicit and can be mapped to an appropriate API response.
Result Type Is Explicit
A structured result is better than unrelated string return codes.
Logging Is Operationally Useful
Notification failures include:
- Employee ID
- Exception stack trace
- Meaningful message
This is more useful during production investigation.
12. Bad Code vs Improved Code
| Area | Bad Code | Improved Code |
|---|---|---|
| API clarity | doIt(id, true) | activateEmployee(id) |
| Naming | Cryptic names | Domain-specific names |
| Status | "0" and "1" | EmployeeStatus enum |
| Error handling | Generic strings | Specific exceptions |
| Dependency management | Field injection | Constructor injection |
| Logging | System.out.println() | Structured logger |
| Testability | Hidden dependencies and multiple paths | Explicit dependencies and focused methods |
| Maintainability | Activation and deactivation mixed | Operations separated |
| Reliability | Invalid states easier to introduce | Domain states constrained |
13. Real Project Scenario
Consider a banking microservice responsible for customer beneficiary management.
Initially, one service method handles:
- Creating a beneficiary
- Updating details
- Activating the beneficiary
- Deactivating the beneficiary
- Validating transfer limits
- Sending OTP
- Recording audit information
- Sending notifications
The behavior is controlled by request flags.
For example:
processBeneficiary(request, true, false, true);Months later, a developer needs to modify only beneficiary activation.
Before changing the code, the developer must understand several unrelated paths controlled by booleans.
The change accidentally bypasses an audit operation during deactivation.
A maintainable API would expose clear operations:
createBeneficiary()
updateBeneficiary()
activateBeneficiary()
deactivateBeneficiary()Shared validation can still be reused internally, but each public operation clearly communicates its purpose.
This significantly reduces maintenance risk.
14. Production Impact
Poor maintainability can affect production indirectly but significantly.
Regression Bugs
A developer may unintentionally affect another business path while modifying tightly coupled code.
Longer Incident Resolution
Unclear naming and hidden side effects increase the time required to understand failures.
Incorrect State Changes
Magic values and loosely defined status strings can produce invalid entity states.
Lost Operational Information
Swallowed exceptions or weak logging can make failures difficult to diagnose.
Inconsistent Behavior
Copy-pasted logic may evolve differently across multiple methods.
Higher Change Risk
Developers become reluctant to modify complex modules because the impact is difficult to predict.
Knowledge Bottlenecks
If only the original developer understands the code, team productivity becomes dependent on that individual.
15. Common Developer Mistakes
Writing for the Current Task Only
A developer implements the immediate requirement without considering how the code will be modified later.
Assuming Everybody Knows the Context
Names such as:
flag
type
mode
data
valuemay make sense while implementing the feature but become unclear later.
Overusing Boolean Parameters
Methods such as:
process(order, true, false);do not communicate behavior.
Excessive Comments Instead of Better Code
Bad:
// Check if customer is active
if (customer.getStatus() == 1) {Better:
if (customer.isActive()) {Creating Generic Utility Methods Too Early
Developers sometimes create generic abstractions before understanding whether behaviors are actually the same.
Copy-Pasting Business Logic
Repeated logic eventually becomes inconsistent when only one copy is updated.
Swallowing Exceptions
Code such as:
try {
externalService.call();
} catch (Exception ex) {
}makes production failures invisible.
Returning Null for Unexpected Conditions
Returning null forces every caller to guess what null represents.
Magic Values
Avoid unexplained values such as:
0
1
999
"A"
"Y"
"N"when domain types can communicate intent better.
16. Edge Cases
Null Inputs
Public service methods should define whether null input:
- Is invalid
- Is optional
- Represents absence
Do not allow behavior to emerge accidentally from a NullPointerException.
Empty Collections
Define how empty requests should behave.
For example, an empty order may be:
- Invalid
- Valid but zero-valued
- Ignored
Duplicate Data
If duplicate IDs or entries are possible, define whether they should:
- Be rejected
- Be merged
- Be processed independently
Invalid State Transitions
Examples:
- Activating an already active employee
- Cancelling an already refunded payment
- Shipping a cancelled order
State transitions should be explicit.
External Service Failure
Maintainable code should make the expected behavior visible when:
- Email fails
- Payment times out
- External REST API returns 500
- Message publication fails
Concurrency
If several requests can update the same entity, reviewers should consider:
- Optimistic locking
- Transaction boundaries
- Duplicate processing
- Idempotency
17. Performance Considerations
Maintainability itself does not require sacrificing performance.
In most business applications, clear Java code and efficient Java code are compatible.
Database Calls
Maintainable code should make database interaction visible.
A method such as:
loadOrders()should not unexpectedly execute dozens of queries.
Reviewers should identify patterns such as:
for (Long id : orderIds) {
orderRepository.findById(id);
}A bulk query may be more appropriate.
External API Calls
Avoid hiding network calls inside generic helpers or collection operations.
Network I/O is important enough to remain visible.
Repeated Computation
Meaningful local variables can improve both clarity and performance.
Instead of:
if (calculateRisk(customer) > 10
&& calculateRisk(customer) < 50) {use:
int riskScore = calculateRisk(customer);Object Creation
Do not introduce unnecessary wrapper objects purely for abstraction.
Performance Optimization
If optimization makes code substantially more complex, document why it is necessary and support the decision with measurements.
Do not reduce maintainability for hypothetical performance gains.
18. Security Considerations
Maintainability is especially important in security-sensitive code.
Authorization behavior should be easy for reviewers to verify.
Avoid code such as:
if (u != null && (u.isAdmin() || u.getId().equals(r.getOwnerId()) && !r.isLocked())) {Prefer explicit security decisions:
if (!canAccessResource(user, resource)) {
throw new AccessDeniedException("Access denied");
}Security-related maintainability includes:
- Clear authorization checks
- Explicit authentication assumptions
- Safe input validation
- Sensitive-data masking
- Avoiding secrets in source code
- Safe logging
- Clear tenant isolation
- Explicit permission rules
Security code should not depend on clever expressions that are difficult to audit.
19. Testing Considerations
Maintainable code should make business behavior straightforward to test.
Positive Tests
For employee activation:
- Inactive employee becomes active.
- Updated timestamp is set.
- Employee is saved.
- Activation notification is triggered.
Negative Tests
- Missing employee ID is rejected.
- Unknown employee ID throws
EmployeeNotFoundException. - Already active employee cannot be activated again.
Exception Tests
Verify behavior when:
- Repository fails
- Notification service fails
- Database update fails
Boundary Cases
Where applicable, test:
- Minimum allowed values
- Maximum allowed values
- Empty collections
- State transition boundaries
Unit Tests
Constructor injection makes collaborators easy to mock.
For example:
@ExtendWith(MockitoExtension.class)
class EmployeeAccountServiceTest {
@Mock
private EmployeeRepository employeeRepository;
@Mock
private EmployeeNotificationService notificationService;
@InjectMocks
private EmployeeAccountService employeeAccountService;
@Test
void shouldActivateInactiveEmployee() {
Employee employee = new Employee();
employee.setId(101L);
employee.setStatus(EmployeeStatus.INACTIVE);
when(employeeRepository.findById(101L))
.thenReturn(Optional.of(employee));
when(employeeRepository.save(employee))
.thenReturn(employee);
EmployeeAccountResult result =
employeeAccountService.activateEmployee(101L);
assertEquals(EmployeeStatus.ACTIVE, result.status());
verify(employeeRepository).save(employee);
verify(notificationService).sendActivationEmail(employee);
}
}Integration Tests
Use integration tests to verify:
- Persistence mappings
- Transaction behavior
- API error responses
- State changes in the database
20. Refactoring Guidelines
Step 1: Understand Current Behavior
Before changing code, identify:
- Inputs
- Outputs
- Side effects
- Exceptions
- Database updates
- External calls
Step 2: Protect Existing Behavior
Add or verify tests before structural refactoring.
Step 3: Improve Names First
Renaming variables and methods often exposes design problems without changing behavior.
Step 4: Replace Magic Values
Convert important strings or numbers to:
- Enums
- Constants
- Domain types
Step 5: Separate Different Operations
If one method performs multiple business actions controlled by flags, consider explicit methods.
Step 6: Extract Business Rules
Move complex conditions into meaningful operations.
Step 7: Make Dependencies Explicit
Prefer constructor injection.
Step 8: Improve Failure Handling
Replace:
- Null returns
- Generic error strings
- Swallowed exceptions
with deliberate behavior.
Step 9: Review Side Effects
Make database and external service calls easy to identify.
Step 10: Refactor Incrementally
Avoid combining a large cleanup with unrelated feature development where possible.
Smaller refactoring steps are easier to review and verify.
21. Best Practices
- Write meaningful class names.
- Write meaningful method names.
- Use domain terminology consistently.
- Keep public service APIs explicit.
- Prefer enums over magic status values.
- Use constructor injection.
- Keep methods focused.
- Keep business rules near the relevant business operation.
- Use specific exceptions.
- Use structured logging.
- Keep side effects visible.
- Avoid excessive nesting.
- Use early returns where they simplify logic.
- Keep important constants named.
- Avoid unnecessary abstraction.
- Reuse logic only when behavior is genuinely the same.
- Keep transaction boundaries understandable.
- Make external integration behavior explicit.
- Design result objects around meaningful API contracts.
- Write tests around behavior rather than implementation details.
- Keep code consistent with established project conventions.
22. Practices to Avoid
Generic Method Names
Avoid:
process()
handle()
execute()
doWork()
manage()unless the surrounding abstraction gives them very clear meaning.
Boolean Flags Controlling Major Behavior
Avoid:
processOrder(order, true, false);Prefer explicit operations.
Magic Values
Avoid unexplained state codes.
Swallowed Exceptions
Never ignore failures without a deliberate reason.
Comments Explaining Poor Names
Do not use comments as a substitute for readable code.
Excessive Abstraction
A developer should not navigate through ten interfaces and factories to understand a simple calculation.
Copy-Paste Logic
Repeated business rules will eventually diverge.
Hidden Side Effects
A method named:
buildInvoice()should not unexpectedly send an email.
Large Utility Classes
Classes such as:
CommonUtil
GeneralHelper
ApplicationUtilsoften become dumping grounds for unrelated behavior.
Static Mutable State
Avoid shared mutable state unless the design genuinely requires it.
23. Code Review Checklist
A reviewer can ask:
- Can another developer understand the purpose of this class quickly?
- Do method names describe business intent?
- Are variable names meaningful?
- Are important domain states represented explicitly?
- Are there unexplained strings or numeric values?
- Does one method perform several unrelated responsibilities?
- Are boolean parameters hiding different business operations?
- Are side effects visible?
- Are database calls easy to identify?
- Are external API calls easy to identify?
- Are exceptions handled deliberately?
- Are failures being silently ignored?
- Is structured logging used appropriately?
- Do logs contain useful operational context?
- Are sensitive values excluded from logs?
- Are dependencies explicit?
- Is constructor injection used?
- Are transaction boundaries understandable?
- Can important business behavior be unit tested?
- Are there duplicated business rules?
- Are null and invalid-state behaviors clearly defined?
- Would another developer know where to add a future business rule?
- Is the implementation simpler than the problem requires, or unnecessarily abstract?
- Does this code follow the conventions already used in the project?
- Can this code be changed safely without asking the original developer how it works?
24. Common Pull Request Review Comments
- *Could we rename this method to reflect the actual business operation? processData() does not make its responsibility clear.*
- *This boolean flag changes the method from activation to deactivation behavior. Please consider exposing separate methods so callers do not need to understand what true/false means.*
- *The status values "0" and "1" are domain concepts. An enum would make the code easier to read and prevent invalid values.*
- *This exception is currently swallowed. Please define the expected failure behavior and add structured logging if the operation is intentionally non-blocking.*
- *Could we use constructor injection here? These dependencies are required by the service and should be explicit.*
- *This helper method both validates the request and updates the database. Please separate the responsibilities or rename it so the side effect is obvious.*
- *There are three copies of this eligibility rule in the service. Consider keeping the rule in one place so future changes do not produce inconsistent behavior.*
- *Please replace the generic "ERROR" return value with a meaningful exception or typed result so callers can distinguish failure cases.*
- *This comment explains what the code is doing because the variable names are unclear. Renaming the variables would make the code self-explanatory.*
- *The implementation works, but the abstraction adds several layers for a simple operation. Can we simplify this while preserving the required behavior?*
25. Code Review Exercise
Review the following payment refund service.
Identify:
- Problems
- Code smells
- Risks
- Improvements
@Service
public class RefundService {
@Autowired
private PaymentRepository repo;
@Autowired
private PaymentGateway gateway;
public String process(Long id, int type) {
Payment p = repo.findById(id).orElse(null);
if (p == null) {
return null;
}
if (type == 1) {
if ("SUCCESS".equals(p.getStatus())) {
if (p.getAmount().compareTo(BigDecimal.ZERO) > 0) {
try {
String ref = gateway.refund(
p.getTransactionId(),
p.getAmount()
);
p.setStatus("REFUNDED");
p.setRefundReference(ref);
repo.save(p);
return "OK";
} catch (Exception e) {
System.out.println("refund failed");
return "FAIL";
}
}
}
}
if (type == 2) {
p.setStatus("CANCELLED");
repo.save(p);
return "DONE";
}
return "INVALID";
}
}26. Exercise Solution
Review Findings
Generic Method Name
process() does not indicate whether the method performs:
- Refund
- Cancellation
- Validation
Magic Operation Types
The caller must know:
1 = refund
2 = cancellationThis creates an unclear API contract.
Cryptic Variables
Variables such as:
p
repo
refprovide minimal business context.
Generic String Status Values
Payment states are represented through unrestricted strings.
Null Return
Missing payment returns null, which does not communicate the reason for failure.
Multiple Business Operations
Refund and cancellation are unrelated operations controlled by type.
Excessive Nesting
Refund validation is deeply nested.
Broad Exception Handling
Exception catches failures that may not belong to payment gateway processing.
Weak Logging
System.out.println("refund failed") does not include:
- Payment ID
- Transaction ID
- Exception
- Failure reason
Unclear State Transition
Cancellation is allowed without checking the current payment state.
This may allow a refunded payment to become cancelled.
Improved Code
@Service
public class RefundService {
private static final Logger log =
LoggerFactory.getLogger(RefundService.class);
private final PaymentRepository paymentRepository;
private final PaymentGateway paymentGateway;
public RefundService(PaymentRepository paymentRepository,
PaymentGateway paymentGateway) {
this.paymentRepository = paymentRepository;
this.paymentGateway = paymentGateway;
}
@Transactional
public RefundResult refundPayment(Long paymentId) {
Payment payment = findPayment(paymentId);
validateRefundEligibility(payment);
String refundReference = executeRefund(payment);
payment.setStatus(PaymentStatus.REFUNDED);
payment.setRefundReference(refundReference);
Payment savedPayment = paymentRepository.save(payment);
return new RefundResult(
savedPayment.getId(),
savedPayment.getStatus(),
savedPayment.getRefundReference()
);
}
@Transactional
public void cancelPayment(Long paymentId) {
Payment payment = findPayment(paymentId);
validateCancellationEligibility(payment);
payment.setStatus(PaymentStatus.CANCELLED);
paymentRepository.save(payment);
}
private Payment findPayment(Long paymentId) {
if (paymentId == null) {
throw new IllegalArgumentException("Payment ID is required");
}
return paymentRepository.findById(paymentId)
.orElseThrow(() -> new PaymentNotFoundException(paymentId));
}
private void validateRefundEligibility(Payment payment) {
if (payment.getStatus() != PaymentStatus.SUCCESS) {
throw new InvalidPaymentStateException(
payment.getId(),
payment.getStatus()
);
}
if (payment.getAmount() == null
|| payment.getAmount().compareTo(BigDecimal.ZERO) <= 0) {
throw new InvalidRefundAmountException(payment.getId());
}
}
private void validateCancellationEligibility(Payment payment) {
if (payment.getStatus() == PaymentStatus.REFUNDED) {
throw new InvalidPaymentStateException(
payment.getId(),
payment.getStatus()
);
}
if (payment.getStatus() == PaymentStatus.CANCELLED) {
throw new InvalidPaymentStateException(
payment.getId(),
payment.getStatus()
);
}
}
private String executeRefund(Payment payment) {
try {
return paymentGateway.refund(
payment.getTransactionId(),
payment.getAmount()
);
} catch (PaymentGatewayException ex) {
log.error(
"Refund failed for paymentId={}, transactionId={}",
payment.getId(),
payment.getTransactionId(),
ex
);
throw new RefundProcessingException(
payment.getId(),
ex
);
}
}
}Payment states are explicit:
public enum PaymentStatus {
PENDING,
SUCCESS,
FAILED,
REFUNDED,
CANCELLED
}A structured refund response can be used:
public record RefundResult(
Long paymentId,
PaymentStatus status,
String refundReference
) {
}Why These Changes Help
refundPayment() and cancelPayment() communicate the supported operations directly.
Magic operation codes were removed.
The payment lifecycle uses a type-safe enum.
Missing payments produce a meaningful exception.
Refund eligibility is explicit.
Cancellation eligibility is explicit.
The external payment call is clearly identified.
Gateway failures include operational context.
The response communicates meaningful data rather than "OK" or "FAIL".
Future developers can determine where to modify:
- Refund eligibility
- Cancellation eligibility
- Payment gateway handling
- Payment state transitions
without decoding one large generic method.
27. Interview Perspective
Maintainability is commonly evaluated in Java, Spring Boot, senior developer, and code-review interviews.
An interviewer may provide working code and ask:
- Would you approve this code?
- How would you make it easier for other developers to maintain?
- What makes Java code maintainable?
- Why are meaningful names important?
- Why are boolean flags sometimes problematic?
- When should you use an enum instead of a string?
- Why is constructor injection preferred?
- How should exceptions be handled?
- How would you refactor a legacy service safely?
- How do you prevent knowledge silos?
- When does abstraction hurt maintainability?
- How do you balance readability and performance?
A strong answer should discuss more than formatting.
Maintainability includes:
- Naming
- Responsibility boundaries
- Domain modeling
- Error handling
- Dependencies
- Testing
- Side effects
- Logging
- Consistency
- Refactoring safety
28. Interview Questions and Answers
Basic Question
Question: What does maintainable Java code mean?
Answer:
Maintainable Java code is code that another developer can understand, test, debug, and modify safely without needing extensive explanation from the original author.
It normally has:
- Clear naming
- Focused responsibilities
- Explicit business rules
- Predictable side effects
- Meaningful error handling
- Testable dependencies
- Consistent domain modeling
Intermediate Question
Question: Why can boolean parameters reduce maintainability?
Answer:
Boolean parameters often hide different behaviors.
For example:
processOrder(order, true);does not explain what true means.
If the parameter changes major behavior, explicit methods are usually clearer:
cancelOrder(order);
confirmOrder(order);Boolean parameters are not always wrong, but reviewers should question them when they represent separate business operations.
Advanced Question
Question: How do you know when abstraction improves maintainability versus when it becomes over-engineering?
Answer:
An abstraction is useful when it hides a stable implementation detail or represents a real reusable concept.
It becomes harmful when developers must navigate multiple interfaces, factories, wrappers, and generic layers to understand a simple operation.
I would consider:
- Whether multiple implementations actually exist
- Whether the abstraction represents a meaningful domain concept
- Whether it reduces duplication
- Whether it makes testing easier
- Whether future changes are reasonably expected
- Whether the abstraction makes normal debugging harder
The simplest design that clearly supports the current requirements is usually preferable.
Scenario-Based Question
Question: You inherit a legacy Spring Boot service with 1,000 lines and almost no tests. How would you improve maintainability?
Answer:
I would avoid rewriting it immediately.
First I would:
- Understand public behavior.
- Identify critical production paths.
- Add characterization tests around current behavior.
- Improve names where safe.
- Identify duplicated rules.
- Extract focused methods incrementally.
- Replace important magic values with domain types.
- Make dependencies explicit.
- Improve error handling and logging.
- Move substantial independent responsibilities to appropriate collaborators only when justified.
I would keep each refactoring small enough to verify that business behavior has not changed accidentally.
Code-Review Question
Question: What would you review specifically for maintainability in a Pull Request?
Answer:
I would check:
- Whether names explain intent
- Whether methods have focused responsibilities
- Whether business rules are visible
- Whether status values use appropriate domain types
- Whether dependencies are explicit
- Whether errors are handled intentionally
- Whether database or network side effects are visible
- Whether logic is duplicated
- Whether tests cover new behavior
- Whether the implementation follows existing project conventions
- Whether another developer could modify the feature later without relying on tribal knowledge
Real-Project Question
Question: How does maintainable code reduce production risk?
Answer:
Most production defects are introduced during changes rather than during the initial implementation.
Maintainable code makes the relationships between business rules clearer.
When a future developer changes one rule, they are less likely to accidentally modify another.
Clear code also improves review quality, testing, debugging, and incident response, which reduces the probability and impact of production defects.
29. Quick Rule to Remember
Write every important method as if the next person changing it has never spoken to you.
The code itself should explain:
- Its purpose
- Its business rules
- Its important side effects
- Its failure behavior
30. Final Takeaway
Maintainable Java code is code that remains understandable after the original implementation context has disappeared.
The goal is not maximum abstraction, minimum line count, or complicated architecture.
The goal is predictable production code that another developer can safely work with.
What the Developer Should Remember
- Use names that communicate business intent.
- Keep responsibilities focused.
- Replace magic values with meaningful domain types.
- Keep important business rules explicit.
- Make dependencies visible.
- Handle failures deliberately.
- Keep side effects identifiable.
- Use logging that helps production investigation.
- Write tests around important behavior.
- Avoid unnecessary complexity.
What the Reviewer Should Check
A reviewer should verify:
- Can the code be understood without additional explanation?
- Are names meaningful?
- Are responsibilities clearly separated?
- Are business states explicit?
- Are failure cases defined?
- Are important database and external calls visible?
- Are dependencies testable?
- Is business logic duplicated?
- Are state transitions valid?
- Can future requirements be added without modifying unrelated behavior?
- Is the implementation consistent with the surrounding codebase?
What Should Be Avoided in Production Code
Avoid:
- Cryptic names
- Generic methods that perform several operations
- Magic values
- Boolean flags controlling unrelated behavior
- Swallowed exceptions
- Arbitrary string return codes
- Hidden side effects
- Unnecessary abstraction layers
- Copy-pasted business rules
- Large utility dumping grounds
- Weak production logging
- Code that depends on the original developer explaining how it works
A useful measure of maintainability is not:
How quickly did I write this?
A better measure is:
How safely can another developer understand and change this six months from now?
Production code becomes valuable when it remains understandable long after the original author has moved to another feature, another team, or another company.