Java code smells: smell versus fix

Java code smells — examples & fixes (staff / principal)

Staff and principal Java code-smell catalog with real-world smell/fix samples: money and time, concurrency and lost updates, collections, resources and @Async, nullability, God services, Spring transaction boundaries, observability, and platform guardrails.

What staff & principal smell hunting grades

01Money & time

double · Instant · Clock

02Concurrency

check-then-act · shared mutable

03Collections

N² · mutable returns · equals

04Resources

streams · pools · @Async

05Null & types

Optional · Stringly · enums

06Shape

God class · envy · shotgun

These samples come from recurring production incidents and PR patterns on Spring/Java backends: checkout mis-bills, inventory lost updates, connection-pool exhaustion under retries, and "temporary" utility classes that quietly become the domain model.

Severity: when a smell is a blocker

  1. Lead every review comment with severity + failure mode.
  2. Bundle related smells into one systemic fix when possible.
  3. Refuse to approve blockers; negotiate high items with an explicit follow-up.

Money, quantities, and time

Floating money

Smell

BigDecimal? No — this shipped:
double total = line.price * line.qty;
total = total * 1.08; // tax
return round(total, 2); // still wrong under load of SKUs

Fix

Money price = Money.of(line.unitPrice(), line.currency());
Money subtotal = price.multiply(line.qty());
Money tax = taxPolicy.apply(subtotal, shipTo);
return subtotal.plus(tax); // Money uses BigDecimal + currency + scale rules

Stringly-typed money amounts

Smell

@PostMapping("/refunds")
Refund refund(@RequestParam String amount) {
  BigDecimal a = new BigDecimal(amount); // NFE / locale / scale surprises
  return refunds.issue(a);
}

Fix

record MoneyDto(@NotNull BigDecimal amount, @NotNull @Size(min=3,max=3) String currency) {}

@PostMapping("/refunds")
Refund refund(@Valid @RequestBody MoneyDto body) {
  return refunds.issue(Money.of(body.amount(), Currency.getInstance(body.currency())));
}

Hidden Instant.now() in domain rules

Smell

boolean isTrialActive(User u) {
  return u.getTrialEnd().isAfter(Instant.now()); // untestable + multi-node skew stories
}

Fix

boolean isTrialActive(User u, Clock clock) {
  return u.getTrialEnd().isAfter(clock.instant());
}
// Spring: @Bean Clock systemClock() { return Clock.systemUTC(); }
// tests: Clock.fixed(Instant.parse("2026-01-01T00:00:00Z"), ZoneOffset.UTC)

LocalDate.now() without zone

Smell

LocalDate due = LocalDate.now().plusDays(2); // which "now"?

Fix

LocalDate due = LocalDate.now(clock.withZone(businessZone)).plusDays(2);
// or: LocalDate.ofInstant(clock.instant(), businessZone).plusDays(2);

Mixing float interest with long cents

Smell

long cents = 1999;
double rate = 0.029;
long fee = Math.round(cents * rate); // banker's vs half-up fights later

Fix

Money principal = Money.ofCents(1999, USD);
Money fee = feePolicy.feeFor(principal); // BigDecimal rate, explicit RoundingMode, scale=0 cents

Concurrency & shared mutable state

Check-then-act inventory

Smell

Item item = repo.findById(id);
if (item.getQty() < 1) throw new SoldOut();
item.setQty(item.getQty() - 1);
repo.save(item); // lost update under concurrency

Fix

@Transactional
public void reserve(String id) {
  int updated = repo.decrementIfAvailable(id); // UPDATE … SET qty=qty-1 WHERE id=? AND qty>=1
  if (updated != 1) throw new SoldOut();
}
// or @Version optimistic lock + retry with clear SoldOut semantics

Non-thread-safe "cache" on a Spring bean

Smell

@Service
class RateLimiter {
  private final Map<String, Long> hits = new HashMap<>(); // shared mutable
  boolean allow(String key) {
    hits.merge(key, 1L, Long::sum);
    return hits.get(key) < 100;
  }
}

Fix

