Software Dev

DRY, KISS, YAGNI & Law of Demeter: Mastering Java Software Design Principles in 2026

Four principles separate senior Java engineers from the rest: DRY, KISS, YAGNI, and the Law of Demeter. They sound simple, yet production codebases violate them daily — leading to fragile services, painful refactors, and 3 AM incidents. This deep-dive covers every principle with real Spring Boot examples, before/after refactoring, and the interview traps you must know.

Md Sanwar Hossain April 9, 2026 20 min read Design Principles
DRY KISS YAGNI Law of Demeter Java software design principles

TL;DR — The Four Principles in One Sentence Each

  • DRY: Every piece of knowledge has exactly one authoritative representation — eliminate duplication, not just copy-paste.
  • KISS: The simplest solution that correctly solves the problem is always the right solution — resist clever abstractions.
  • YAGNI: Do not implement anything until it is concretely required today — speculation creates waste, not value.
  • Law of Demeter: Only talk to your direct collaborators — order.getCustomer().getAddress().getCity() is a design smell, not a feature.

Table of Contents

  1. DRY — Don't Repeat Yourself
  2. KISS — Keep It Simple, Stupid
  3. YAGNI — You Aren't Gonna Need It
  4. Law of Demeter & Tell, Don't Ask
  5. Combining All 4: E-Commerce Order Processing
  6. Common Pitfalls & Interview Tips
  7. Conclusion & Cheat Sheet

1. DRY — Don't Repeat Yourself

Originally stated by Andy Hunt and Dave Thomas in The Pragmatic Programmer (1999): "Every piece of knowledge must have a single, unambiguous, authoritative representation within a system." Notice it says knowledge, not just code. You can have the same string literal in two places and not violate DRY if both usages represent different concepts. Conversely, two methods that look different but encode the same business rule do violate DRY.

DRY Violation: Duplicated Validation Logic Across Controllers

A classic Spring Boot violation: the same email validation and age-check logic copy-pasted into three REST controllers. When the business rule changes ("users must be 18+"), you must find and update all three places — and you will inevitably miss one:

// ❌ VIOLATION — Same validation duplicated in 3 controllers

// UserRegistrationController.java
@PostMapping("/register")
public ResponseEntity<?> register(@RequestBody UserDto dto) {
    if (dto.getEmail() == null || !dto.getEmail().matches("^[\\w.-]+@[\\w.-]+\\.[a-z]{2,}$")) {
        return ResponseEntity.badRequest().body("Invalid email");
    }
    if (dto.getAge() == null || dto.getAge() < 16) {
        return ResponseEntity.badRequest().body("Must be at least 16 years old");
    }
    // ... registration logic
}

// AdminUserController.java
@PostMapping("/admin/users")
public ResponseEntity<?> createUser(@RequestBody UserDto dto) {
    if (dto.getEmail() == null || !dto.getEmail().matches("^[\\w.-]+@[\\w.-]+\\.[a-z]{2,}$")) {
        return ResponseEntity.badRequest().body("Invalid email");
    }
    if (dto.getAge() == null || dto.getAge() < 16) {
        return ResponseEntity.badRequest().body("Must be at least 16 years old");
    }
    // ... admin user creation
}

// InviteController.java
@PostMapping("/invite")
public ResponseEntity<?> invite(@RequestBody UserDto dto) {
    if (dto.getEmail() == null || !dto.getEmail().matches("^[\\w.-]+@[\\w.-]+\\.[a-z]{2,}$")) {
        return ResponseEntity.badRequest().body("Invalid email");
    }
    if (dto.getAge() == null || dto.getAge() < 16) {
        return ResponseEntity.badRequest().body("Must be at least 16 years old");
    }
    // ... invite logic
}

The DRY fix is to extract the shared knowledge into a single authoritative location. In Spring Boot the best place is either Bean Validation annotations (for structural rules) or a shared validator service (for business rules):

// ✅ DRY FIX — Bean Validation on the DTO (structural rule)

// UserDto.java
public record UserDto(
    @NotNull @Email(message = "Invalid email format")
    String email,

    @NotNull @Min(value = 16, message = "Must be at least 16 years old")
    Integer age,

    @NotBlank String name
) {}

