What staff & principal smell hunting grades
double · Instant · Clock
check-then-act · shared mutable
N² · mutable returns · equals
streams · pools · @Async
Optional · Stringly · enums
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
- Lead every review comment with severity + failure mode.
- Bundle related smells into one systemic fix when possible.
- 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.
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
Names failure modes, ranks severity, lands systemic fix in the PR, writes the test that would have caught it.
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)
- Drill A (10m): Find money/time smells in a checkout method — double, Instant.now, string amount.
- Drill B (10m): Inventory reserve — rewrite check-then-act to conditional UPDATE; explain oversell.
- Drill C (10m): Spot remote I/O inside @Transactional; propose outbox.
- Drill D (10m): Principal close — list two platform guardrails for the PR.
- Drill E (15m): Review a controller that takes an entity body + unbounded Pageable — write blocker comments for mass assignment and DoS.
- 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.