@Service
class RateLimiter {
  private final ConcurrentHashMap<String, AtomicLong> hits = new ConcurrentHashMap<>();
  // better: Redis / bucket4j / API gateway — process-local maps don't work multi-instance
  boolean allow(String key) {
    long n = hits.computeIfAbsent(key, k -> new AtomicLong()).incrementAndGet();
    return n <= 100;
  }
}

synchronized on this for I/O

Smell

public synchronized Order checkout(Cart cart) {
  payment.charge(cart);   // remote I/O under lock
  return orders.save(...);
}

Fix

public Order checkout(Cart cart) {
  // keep critical section tiny — or better, rely on DB uniqueness / idempotency key
  PaymentIntent intent = payment.createIntent(cart.idempotencyKey());
  return orders.savePaid(cart, intent);
}

Double-checked locking without volatile

Smell

class Client {
  private static Client instance;
  static Client get() {
    if (instance == null) {
      synchronized (Client.class) {
        if (instance == null) instance = new Client(); // may publish partially constructed
      }
    }
    return instance;
  }
}

Fix

enum Client {
  INSTANCE;
  // or Spring @Bean / constructor injection — don't hand-roll singletons
}

Collections, equality, and API surfaces

Mutable collection escaped from domain

Smell

class Order {
  private final List<Line> lines;
  List<Line> getLines() { return lines; } // caller can clear()
}

Fix

class Order {
  private final List<Line> lines;
  List<Line> getLines() { return List.copyOf(lines); } // or UnmodifiableList
  // mutations only via addLine(...) that enforces invariants
}

equals/hashCode on mutable entity identity

Smell

@Entity
class User {
  @Id Long id;
  String email;
  public boolean equals(Object o) { return id != null && id.equals(((User)o).id); }
  public int hashCode() { return Objects.hash(id); } // null id → collapses; then id assigned → lost in Set
}

Fix

// Prefer business key for equality when needed, or don't put managed entities in Sets.
// Common staff pattern: equals/hashCode on stable natural key (email) OR identity (==) only in aggregates.
@Override public boolean equals(Object o) {
  if (this == o) return true;
  if (!(o instanceof User u)) return false;
  return email != null && email.equalsIgnoreCase(u.email);
}
@Override public int hashCode() { return Objects.hash(email == null ? null : email.toLowerCase()); }

N² contains on lists

Smell

List<String> allowed = loadAllowed(); // ArrayList
return items.stream().filter(allowed::contains).toList(); // O(n*m)

Fix

Set<String> allowed = Set.copyOf(loadAllowed());
return items.stream().filter(allowed::contains).toList(); // O(n)

Stream side effects / shared mutable accumulator

Smell

List<String> out = new ArrayList<>();
items.parallelStream().forEach(out::add); // race

Fix

List<String> out = items.parallelStream().map(this::map).toList();
// or Collectors.toList() / concurrent collector intentionally

Resources, threads, and async

Missing try-with-resources

Smell

InputStream in = client.getObject(key).getObjectContent();
byte[] bytes = in.readAllBytes();
in.close(); // skipped on exception → leak

Fix

try (InputStream in = client.getObject(key).getObjectContent()) {
  return in.readAllBytes();
}

Unbounded CompletableFuture fan-out

Smell

List<CompletableFuture<Result>> futs = ids.stream()
  .map(id -> CompletableFuture.supplyAsync(() -> fetch(id))) // unbounded
  .toList();
return futs.stream().map(CompletableFuture::join).toList();

Fix

Executor exec = Executors.newFixedThreadPool(32); // or Spring TaskExecutor bean with bounds + queue
Semaphore lim = new Semaphore(32);
List<CompletableFuture<Result>> futs = ids.stream().map(id ->
  CompletableFuture.supplyAsync(() -> {
    lim.acquireUninterruptibly();
    try { return fetch(id); } finally { lim.release(); }
  }, exec)
).toList();

@Async without an executor bean

Smell

@EnableAsync
@Service
class Mailer {
  @Async
  void send(Email e) { smtp.send(e); } // unbounded threads
}

Fix