// UserValidationService.java — for business-level rules
@Service
public class UserValidationService {

    public void validate(UserDto dto) {
        if (isDisposableEmailDomain(dto.email())) {
            throw new ValidationException("Disposable email addresses are not allowed");
        }
    }

    private boolean isDisposableEmailDomain(String email) {
        String domain = email.substring(email.indexOf('@') + 1);
        return DISPOSABLE_DOMAINS.contains(domain);
    }
}

// All three controllers now simply declare:
@PostMapping("/register")
public ResponseEntity<?> register(@Valid @RequestBody UserDto dto) {
    userValidationService.validate(dto);  // single call — one source of truth
    // ... registration logic
}

DRY in Spring Boot: Three Power Patterns

// ✅ DRY — Global Exception Handler (single source of truth for error responses)

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException ex) {
        List<String> errors = ex.getBindingResult().getFieldErrors()
            .stream()
            .map(fe -> fe.getField() + ": " + fe.getDefaultMessage())
            .toList();
        return ResponseEntity.badRequest().body(new ErrorResponse("VALIDATION_FAILED", errors));
    }

    @ExceptionHandler(EntityNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleNotFound(EntityNotFoundException ex) {
        return ResponseEntity.status(404).body(new ErrorResponse("NOT_FOUND", List.of(ex.getMessage())));
    }

    @ExceptionHandler(AccessDeniedException.class)
    public ResponseEntity<ErrorResponse> handleAccessDenied(AccessDeniedException ex) {
        return ResponseEntity.status(403).body(new ErrorResponse("FORBIDDEN", List.of("Access denied")));
    }
}

The DRY vs. Copy-Paste Inheritance Pitfall

A dangerous pattern: creating a deep inheritance hierarchy solely to avoid repeating fields. If AdminUser extends User extends BaseEntity only to reuse five fields, you've traded minor duplication for tight coupling. The moment AdminUser needs different behavior for one of those fields, you'll regret the hierarchy. Prefer composition over inheritance for DRY — a shared AuditFields record embedded in both entities is cleaner than a fragile superclass chain.

DRY KISS YAGNI Law of Demeter Java design principles diagram
Overview of DRY, KISS, YAGNI & Law of Demeter — the four foundational Java design principles. Source: mdsanwarhossain.me

2. KISS — Keep It Simple, Stupid

KISS was coined by the U.S. Navy in the 1960s, and it's just as relevant in software. The principle is: favor simple solutions over clever ones. Complexity should only be introduced when there is a concrete, proven need for it. In Java, KISS violations most often appear as over-engineered abstractions, unnecessarily generic frameworks, and multi-level method chains that look "enterprise-grade" but obscure intent.

KISS Violation: The Over-Engineered AbstractFactory

You need to send a notification email on user registration. A KISS violation: three abstraction layers when a single @Service method would do:

// ❌ VIOLATION — Over-engineered notification "framework" for a single email

public interface NotificationStrategy {
    void send(NotificationPayload payload);
}

public abstract class AbstractNotificationFactory {
    public abstract NotificationStrategy createStrategy(NotificationType type);
}

public class EmailNotificationFactory extends AbstractNotificationFactory {
    @Override
    public NotificationStrategy createStrategy(NotificationType type) {
        return switch (type) {
            case EMAIL -> new SmtpEmailStrategy(smtpConfig);
            default -> throw new UnsupportedOperationException("Type: " + type);
        };
    }
}

// Caller:
NotificationStrategy strategy = factory.createStrategy(NotificationType.EMAIL);
strategy.send(new NotificationPayload(user.getEmail(), "Welcome!", body));

// Six classes, two interfaces, one enum — for sending one email.
// ✅ KISS FIX — A plain Spring @Service method

@Service
@RequiredArgsConstructor
public class NotificationService {

    private final JavaMailSender mailSender;

    public void sendWelcomeEmail(String to, String username) {
        SimpleMailMessage msg = new SimpleMailMessage();
        msg.setTo(to);
        msg.setSubject("Welcome to the platform, " + username + "!");
        msg.setText("Your account has been created successfully.");
        mailSender.send(msg);
    }
}

