Compile Ready
All low level design problems
Low Level Design/Concepts & Foundations/ Concept

Design Principles (DRY, KISS, YAGNI)

The pragmatic heuristics — DRY, KISS, YAGNI, composition over inheritance, and law of Demeter — that keep code lean.

5m readDRYKISSYAGNICompositionCoupling

Introduction

Design principles are practical guardrails for making LLD answers simple, extensible, and easy to reason about. They sit below SOLID: when the interviewer asks why your classes are shaped this way, these heuristics explain the tradeoff.

Use them to keep the design lean: remove duplication, avoid speculative abstractions, keep object graphs understandable, and make change local.

Learning Objectives

  • Explain DRY, KISS, YAGNI, composition over inheritance, Law of Demeter, high cohesion and low coupling, program to an interface, and separation of concerns in interview language.

  • Identify the code smell each principle is meant to prevent.

  • Refactor a small Java anti-pattern into a cleaner object model.

  • Connect each principle to a practical LLD payoff such as easier extension, safer testing, or smaller blast radius.

Core Theory

DRY: do not repeat knowledge

Definition: DRY means every important business rule should have one authoritative home. Duplication of syntax is not always bad, but duplication of decisions is dangerous.

Interview payoff: In LLD, DRY helps you extract policy objects such as pricing, validation, eligibility, or retry rules. When the interviewer asks for a new rule, you change one class instead of hunting through flows.

// Anti-pattern: the same discount rule is copied into two flows.
class CheckoutService {
    int totalAfterDiscount(Customer customer, int subtotal) {
        if (customer.isPremium() && subtotal > 1000) {
            return subtotal - 100;
        }
        return subtotal;
    }
}

class InvoiceService {
    int payableAmount(Customer customer, int subtotal) {
        if (customer.isPremium() && subtotal > 1000) {
            return subtotal - 100;
        }
        return subtotal;
    }
}

// Improved: the business rule has one owner.
interface DiscountPolicy {
    int apply(Customer customer, int subtotal);
}

class PremiumDiscountPolicy implements DiscountPolicy {
    public int apply(Customer customer, int subtotal) {
        if (customer.isPremium() && subtotal > 1000) {
            return subtotal - 100;
        }
        return subtotal;
    }
}

class CheckoutService {
    private final DiscountPolicy discounts;

    CheckoutService(DiscountPolicy discounts) {
        this.discounts = discounts;
    }

    int total(Customer customer, int subtotal) {
        return discounts.apply(customer, subtotal);
    }
}

KISS: keep the simple case simple

Definition: KISS says the clearest design that satisfies the requirements usually beats a clever generalized framework.

Interview payoff: For beginner and intermediate LLD problems, KISS prevents premature factories, reflection, generic registries, and deep hierarchies. You spend time on responsibilities and invariants instead of explaining accidental complexity.

// Anti-pattern: over-engineered dispatch for a tiny fixed choice.
class ReportEngine {
    private final Map<String, ReportPlugin> plugins;

    ReportEngine(Map<String, ReportPlugin> plugins) {
        this.plugins = plugins;
    }

    Report render(String type, Order order) {
        return plugins.get(type).render(order);
    }
}

interface ReportPlugin {
    Report render(Order order);
}

// Improved: direct code is enough until variation grows.
enum ReportType {
    SUMMARY, DETAILED
}

class ReportService {
    Report render(ReportType type, Order order) {
        if (type == ReportType.SUMMARY) {
            return Report.summary(order);
        }
        return Report.detailed(order);
    }
}

YAGNI: do not build tomorrow until it arrives

Definition: YAGNI means do not add capabilities, extension points, or storage models just because they may be useful someday.

Interview payoff: YAGNI keeps the scope aligned with the clarified requirements. You can say, this is a documented extension, not part of the core model, which shows judgment under a 45-minute constraint.

// Anti-pattern: adding cancellation, audit, and scheduling before the prompt asks for them.
class Booking {
    private BookingState state;
    private List<AuditEvent> auditEvents;
    private RefundPolicy refundPolicy;
    private ReschedulePolicy reschedulePolicy;
    private LoyaltyPolicy loyaltyPolicy;

    void reserve(Seat seat, User user) {
        // Core reservation is buried under unused future features.
    }
}

// Improved: model the required behavior and leave clear seams.
class Booking {
    private final Seat seat;
    private final User user;
    private BookingStatus status = BookingStatus.CONFIRMED;

    Booking(Seat seat, User user) {
        this.seat = seat;
        this.user = user;
    }

    boolean isConfirmed() {
        return status == BookingStatus.CONFIRMED;
    }
}

Composition over inheritance

Definition: Prefer assembling objects with contained collaborators over forcing behavior through a rigid parent-child hierarchy.

Interview payoff: Composition lets you vary one dimension at a time. In LLD answers, it is often the cleanest way to combine payment methods, pricing rules, notification channels, movement rules, or storage backends without subclass explosion.