@Configuration
class AsyncConfig {
  @Bean(name = "mailExecutor")
  Executor mailExecutor() {
    ThreadPoolTaskExecutor t = new ThreadPoolTaskExecutor();
    t.setCorePoolSize(4); t.setMaxPoolSize(16); t.setQueueCapacity(500);
    t.setRejectedExecutionHandler(new CallerRunsPolicy()); // or abort + metric
    t.initialize(); return t;
  }
}
@Async("mailExecutor") void send(Email e) { ... }

Ignoring InterruptedException

Smell

try {
  queue.take();
} catch (InterruptedException e) {
  // ignore
}

Fix

try {
  queue.take();
} catch (InterruptedException e) {
  Thread.currentThread().interrupt();
  throw new IllegalStateException("interrupted waiting on queue", e);
}

Nullability, Optional, and stringly types

Optional as a field / parameter

Smell

class User {
  private Optional<String> nickname; // smell
  void setNickname(Optional<String> n) { this.nickname = n; }
}

Fix

class User {
  private @Nullable String nickname; // or omit field when absent
  void setNickname(@Nullable String n) { this.nickname = n; }
  Optional<String> nickname() { return Optional.ofNullable(nickname); } // at API boundary if needed
}

Optional.get() without isPresent

Smell

User u = repo.findById(id).get();

Fix

User u = repo.findById(id)
  .orElseThrow(() -> new NotFound("user", id));

Stringly status / magic booleans

Smell

void transition(String status) {
  if (status.equals("ACTIVE")) ...
  else if (status.equals("active")) ... // duplicate
}
boolean cancelled; boolean refunded; // cancelled&&!refunded vs ...

Fix

enum OrderStatus { PENDING, ACTIVE, CANCELLED, REFUNDED }
void transition(OrderStatus next) { stateMachine.assertTransition(current, next); }

Catching Exception / returning null

Smell

User find(String id) {
  try {
    return repo.load(id);
  } catch (Exception e) {
    log.error("fail"); return null;
  }
}

Fix

User find(String id) {
  return repo.findById(id).orElseThrow(() -> new NotFound("user", id));
}
// map infra failures at the boundary; don't swallow

Structure smells that amplify change

God service (feature envy + transaction script)

Smell

@Service
class OrderService { // 2000 lines
  Order checkout(...) {
    // price, tax, inventory, payment, email, metrics, audit...
  }
}

Fix

@Service
class CheckoutService {
  private final Pricing pricing;
  private final Inventory inventory;
  private final Payments payments;
  private final OrderPublisher publisher;

  @Transactional
  Order checkout(CheckoutCmd cmd) {
    Money total = pricing.quote(cmd.cart());
    inventory.reserve(cmd.cart());
    Payment p = payments.charge(cmd.idempotencyKey(), total);
    Order order = Order.paid(cmd, total, p);
    publisher.ordered(order); // outbox preferred
    return order;
  }
}

Primitive obsession in domain IDs

Smell

void transfer(String from, String to, String amount) { ... } // which is user? account?

Fix

void transfer(AccountId from, AccountId to, Money amount) { ... }
record AccountId(UUID value) {
  static AccountId of(String raw) { return new AccountId(UUID.fromString(raw)); }
}

Shotgun surgery: duplicated authorization

Smell

// Controller A
if (!doc.getOwnerId().equals(userId)) throw new Forbidden();
// Controller B forgot the check → IDOR

Fix

@Component
class DocumentAccess {
  Document requireOwned(String docId, String userId) {
    Document d = repo.findById(docId).orElseThrow(...);
    if (!d.ownerId().equals(userId)) throw new Forbidden();
    return d;
  }
}
// all entry points call requireOwned — or method security / query filters

Anemic domain + setters everywhere

Smell

order.setStatus("PAID");
order.setPaidAt(Instant.now());
order.setTotal(total);

Fix

order.markPaid(payment, clock.instant()); // enforces: pending→paid, total matches, paidAt set once

Spring / persistence boundary smells

LazyInitializationException waiting to happen

Smell

@Transactional(readOnly = true)
Order get(String id) { return repo.findById(id).orElseThrow(); }

// later in controller / DTO mapper — no TX
order.getLines().size(); // LazyInitializationException

Fix

@Transactional(readOnly = true)
OrderResponse get(String id) {
  Order o = repo.findWithLines(id); // join fetch / entity graph
  return OrderResponse.from(o);     // map inside TX
}