// Caller:
notificationService.sendWelcomeEmail(user.getEmail(), user.getName());

// One class. Zero abstraction overhead. Perfectly readable. Easily testable.

KISS Violation: Deep Method Chains That Obscure Intent

Long fluent chains feel elegant at first but become debugging nightmares when something throws a NullPointerException three levels in:

// ❌ VIOLATION — Clever but fragile multi-level chain

String discountCode = orderRepository
    .findActiveOrdersByUser(userId)
    .stream()
    .filter(o -> o.getStatus().isEligibleForDiscount())
    .max(Comparator.comparing(Order::getTotalAmount))
    .map(o -> o.getLoyaltyProfile().getActiveTier().getBenefits().getDiscountCode())
    .orElse("NONE");

// If getLoyaltyProfile() returns null → NullPointerException on line 8.
// Which object was null? Good luck in production.
// ✅ KISS FIX — Break into named steps; each step is testable and debuggable

List<Order> activeOrders = orderRepository.findActiveOrdersByUser(userId);

Optional<Order> highestEligibleOrder = activeOrders.stream()
    .filter(o -> o.getStatus().isEligibleForDiscount())
    .max(Comparator.comparing(Order::getTotalAmount));

String discountCode = highestEligibleOrder
    .flatMap(Order::getDiscountCode)  // delegate null-safety to domain model
    .orElse("NONE");

// Each variable has a name. Each step is independently unit-testable.

KISS in Spring Boot: When Simple Beats "Correct"

3. YAGNI — You Aren't Gonna Need It

Ron Jeffries, one of the XP founders, coined YAGNI: never implement functionality until it is actually needed. YAGNI doesn't mean "never plan ahead" — it means "don't write code for requirements you don't have yet." Every speculative feature has a carrying cost: maintenance, documentation, cognitive load, and increased test surface. YAGNI violations are almost always well-intentioned engineers trying to be helpful.

YAGNI Violation: Building a Plugin System Nobody Asked For

The feature request: "Add a CSV export for the sales report." A YAGNI violation: building a full plugin architecture "in case we ever need to export to Excel, PDF, and XML too":

// ❌ YAGNI VIOLATION — Plugin system for a feature that needs exactly one export format

public interface ReportExporter {
    byte[] export(ReportData data);
    String getFormatName();
    boolean supports(ExportFormat format);
}

public interface ExportPlugin {
    ReportExporter getExporter();
    ExportFormat[] supportedFormats();
    void registerHooks(ExportPluginRegistry registry);
}

@Component
public class ExportPluginRegistry {
    private final Map<ExportFormat, ReportExporter> registry = new ConcurrentHashMap<>();

    public void register(ExportPlugin plugin) {
        for (ExportFormat fmt : plugin.supportedFormats()) {
            registry.put(fmt, plugin.getExporter());
        }
    }
    public ReportExporter resolve(ExportFormat format) {
        return Optional.ofNullable(registry.get(format))
            .orElseThrow(() -> new UnsupportedExportFormatException(format));
    }
}

// Plus: CsvExportPlugin, ExportFormat enum, ExportPluginRegistry,
// ExportPluginAutoConfiguration, and a unit test for each.
// Current users: exactly 1. Current requirement: CSV only.
// ✅ YAGNI FIX — Simplest thing that works today

@Service
public class SalesReportService {

    public byte[] exportToCsv(LocalDate from, LocalDate to) {
        List<SaleRecord> records = saleRepository.findByDateBetween(from, to);
        StringBuilder csv = new StringBuilder("date,amount,product\n");
        for (SaleRecord r : records) {
            csv.append(r.date()).append(',')
               .append(r.amount()).append(',')
               .append(r.productName()).append('\n');
        }
        return csv.toString().getBytes(StandardCharsets.UTF_8);
    }
}

// When Excel export is *actually* requested, extend this method or
// extract an interface at that point — with real requirements to guide the design.

YAGNI Violation: Adding Caching Before Measuring Performance

// ❌ YAGNI VIOLATION — Pre-emptive caching without a measured performance problem