// Anti-pattern: every combination becomes a subclass.
abstract class NotificationService {
    abstract void notify(User user, String message);
}

class EmailOrderNotificationService extends NotificationService {
    void notify(User user, String message) {
        // Send order email.
    }
}

class SmsOrderNotificationService extends NotificationService {
    void notify(User user, String message) {
        // Send order SMS.
    }
}

// Improved: the order flow composes a channel.
interface NotificationChannel {
    void send(User user, String message);
}

class EmailChannel implements NotificationChannel {
    public void send(User user, String message) {
        // Send email.
    }
}

class OrderNotifier {
    private final NotificationChannel channel;

    OrderNotifier(NotificationChannel channel) {
        this.channel = channel;
    }

    void orderPlaced(User user, Order order) {
        channel.send(user, order.summary());
    }
}

Law of Demeter: talk to close friends only

Definition: An object should collaborate with itself, its fields, its parameters, and objects it creates directly. It should not navigate through a long chain of someone else’s internals.

Interview payoff: Law of Demeter protects encapsulation. It helps you move behavior to the object that owns the data, which makes UML relationships simpler and reduces fragile train-wreck calls.

// Anti-pattern: the service reaches through multiple objects.
class ShippingService {
    Money shippingCost(Order order) {
        Address address = order.getCustomer().getProfile().getAddress();
        return rateCard().priceFor(address.getPostalCode());
    }
}

// Improved: Order exposes the decision the caller needs.
class Order {
    private final Customer customer;

    String deliveryPostalCode() {
        return customer.deliveryPostalCode();
    }
}

class Customer {
    private final Profile profile;

    String deliveryPostalCode() {
        return profile.deliveryPostalCode();
    }
}

class ShippingService {
    Money shippingCost(Order order) {
        return rateCard().priceFor(order.deliveryPostalCode());
    }
}

High cohesion and low coupling

Definition: High cohesion means a class has one focused reason to exist. Low coupling means it depends on few implementation details of other classes.

Interview payoff: Cohesion and coupling are how interviewers judge whether your boxes on the whiteboard are real objects or random buckets. A cohesive class is easier to name, test, and extend; a loosely coupled design has a smaller change blast radius.

// Anti-pattern: one class owns unrelated responsibilities and concrete dependencies.
class OrderManager {
    private final MySqlConnection database = new MySqlConnection();
    private final SmtpClient smtp = new SmtpClient();

    void place(Order order) {
        validate(order);
        database.insert(order);
        smtp.send(order.customerEmail(), order.summary());
    }

    private void validate(Order order) {
        // Validation rules.
    }
}

// Improved: focused collaborators are wired into the use case.
class OrderService {
    private final OrderValidator validator;
    private final OrderRepository repository;
    private final ReceiptSender receipts;

    OrderService(OrderValidator validator, OrderRepository repository, ReceiptSender receipts) {
        this.validator = validator;
        this.repository = repository;
        this.receipts = receipts;
    }

    void place(Order order) {
        validator.validate(order);
        repository.save(order);
        receipts.send(order);
    }
}

Program to an interface

Definition: Depend on the behavior you need, not on the concrete class that happens to provide it today.

Interview payoff: Interfaces create stable seams for strategies, repositories, gateways, and clocks. They make follow-ups easy: swap a payment provider, add caching, or test with a fake without rewriting the caller.

// Anti-pattern: the service is tied to one provider.
class PaymentService {
    private final StripeGateway gateway = new StripeGateway();

    Receipt pay(Money amount) {
        return gateway.charge(amount);
    }
}

// Improved: depend on required behavior.
interface PaymentGateway {
    Receipt charge(Money amount);
}

class StripeGateway implements PaymentGateway {
    public Receipt charge(Money amount) {
        return new Receipt();
    }
}

class PaymentService {
    private final PaymentGateway gateway;

    PaymentService(PaymentGateway gateway) {
        this.gateway = gateway;
    }

    Receipt pay(Money amount) {
        return gateway.charge(amount);
    }
}

Separation of concerns

Definition: Keep distinct concerns in distinct modules: domain rules, orchestration, persistence, presentation, and integrations should not be mixed in one class.

Interview payoff: Separation of concerns gives your LLD answer clean layers. It prevents a model class from knowing about SQL, HTTP, or email, and it helps you explain where future APIs, databases, and background workers would plug in.

// Anti-pattern: domain, persistence, and email are mixed.
class User {
    private String email;

    void register() {
        Sql.insert(this);
        Mailer.send(email, confirmationText());
    }

    private String confirmationText() {
        return "Welcome";
    }
}

// Improved: the domain object is simple, the use case coordinates collaborators.
class User {
    private final String email;

    User(String email) {
        this.email = email;
    }

    String email() {
        return email;
    }
}

class RegistrationService {
    private final UserRepository users;
    private final EmailSender emails;

    RegistrationService(UserRepository users, EmailSender emails) {
        this.users = users;
        this.emails = emails;
    }