Self-invocation skips @Transactional / @Async

Smell

@Service
class Billing {
  public void run() { charge(); } // this.charge() — no proxy
  @Transactional void charge() { ... }
}

Fix

@Service
class Billing {
  private final Billing self; // constructor-inject self via @Lazy, or split collaborator
  public void run() { self.charge(); }
  @Transactional public void charge() { ... }
}
// cleaner: extract ChargingService and inject it

Open transaction across remote calls

Smell

@Transactional
Order checkout(Cart c) {
  Order o = orders.save(...);
  payment.charge(o);      // HTTP inside TX — pool exhaustion
  mailer.send(o);         // more I/O
  return o;
}

Fix

@Transactional
Order checkout(Cart c) {
  Order o = orders.savePending(c);
  outbox.enqueue("OrderPaid", o.id()); // same TX
  return o;
}
// payment + mail happen in consumers with idempotency — not inside the DB TX

Logging, errors, and operability smells

Logging secrets / PII

Smell

log.info("charge user={} token={} pan={}", userId, stripeToken, pan);

Fix

log.info("charge user={} paymentIntent={} last4={}", userId, intentId, last4);
// structured logging + scrubbing filters on the logger MDC pipeline

log-and-throw

Smell

try {
  return payment.charge(...);
} catch (PaymentException e) {
  log.error("payment failed", e);
  throw e;
}

Fix

// log once at the boundary (controller advice / consumer) with correlation id
return payment.charge(...); // let it bubble; enrich context with exception fields

Empty catch / TODO later

Smell

try {
  analytics.track(event);
} catch (Exception ignored) {
  // TODO
}

Fix

try {
  analytics.track(event);
} catch (Exception e) {
  metrics.increment("analytics.track.fail");
  log.warn("analytics failed eventType={}", event.type(), e);
  // decide: fail the request vs degrade — make it explicit
}

Test smells that hide production bugs

Asserting only "not null" / no invariants

Smell

Order o = service.checkout(cart);
assertNotNull(o.getId());

Fix

Order o = service.checkout(cart);
assertEquals(Money.of("21.60", "USD"), o.total());
assertEquals(OrderStatus.PAID, o.status());
assertTrue(inventory.reserved(cart));

Sleeping for async

Smell

publisher.publish(evt);
Thread.sleep(2000);
assertEquals(1, repo.count());

Fix

publisher.publish(evt);
Awaitility.await().atMost(2, SECONDS).untilAsserted(() ->
  assertEquals(1, repo.count())
);
// better: unit-test the handler directly; use @Transactional outbox assertions

Mockito stubbing the class under test’s internals

Smell

@Spy OrderService service;
doReturn(price).when(service).computeTax(...); // testing nothing real

Fix

// Prefer fakes for slow I/O; real domain objects for rules.
// Integration test: @DataJpaTest / Testcontainers for reserve UPDATE semantics.

Principal lens: systemic smells

Patterns that deserve a platform response

  • Money as double in >1 service → shared Money library + codec for APIs.
  • Ownership checks per controller → DocumentAccess / method security / RLS.
  • @Async defaults → org-wide TaskExecutor starter with metrics + rejection policy.
  • Remote I/O in TX → outbox starter + review checklist item.
  • Instant.now() in domain → Clock bean in the service starter.
Principal takeaway

Your job is not only cleaner methods — it is preventing the same incident class across the org. Smell → blast radius → local fix → guardrail.

API, DTO, and serialization smells

Entity as @RequestBody (mass assignment)

Smell

@PostMapping("/users")
User create(@RequestBody User user) { // entity exposed
  return repo.save(user); // client sets role=ADMIN, verified=true
}

Fix

record CreateUserRequest(@Email String email, @NotBlank String name) {}

@PostMapping("/users")
UserResponse create(@Valid @RequestBody CreateUserRequest req) {
  User u = User.register(req.email(), req.name()); // server sets role/defaults
  return UserResponse.from(repo.save(u));
}

Missing Idempotency-Key on charge/create

Smell

@PostMapping("/charges")
Charge charge(@RequestBody ChargeReq req) {
  return payments.charge(req); // every retry = new charge
}