@Service
public class ProductService {

    @Cacheable(value = "products", key = "#id",
               condition = "#id != null", unless = "#result == null")
    public ProductDto findById(Long id) {
        return productRepository.findById(id)
            .map(productMapper::toDto)
            .orElseThrow(() -> new EntityNotFoundException("Product " + id));
    }
}
// Reality: this endpoint is called 50 times/day. The DB query takes 2ms.
// The caching adds stale-data bugs, complexity, and a Redis dependency
// for zero measurable benefit.
// ✅ YAGNI FIX — Add caching only when profiling reveals a real bottleneck

@Service
public class ProductService {

    public ProductDto findById(Long id) {
        return productRepository.findById(id)
            .map(productMapper::toDto)
            .orElseThrow(() -> new EntityNotFoundException("Product " + id));
    }
}

// When load testing shows /products/{id} at P99 > 200ms under production load,
// THEN add @Cacheable — with an informed TTL and a measured baseline to compare against.

YAGNI vs. Over-Engineering: Common Triggers

Premature Abstraction Trigger What Engineers Tell Themselves YAGNI Counter-Argument
Plugin / Strategy pattern "We might need multiple implementations later" Extract the interface when the 2nd implementation is requested, not before
Pre-emptive caching "This query could be slow at scale" Measure first; most queries are fast enough and caching adds bugs
Multi-tenancy scaffolding "We'll need this when we go enterprise" Build it when the first enterprise customer signs a contract
Event sourcing from day one "Audit logs will be important someday" Add an audit table when a specific audit requirement exists; event sourcing is a major architectural commitment
Microservice split on day one "Monoliths don't scale" Start with a modular monolith; split only when team/scaling boundaries are proven

4. Law of Demeter & Tell, Don't Ask

The Law of Demeter (LoD) was formulated at Northeastern University in 1987: a method should only call methods on (1) itself, (2) its direct parameters, (3) objects it creates, or (4) its direct component objects. It is often summarized as "talk to friends, not to strangers." Its companion is the Tell, Don't Ask principle: instead of asking an object for its state and making decisions based on that state externally, tell the object to perform the behavior itself.

The "Train Wreck" Anti-Pattern

The most common LoD violation in Java is a chain of getter calls that traverses the object graph — sometimes called the "train wreck" because it looks like a train of method calls and derails just as badly when a link in the chain is null:

// ❌ VIOLATION — Law of Demeter "train wreck"
// OrderShippingService knows about Order → Customer → Address → City

@Service
public class OrderShippingService {

    public ShippingRate calculateRate(Order order) {
        // Violates LoD: we're traversing 3 levels deep into the object graph
        String city    = order.getCustomer().getAddress().getCity();
        String country = order.getCustomer().getAddress().getCountry();
        double weight  = order.getItems().stream()
                              .mapToDouble(item -> item.getProduct().getWeight() * item.getQuantity())
                              .sum();

        return shippingRateCalculator.calculate(city, country, weight);
    }
}

// Problems:
// 1. OrderShippingService is tightly coupled to Customer, Address, and Product internals
// 2. Any refactoring of Address (rename city to cityName) breaks this service
// 3. NullPointerException if getCustomer() or getAddress() returns null
// ✅ FIX — Delegate via wrapper methods on domain objects (Tell, Don't Ask)

// Order.java — the domain object encapsulates navigation
public class Order {

    private Customer customer;
    private List<OrderItem> items;

    public ShippingDestination getShippingDestination() {
        // Order knows its Customer; delegates city/country resolution
        return customer.getShippingDestination();
    }

    public double getTotalWeight() {
        // Order owns the responsibility of calculating its own weight
        return items.stream()
                    .mapToDouble(OrderItem::getWeightContribution)
                    .sum();
    }
}

// Customer.java
public class Customer {
    private Address address;

    public ShippingDestination getShippingDestination() {
        return new ShippingDestination(address.getCity(), address.getCountry());
    }
}

// OrderShippingService.java — now talks only to Order (direct friend)
@Service
public class OrderShippingService {

