Introduction
Principles like DRY, KISS, and YAGNI sound simple. But applying them correctly in real Java and Spring Boot codebases requires nuance. This article goes beyond the acronyms to show you when each principle helps — and when blindly following it causes harm.
1. DRY vs WET vs AHA
DRY — Don't Repeat Yourself
The original idea: every piece of knowledge should have a single, unambiguous representation.
// BAD — duplicated validation in two services
public class OrderService {
public void createOrder(Order order) {
if (order.getAmount() <= 0) throw new IllegalArgumentException("Invalid amount");
// ...
}
}
public class RefundService {
public void processRefund(Order order) {
if (order.getAmount() <= 0) throw new IllegalArgumentException("Invalid amount");
// ...
}
}
// GOOD — extract shared validation
public class OrderValidator {
public static void validateAmount(Order order) {
if (order.getAmount() <= 0) throw new IllegalArgumentException("Invalid amount");
}
}
WET — Write Everything Twice
Sometimes duplication is the right call. Two similar-looking blocks may serve different business purposes.
// These look similar but serve DIFFERENT business rules
public class PricingService {
public double calculateB2BPrice(Product p) {
return p.getBasePrice() * 0.85; // B2B gets 15% off
}
public double calculateB2CPrice(Product p) {
return p.getBasePrice() * 0.90; // B2C gets 10% off
}
}
// Merging these into one method would couple B2B and B2C pricing.
// When B2B pricing changes (e.g., volume discounts), B2C shouldn't be affected.
AHA — Avoid Hasty Abstractions
Wait until you've seen the pattern three times before abstracting. Premature DRY creates wrong abstractions that are harder to fix than duplication.
"Duplication is far cheaper than the wrong abstraction." — Sandi Metz
2. KISS — Keep It Simple, Stupid
Simple code beats clever code. Always.
Bad Code (Clever)
// One-liner that nobody can read
public boolean isEligible(User u) {
return u != null && u.getAge() >= 18 && u.getStatus() != null
&& "ACTIVE".equals(u.getStatus()) && !u.isBanned()
&& (u.getKycStatus() == KycStatus.VERIFIED || u.isStaff());
}
Good Code (Simple)
public boolean isEligible(User user) {
if (user == null) return false;
if (user.getAge() < 18) return false;
if (!"ACTIVE".equals(user.getStatus())) return false;
if (user.isBanned()) return false;
return user.isKycVerified() || user.isStaff();
}
Each condition is on its own line. You can set a breakpoint on any specific check. You can read the logic in 5 seconds.
3. YAGNI — You Ain't Gonna Need It
Don't build for imaginary future requirements.
Bad Code
// "We might need to support XML and YAML later"
public interface ConfigParser {
Config parse(String input);
}
public class JsonConfigParser implements ConfigParser { ... }
public class XmlConfigParser implements ConfigParser { ... } // Never used
public class YamlConfigParser implements ConfigParser { ... } // Never used
public class ConfigParserFactory {
public ConfigParser getParser(String format) { ... } // Unnecessary complexity
}
Good Code
// We only use JSON today. Build only what you need.
public class ConfigParser {
public Config parse(String jsonInput) {
return objectMapper.readValue(jsonInput, Config.class);
}
}
// If we ever need YAML, we can refactor then. It takes 30 minutes, not 3 days.
4. Dependency Injection — Explained from Scratch
Stage 1: No DI (Tightly Coupled)
public class OrderService {
private MySQLOrderRepository repository = new MySQLOrderRepository();
private EmailService emailService = new EmailService();
public void placeOrder(Order order) {
repository.save(order);
emailService.sendConfirmation(order);
}
}
// Problem: Cannot test without a real MySQL database and email server
Stage 2: Manual Constructor Injection
public class OrderService {
private final OrderRepository repository;
private final NotificationService notificationService;
public OrderService(OrderRepository repository, NotificationService notificationService) {
this.repository = repository;
this.notificationService = notificationService;
}
public void placeOrder(Order order) {
repository.save(order);
notificationService.sendConfirmation(order);
}
}
// Now we can inject mocks
OrderService service = new OrderService(
new InMemoryOrderRepository(), // Test double
new NoOpNotificationService() // Test double
);
Stage 3: Spring Boot
public interface OrderRepository {
void save(Order order);
Optional<Order> findById(String id);
}
@Repository
public class JpaOrderRepository implements OrderRepository {
@Autowired private JpaRepository<OrderEntity, String> jpa;
public void save(Order order) { jpa.save(toEntity(order)); }
public Optional<Order> findById(String id) { return jpa.findById(id).map(this::toDomain); }
}
@Service
public class OrderService {
private final OrderRepository repository;
private final NotificationService notificationService;
public OrderService(OrderRepository repository, NotificationService notificationService) {
this.repository = repository;
this.notificationService = notificationService;
}
}
Stage 4: Testing Advantage
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
@Mock private OrderRepository repository;
@Mock private NotificationService notificationService;
@InjectMocks private OrderService orderService;
@Test
void shouldSaveOrderAndSendNotification() {
Order order = new Order("ORD-1", "CUST-1", 500.0);
orderService.placeOrder(order);
verify(repository).save(order);
verify(notificationService).sendConfirmation(order);
}
@Test
void shouldNotSendNotificationIfSaveFails() {
doThrow(new RuntimeException("DB Down")).when(repository).save(any());
assertThrows(RuntimeException.class, () -> orderService.placeOrder(new Order()));
verify(notificationService, never()).sendConfirmation(any());
}
}
DI makes unit testing trivial. You test business logic in isolation, without any infrastructure.
Interview Questions
-
When is duplication acceptable? When two pieces of code look similar but change for different reasons. Premature DRY creates wrong abstractions.
-
What is the difference between
@Autowiredon a field vs constructor? Constructor injection is preferred. It makes dependencies explicit, enables immutability (finalfields), and works without Spring (for testing). -
How do you decide between KISS and extensibility? Default to KISS. Only add extensibility when you have a concrete requirement, not a hypothetical one. It is cheaper to refactor later than to maintain unused abstractions now.
Common Mistakes
- Field injection in Spring: Using
@Autowiredon fields instead of constructors. Field injection hides dependencies and makes testing harder. - Abstracting after one instance: Creating an interface with only one implementation "just in case." Wait for the second implementation.
- Gold plating: Spending days making code "perfect" for features that may never be built.
Summary
| Principle | Rule | Trap to Avoid |
|---|---|---|
| DRY | Extract shared knowledge | Premature abstraction |
| WET | Allow intentional duplication | Coupling unrelated logic |
| AHA | Abstract after 3 repetitions | Hasty abstractions |
| KISS | Simplest solution first | Clever one-liners |
| YAGNI | Build only what's needed today | Speculative engineering |
| DI | Inject dependencies, don't create them | Field injection |