    void register(User user) {
        users.save(user);
        emails.sendWelcome(user.email());
    }
}

Diagrams

Composition reduces subclass explosion

Rendering diagram…
Inheritance bakes one variation into the type tree. Composition keeps the order flow stable and swaps the channel behind an interface.

Comparisons

Principle, symptom, remedy, and LLD payoff

PrincipleSymptom in a designTypical remedyInterview payoff
DRYThe same business rule appears in multiple services.Extract a policy, validator, or domain method with one owner.Follow-up rule changes are local.
KISSA simple requirement is hidden behind frameworks and layers.Use direct classes and add abstraction only when variation is real.The interviewer can follow the model quickly.
YAGNIThe answer includes unused features and speculative extension points.State the extension and keep it outside the core solution.Scope stays realistic for the interview.
CompositionSubclass count grows for every feature combination.Inject collaborators for independently varying behavior.New behavior does not require rebuilding the hierarchy.
Law of DemeterCalls chain through internal object graphs.Expose intention-revealing methods on the owner object.Encapsulation is visible in the UML.
High cohesion and low couplingClasses are hard to name or depend on concrete infrastructure.Split responsibilities and depend on narrow interfaces.Change impact and test setup stay small.
Program to an interfaceA service directly constructs a concrete gateway or repository.Define the required behavior and inject an implementation.Provider swaps and fakes become easy.
Separation of concernsDomain classes contain SQL, HTTP, formatting, or email code.Keep domain, orchestration, persistence, and adapters separate.The design maps cleanly to layers and production boundaries.

Coupling types to recognize

Coupling typeWhat it looks likeRiskBetter direction
Concrete class couplingA service creates a specific database, gateway, or client.Tests and provider changes are expensive.Depend on a small interface.
Data-structure couplingCallers know a nested map, list, or object graph shape.Internal representation cannot change safely.Expose intention-based methods.
Temporal couplingMethods must be called in a hidden order.Objects are easy to misuse.Model valid states explicitly and enforce invariants.
Policy couplingBusiness rules are hard-coded inside orchestration.Rule changes modify the main flow.Extract strategy, policy, or rule objects.

Best Practices

  • Start every LLD answer with the simplest model that satisfies the clarified requirements.

  • Name classes around cohesive responsibilities, not around technical layers alone.

  • Extract a new abstraction only when there is a real axis of variation or a clear testing seam.

  • Keep business rules close to the domain object or policy that owns them.

  • Prefer injected collaborators over direct construction of infrastructure in core services.

  • Use composition when behavior combinations would create a wide inheritance tree.

  • Call out YAGNI explicitly for features that are reasonable extensions but not part of the asked scope.

Common Mistakes

  • ×

    Treating DRY as a command to remove every repeated line instead of duplicated knowledge.

  • ×

    Adding a design pattern because it sounds advanced, even when a simple class would do.

  • ×

    Creating inheritance hierarchies for behavior that should vary independently.

  • ×

    Letting services reach through many getters instead of asking an object for the result they need.

  • ×

    Mixing persistence, domain rules, and notifications inside the same entity.

  • ×

    Depending on concrete gateways or repositories in the core flow, making follow-ups harder.

  • ×

    Using YAGNI to reject obvious extension seams that are already requested by the prompt.

Quiz

0/7 answered

  1. 1.Which statement best captures DRY in LLD?

  2. 2.When should KISS push you away from adding a pattern?

  3. 3.What is the main risk of violating YAGNI?

  4. 4.Why is composition often preferred over inheritance in LLD interviews?

  5. 5.Which call most clearly violates the Law of Demeter?

  6. 6.What does high cohesion usually improve?

  7. 7.Program to an interface is most useful when:

Flashcards

Cheat Sheet

Design principles interview cheat sheet

  • DRY: one owner for each business rule. Extract policies, validators, or domain methods when copied decisions appear.
  • KISS: prefer the smallest clear design. Avoid clever frameworks unless the requirement needs them.
  • YAGNI: do not implement future features. Mention extensions and keep the core focused.
  • Composition over inheritance: vary behavior by injecting collaborators instead of multiplying subclasses.
  • Law of Demeter: avoid long navigation chains. Ask objects for the result you need.
  • High cohesion: a class should be easy to name because its methods and data belong together.
  • Low coupling: depend on stable interfaces and intention methods, not concrete infrastructure or object internals.
  • Program to an interface: model repositories, gateways, strategies, and clocks as behavior contracts.
  • Separation of concerns: keep domain, orchestration, persistence, presentation, and integration code in separate places.

In interviews, state the principle, point to the smell it prevents, and show the class boundary that makes change local.

References

  • BookClean CodeRobert C. Martin
  • BookRefactoringMartin Fowler
  • BookDesign Patterns: Elements of Reusable Object-Oriented SoftwareErich Gamma, Richard Helm, Ralph Johnson, John Vlissides
  • BlogComposition Over InheritanceWikipedia contributors