    public ShippingRate calculateRate(Order order) {
        ShippingDestination dest   = order.getShippingDestination();
        double              weight = order.getTotalWeight();
        return shippingRateCalculator.calculate(dest.city(), dest.country(), weight);
    }
}

Tell, Don't Ask in Practice

The "Ask" pattern: a service queries an object's state and makes a decision externally. The "Tell" pattern: the service delegates the decision to the object that owns the data:

// ❌ ASK PATTERN — Service reaches into Account to make a decision

@Service
public class AccountService {

    public void applyMonthlyFee(Account account) {
        // Service is ASKING for state, then deciding:
        if (account.getBalance() >= account.getMinimumBalance()
            && account.getStatus() == AccountStatus.ACTIVE
            && !account.isFeesWaived()) {
            account.setBalance(account.getBalance() - account.getMonthlyFee());
        }
    }
}

// ✅ TELL PATTERN — Service tells Account to handle its own logic

// Account.java
public class Account {

    public boolean applyMonthlyFeeIfApplicable() {
        if (balance >= minimumBalance
            && status == AccountStatus.ACTIVE
            && !feesWaived) {
            balance -= monthlyFee;
            return true;
        }
        return false;
    }
}

// AccountService.java
@Service
public class AccountService {

    public void applyMonthlyFee(Account account) {
        boolean feeApplied = account.applyMonthlyFeeIfApplicable();
        if (feeApplied) {
            eventPublisher.publishEvent(new FeeAppliedEvent(account.getId()));
        }
    }
}

Law of Demeter in Spring Boot Service Layer

A real Spring Boot service-layer violation: an OrderFulfillmentService that reaches through Order → Payment → PaymentGateway → GatewayConfig → ApiKey to capture a charge. The fix: encapsulate charge capture behind a PaymentService API that OrderFulfillmentService calls directly:

// ❌ VIOLATION in service layer

@Service
@RequiredArgsConstructor
public class OrderFulfillmentService {

    public void fulfillOrder(Long orderId) {
        Order order = orderRepository.findById(orderId).orElseThrow();
        // Train wreck: we reach 4 levels deep
        String apiKey = order.getPayment()
                             .getPaymentGateway()
                             .getConfig()
                             .getApiKey();
        String chargeId = stripeClient.capture(apiKey, order.getPayment().getChargeToken());
        order.getPayment().setStatus(PaymentStatus.CAPTURED);
        order.setStatus(OrderStatus.FULFILLED);
        orderRepository.save(order);
    }
}

// ✅ FIX — Delegate to PaymentService; OrderFulfillmentService stays in its lane

@Service
@RequiredArgsConstructor
public class OrderFulfillmentService {

    private final OrderRepository  orderRepository;
    private final PaymentService   paymentService;   // direct friend

    public void fulfillOrder(Long orderId) {
        Order order = orderRepository.findById(orderId).orElseThrow();
        paymentService.capturePayment(order.getPaymentId()); // tell, don't ask
        order.markFulfilled();
        orderRepository.save(order);
    }
}

5. Combining All 4 Principles: E-Commerce Order Processing

Let's examine how all four principles apply to a single OrderService class. First, the "before" state — a realistic mess of violations commonly found in production Spring Boot services:

// ❌ ALL FOUR VIOLATIONS in one service

@Service
public class OrderService {

    // DRY violation: stock-check logic is also duplicated in CartService and WishlistService
    public OrderConfirmation placeOrder(Long userId, List<OrderItemDto> items) {

        // LoD violation: traversing User → Profile → Preferences → NotificationChannel
        String channel = userRepository.findById(userId).get()
            .getProfile().getPreferences().getNotificationChannel();

        // YAGNI violation: building a full notification plugin system
        // when this app only ever sends emails
        NotificationStrategy strategy = notificationPluginRegistry.resolve(channel);

        // DRY violation: same price-calculation logic exists in QuoteService
        double total = 0;
        for (OrderItemDto item : items) {
            Product product = productRepository.findById(item.getProductId()).orElseThrow();
            if (product.getStock() < item.getQuantity()) {           // DRY: stock check duplicated
                throw new InsufficientStockException(product.getId());
            }
            total += product.getPrice() * item.getQuantity()
                   * (1 - product.getDiscountRate());                // DRY: discount formula duplicated
        }

        // KISS violation: overly generic builder with 14 optional fields
        // when every order has the same 5 required fields
        Order order = OrderBuilder.newInstance()
            .withUserId(userId)
            .withItems(items)
            .withTotal(total)
            .withStatus(OrderStatus.PENDING)
            .withCreationStrategy(CreationStrategy.STANDARD)
            .withProcessingPipeline(ProcessingPipeline.DEFAULT)
            .withMetadataKey("source", "web")
            .build();

        orderRepository.save(order);
        strategy.send(new NotificationPayload(userId, "Order placed!", "Total: " + total));
        return new OrderConfirmation(order.getId(), total);
    }
}