Fix

@PostMapping("/charges")
Charge charge(@RequestHeader("Idempotency-Key") String key,
              @Valid @RequestBody ChargeReq req) {
  return payments.chargeOnce(key, req); // store key→result with TTL / unique constraint
}

Unbounded page size

Smell

Page<Order> list(Pageable pageable) {
  return repo.findAll(pageable); // trusts client size
}

Fix

@GetMapping
Page<OrderResponse> list(@PageableDefault(size = 20) Pageable pageable) {
  int size = Math.min(pageable.getPageSize(), 100);
  Pageable safe = PageRequest.of(pageable.getPageNumber(), size, pageable.getSort());
  return repo.findAll(safe).map(OrderResponse::from);
}

Returning JPA graphs over the wire

Smell

@GetMapping("/{id}")
Order get(@PathVariable String id) {
  return repo.findById(id).orElseThrow(); // entity + lazy lines
}

Fix

@GetMapping("/{id}")
OrderResponse get(@PathVariable String id) {
  return query.findOrderView(id); // DTO / projection query
}

Hot-path performance smells

String concatenation in a loop

Smell

String s = "";
for (Line line : lines) s += line.render() + "\n";
return s;

Fix

StringBuilder sb = new StringBuilder(lines.size() * 64);
for (Line line : lines) sb.append(line.render()).append('\n');
return sb.toString();
// or String.join / Collectors.joining

Compiling Pattern per call

Smell

boolean valid(String email) {
  return Pattern.compile(".+@.+").matcher(email).matches();
}

Fix

private static final Pattern EMAIL = Pattern.compile(".+@.+"); // or use a real validator
boolean valid(String email) { return EMAIL.matcher(email).matches(); }

SELECT * + filter in memory

Smell

return repo.findAll().stream()
  .filter(o -> o.status() == PAID)
  .filter(o -> o.createdAt().isAfter(since))
  .toList();

Fix

return repo.findByStatusAndCreatedAtAfter(PAID, since);
// indexed columns; pagination if the result set can grow

Chatty N+1 in a loop

Smell

for (Line line : order.getLines()) {
  Product p = productRepo.findById(line.productId()).orElseThrow();
  line.setName(p.name());
}

Fix

Set<String> ids = order.lineProductIds();
Map<String, Product> products = productRepo.findAllById(ids).stream()
  .collect(Collectors.toMap(Product::id, Function.identity()));
order.enrichNames(products);

War stories: how these smells fail in prod

Hire bar: staff vs principal on smells

StaffLocal mastery

Names failure modes, ranks severity, lands systemic fix in the PR, writes the test that would have caught it.

PrincipalOrg leverage

Sees copy-paste across services, ships platform types/executors/outbox, adds ArchUnit or CI guardrails, teaches the pattern.

Review script: how to narrate smells

Weak: "This is messy, please clean up." Strong: name the incident you are preventing.

Practice drills (staff / principal)

  1. Drill A (10m): Find money/time smells in a checkout method — double, Instant.now, string amount.
  2. Drill B (10m): Inventory reserve — rewrite check-then-act to conditional UPDATE; explain oversell.
  3. Drill C (10m): Spot remote I/O inside @Transactional; propose outbox.
  4. Drill D (10m): Principal close — list two platform guardrails for the PR.
  5. Drill E (15m): Review a controller that takes an entity body + unbounded Pageable — write blocker comments for mass assignment and DoS.
  6. Drill F (15m): Narrate a war story (oversell / penny drift / pool exhaustion) in STAR form as if in a principal interview.

One-page cheat sheet

  • Money → Money/BigDecimal + currency — never double.
  • Time → inject Clock — never scattered Instant.now().
  • Reserves → conditional write / version — never check-then-act.
  • Caches on beans → concurrent + bounded — or shared store.
  • Async → bounded executor — never unbounded supplyAsync/@Async default.
  • Resources → try-with-resources — always.
  • Authz → one guard — never copy-paste ownership.
  • TX → no remote I/O — outbox / after-commit.
  • Logs → no secrets — ids and redaction.
  • Principal → local fix + platform guardrail.

← Lattice