Now the refactored version applying all four principles:

// ✅ ALL FOUR PRINCIPLES APPLIED

@Service
@RequiredArgsConstructor
public class OrderService {

    private final OrderRepository      orderRepository;
    private final ProductService       productService;    // direct friend (LoD)
    private final PricingService       pricingService;    // eliminates DRY violation
    private final NotificationService  notificationService; // KISS: simple @Service, no plugins

    public OrderConfirmation placeOrder(Long userId, List<OrderItemDto> items) {

        // DRY: stock validation lives in ProductService (single source of truth)
        productService.validateStockAvailability(items);

        // DRY + KISS: price calculation lives in PricingService
        Money total = pricingService.calculateOrderTotal(items);

        // KISS: straightforward constructor, no over-engineered builder
        Order order = new Order(userId, items, total, OrderStatus.PENDING);
        orderRepository.save(order);

        // LoD: we ask the User for its notification preference via a single method
        // YAGNI: simple email notification — no plugin registry
        notificationService.sendOrderConfirmation(userId, order.getId(), total);

        return new OrderConfirmation(order.getId(), total.amount());
    }
}

// PricingService.java — the single source of truth for pricing (DRY)
@Service
public class PricingService {
    public Money calculateOrderTotal(List<OrderItemDto> items) {
        double total = items.stream()
            .mapToDouble(item -> {
                Product p = productRepository.findById(item.getProductId()).orElseThrow();
                return p.getEffectivePrice().multiply(item.getQuantity()); // Tell, Don't Ask
            })
            .sum();
        return Money.of(total, "USD");
    }
}

// Product.java — getEffectivePrice() encapsulates the discount rule (Tell, Don't Ask)
public class Product {
    public BigDecimal getEffectivePrice() {
        return price.multiply(BigDecimal.ONE.subtract(discountRate));
    }
}

Principle Summary Table

Principle Signal It's Violated Primary Fix
DRY Grep finds the same logic in 2+ places; changing a rule requires multi-file edits Extract to a service, utility, or Bean Validation annotation
KISS Onboarding a new engineer takes hours to understand a simple feature; class count >> concept count Delete abstraction layers; use concrete types; prefer records and @Service
YAGNI Code paths that are never exercised by current tests; "just in case" comments Delete speculative code; implement when the requirement is real and concrete
Law of Demeter Method chains > 2 dots; changing an internal object breaks distant services Add delegation methods to domain objects; Tell, Don't Ask; inject direct collaborators

6. Common Pitfalls & Interview Tips

DRY vs. WET — When Duplication Is Acceptable

WET stands for "Write Everything Twice" (sometimes "We Enjoy Typing"). The rule of three is a pragmatic heuristic: do not extract until you have three concrete instances of the same concept. Premature DRY can create a worse problem than duplication: a poorly designed abstraction that is hard to change. If two pieces of code look similar but represent genuinely different business concepts (e.g., order total calculation vs. invoice total calculation), keeping them separate is the right call — even if they currently share logic. The key question is: if one changes, should the other change too? If yes → extract. If not necessarily → keep separate.

KISS vs. YAGNI Tension

KISS and YAGNI both push for simplicity, but they can create tension. KISS says "the simplest solution now" while YAGNI says "don't build what isn't needed." The conflict surfaces when the simplest solution today (a hard-coded switch statement) will need to become a flexible strategy in one sprint. In practice: YAGNI governs scope (what to build); KISS governs implementation quality (how to build it simply). When in doubt, err toward YAGNI — you can always add an abstraction; removing a bad one costs much more.

How These Principles Relate to SOLID

These four principles are complementary to SOLID, not a replacement:

Top Interview Questions & Model Answers

Q: How does DRY differ from just avoiding copy-paste?

Model answer: DRY is about knowledge, not text. Two methods that look identical but represent different business concepts do not violate DRY — they model different knowledge. Conversely, two methods that differ in variable names but encode the same business rule do violate DRY. The test is: if the business rule changes, how many places must I update? If the answer is more than one, you probably have a DRY violation.

Q: Can you give an example where YAGNI and SOLID contradict each other?

Model answer: Open/Closed Principle (make classes open for extension) can encourage adding abstraction hooks proactively. YAGNI says don't build those hooks until you have a real extension. The pragmatic resolution: apply OCP on the second modification. If you're changing a class for the first time, use YAGNI and make the change directly. If you're changing it a second time in the same direction, apply OCP and add the abstraction to prevent future modifications. This is the "Rule of Three" applied to SOLID.

Q: What is the Law of Demeter, and how does it relate to Tell, Don't Ask?

Model answer: The Law of Demeter restricts what objects a method may call methods on — only direct parameters, components, and objects it creates. Tell, Don't Ask is the behavioral counterpart: instead of asking an object for its data and deciding externally, tell the object what to do and let it decide internally. LoD is the structural rule; TDA is the design philosophy behind it. In Java, a train wreck like order.getCustomer().getAddress().getCity() violates both: it chains through strangers (LoD) and asks for data to make a decision that the domain objects should own (TDA).

Q: How do you apply KISS in a microservices architecture?

Model answer: Start with a well-structured modular monolith. Extract a microservice only when a specific team ownership boundary, independent deployment need, or scaling bottleneck is proven — not speculated. Within each service, KISS means: one Spring Boot app, one database, one responsibility. Avoid building in saga orchestration, event sourcing, and multi-region replication until real traffic and team size demand it. The single greatest KISS violation in microservices is splitting too early, creating distributed system complexity before you have the scale to justify it.

7. Conclusion & Cheat Sheet

DRY, KISS, YAGNI, and the Law of Demeter are not rules to follow mechanically — they are lenses for evaluating design decisions. Senior engineers internalize them as instincts: a moment of hesitation before adding a third copy of validation logic (DRY), a pause before building the plugin system (YAGNI), a reflex to flatten the method chain (KISS & LoD). The goal is not pristine compliance — it is code that is easy to reason about, change, and debug at 2 AM in production.

Quick Cheat Sheet — Code Review Checklist

  • DRY: If this logic changes, how many files must I update? More than one = DRY violation.
  • DRY: Is this validation already in a @ControllerAdvice, @Validated constraint, or service? If yes, reuse it.
  • KISS: Could a new engineer understand this method in under 2 minutes? If not, simplify.
  • KISS: Am I using a Design Pattern because it's appropriate, or because it sounds impressive? Remove if the latter.
  • YAGNI: Is there a concrete ticket or confirmed requirement for every abstraction here? Delete speculative code.
  • YAGNI: Is this cache, plugin system, or extension point exercised by any current test? If not, is it needed?
  • LoD: Does any method chain have more than 2 dots? Delegate the navigation to the domain object.
  • LoD / TDA: Am I asking for state to make a decision that the domain object should make itself? Convert to a tell.

In 2026, these principles are more relevant than ever because AI coding assistants (Copilot, Cursor, Gemini) tend to generate verbose, speculative, over-engineered code. Your job as a senior engineer is to apply DRY, KISS, YAGNI, and LoD as a filter on generated code — not just on code you write yourself. The engineer who consistently delivers simple, focused, testable code is more valuable than the one who builds impressive-looking architectures for problems that don't exist yet.

Leave a Comment

Related Posts

Md Sanwar Hossain - Software Engineer
Md Sanwar Hossain

Software Engineer · Java · Spring Boot · Microservices · AI/LLM Systems

All Posts
Last updated: April 9, 2026