Software Design & Architecture35 min read·By Liyabona Saki··

Essential Software Design Principles Every Developer Should Know

A practical overview of the design principles that separate junior and senior engineers — DRY, KISS, YAGNI, SOLID, separation of concerns and more.

Introduction

Every senior engineer you've admired has internalized a set of heuristics that guide their design decisions. These heuristics have names: DRY, KISS, YAGNI, Separation of Concerns, Composition over Inheritance, Law of Demeter, high cohesion, low coupling. They aren't rules to follow blindly — they're forces that, when balanced correctly, push code toward a state where it can be safely changed, understood quickly, and tested efficiently.

This article explores each principle in depth with Java and Spring Boot examples, including when to apply it and — crucially — when NOT to apply it. Principles in conflict are just as important to understand as principles in isolation.

Key Takeaways

  • DRY is about knowledge, not copy-pasted lines.
  • KISS prevents over-engineering; the simplest thing that works is usually right.
  • YAGNI stops speculative generality before it accumulates.
  • Separation of Concerns is the macro-level version of SRP.
  • Composition over Inheritance gives you flexibility at runtime.
  • Law of Demeter reduces coupling between distant components.
  • High cohesion and low coupling are the ultimate quality measures.

DRY — Don't Repeat Yourself

DRY, from *The Pragmatic Programmer*, states: every piece of knowledge must have a single, unambiguous, authoritative representation within a system. Note that it says *knowledge*, not *code*. Two lines of code that look identical may represent different pieces of knowledge and should stay separate.

DRY Violation

```java
// In OrderService
double calculateTax(double amount) {
  return amount * 0.20;
}

// In InvoiceService double computeTax(double value) { return value * 0.20; } ```

The tax rate (20%) is duplicated. If VAT changes to 23%, you hunt down every copy.

DRY Fix

```java
@Component
public class TaxCalculator {
  @Value("${tax.rate:0.20}")
  private double taxRate;

public double calculate(double amount) { return amount * taxRate; } } ```

One source of truth. Change the rate in application.properties and it flows everywhere.

When NOT to Apply DRY

Two methods that look identical but represent different business rules should NOT be merged:

```java
// Employee bonus — governed by HR policy
double employeeBonus(double salary)    { return salary * 0.05; }

// Contractor bonus — governed by contract law double contractorBonus(double rate) { return rate * 0.05; } ```

Merging these into one method creates false coupling. When the contractor rate changes, you don't want to touch employee logic.

KISS — Keep It Simple, Stupid

KISS means: prefer the simplest design that satisfies the requirements. Complexity should be earned, not speculated.

KISS Violation

java
// Overengineered filter builder for a 2-condition query
List<User> activeAdmins = new QueryBuilder<User>()
    .addPredicate(new ActiveStatusPredicate(true))
    .addPredicate(new RolePredicate(Role.ADMIN))
    .withSortStrategy(new DefaultSortStrategy())
    .build(userRepository);

KISS Fix

java
List<User> activeAdmins = users.stream()
    .filter(u -> u.isActive() && u.getRole() == Role.ADMIN)
    .toList();

The stream version is readable, requires no framework, and handles two conditions cleanly. Add the QueryBuilder when you have 10 dynamic conditions — not before.

Spring Boot KISS Example

```java
// Overcomplicated: custom FactoryBean, BeanDefinitionRegistrar, and BeanPostProcessor
// to create a simple service with a flag

// KISS: use @ConditionalOnProperty @Service @ConditionalOnProperty(name = "feature.new-pricing", havingValue = "true") public class NewPricingService implements PricingService { /* ... */ }

@Service @ConditionalOnProperty(name = "feature.new-pricing", matchIfMissing = true) public class LegacyPricingService implements PricingService { /* ... */ } ```

Spring already solves this. Use it.

YAGNI — You Aren't Gonna Need It

YAGNI: don't implement something until you actually need it. Future requirements are guesses. Most guesses are wrong.

YAGNI Violation

java
public class ReportService {
  // "Someone might want to export to XML later"
  public byte[] exportPdf(ReportData d)   { /* implemented */ }
  public byte[] exportCsv(ReportData d)   { /* implemented */ }
  public byte[] exportXml(ReportData d)   { /* TODO - just returns empty */ }
  public byte[] exportExcel(ReportData d) { /* TODO - just returns empty */ }
}

Deadcode is maintained, confuses new developers, and inflates test requirements.

YAGNI Fix

java
public class ReportService {
  public byte[] exportPdf(ReportData d) { /* real implementation */ }
  public byte[] exportCsv(ReportData d) { /* real implementation */ }
  // XML/Excel added when a ticket exists and a product owner prioritizes it
}

The Tension Between DRY and YAGNI

DRY says "don't duplicate knowledge". YAGNI says "don't build abstractions for hypothetical futures". When you see two similar code paths, YAGNI says wait for a third before extracting a common abstraction. If it stays at two, the duplication was fine.

Separation of Concerns (SoC)

SoC means each module or layer handles one concern. The HTTP layer parses and validates requests. The service layer applies business rules. The repository layer persists data. No layer should bleed into another.

Spring Boot Layering

```java
// Controller concern: HTTP — parse input, validate, return response
@RestController
@RequiredArgsConstructor
public class OrderController {
  private final OrderService service;

@PostMapping("/orders") public ResponseEntity<OrderDto> place(@RequestBody @Valid OrderRequest req) { Order order = service.place(req.toOrder()); return ResponseEntity.created(URI.create("/orders/" + order.getId())) .body(OrderDto.from(order)); } }

// Service concern: business rules @Service public class OrderService { public Order place(Order order) { order.validate(); // apply pricing rules, check inventory, etc. return repository.save(order); } }

// Repository concern: persistence @Repository public interface OrderJpaRepository extends JpaRepository<Order, Long> { List<Order> findByCustomerIdOrderByCreatedAtDesc(long customerId); } ```

Each layer changes for independent reasons. A change to the JSON response format doesn't touch the pricing logic.

Composition over Inheritance

Prefer assembling behavior from small collaborators over building deep class hierarchies.

Inheritance Problem

java
class Vehicle { void startEngine() {} }
class Car extends Vehicle { void playRadio() {} }
class ElectricCar extends Car {
  // Doesn't have a radio? Inherits startEngine which doesn't apply?
}

Composition Solution

```java
interface Engine   { void start(); }
interface Audio    { void play(String track); }

class GasolineEngine implements Engine { public void start() { /* vroom */ } } class ElectricMotor implements Engine { public void start() { /* whirr */ } } class CarRadio implements Audio { public void play(String t) { /* ... */ } }

class Car { private final Engine engine; private final Audio audio; // optional: null or a NoOpAudio Car(Engine engine, Audio audio) { this.engine = engine; this.audio = audio; } }

var tesla = new Car(new ElectricMotor(), new CarRadio()); var commercial = new Car(new GasolineEngine(), null); ```

Behavior is mixed and matched at construction time.

Law of Demeter

The Law of Demeter says: talk to friends, not strangers. A method should only call methods on: - this - Its direct fields - Objects passed as parameters - Objects it creates

Violation

java
// Reaches through the object graph — brittle
double amount = order.getCustomer().getAccount().getBalance().getAmount();

This method knows the internal structure of Order, Customer, Account, and Balance. Any change in that chain breaks this line.

Fix

```java
// Order encapsulates the walk
double amount = order.customerAccountBalance();

// In Order: public double customerAccountBalance() { return customer.accountBalance(); } ```

Or use a dedicated service to cross aggregate boundaries.

Spring Boot Example

```java
// VIOLATION
@PostMapping("/checkout")
public ResponseEntity<?> checkout(Principal principal) {
  User user = userService.findByUsername(principal.getName());
  double credit = user.getLoyalty().getPoints().toCreditAmount(); // chain of 3
  // ...
}

// FIX @PostMapping("/checkout") public ResponseEntity<?> checkout(Principal principal) { double credit = loyaltyService.creditAmountFor(principal.getName()); // ... } ```

High Cohesion and Low Coupling

These two metrics are the ultimate design health indicators:

  • High cohesion: everything inside a class/module is closely related and works toward one goal.
  • Low coupling: a class/module knows as little as possible about other classes/modules.
```java
// HIGH COHESION: all methods are about Invoice calculation
public class InvoiceCalculator {
  public double subtotal(Invoice i)  { return i.items().stream().mapToDouble(Item::total).sum(); }
  public double tax(Invoice i)       { return subtotal(i) * taxRate; }
  public double grandTotal(Invoice i){ return subtotal(i) + tax(i); }
}

// LOW COUPLING via DIP public class InvoiceService { private final InvoiceRepository repo; // interface private final InvoiceCalculator calc; // no knowledge of DB or HTTP } ```

When Principles Conflict

| Situation | Winning principle | |---|---| | Two similar lines vs. premature abstraction | YAGNI (wait for three) | | Simple inline code vs. testable design | DIP (testability wins) | | Fewer files vs. clarity | KISS (readability wins) | | DRY extraction vs. coupling unrelated modules | Cohesion (keep them separate) |

Production Best Practices

  • Run SonarQube or similar static analysis; cognitive complexity > 15 is a KISS/SRP violation.
  • Add ArchUnit tests to enforce layer boundaries (SoC).
  • In code review, ask: "is there a simpler way?" before approving complex solutions.
  • Use feature flags instead of YAGNI abstractions when you genuinely need future extensibility.

FAQ

Q: How do I know when DRY extraction creates coupling vs. removes duplication? Ask: "Would a change to this extracted piece always require the same change to all its callers?" If yes, extract. If callers might diverge, leave it duplicated.

Q: Is YAGNI an excuse not to write good interfaces? No. Write interfaces at the architectural seams (ports for your domain). Don't write them speculatively for every service class.

Q: Which principle should I focus on first? Start with SRP (SoC at the class level) and DIP. They deliver the most immediate benefit in testability and maintainability.

Related Tutorials

What this guide consolidates

Smaller pages covering individual design heuristics (DRY, KISS, YAGNI, coupling vs cohesion, common OO mistakes) have been folded into this single guide. They reinforce each other and are easier to apply when read as a connected set.

DRY, KISS, and YAGNI Explained with Java Examples

Introduction

DRY, KISS, and YAGNI are three of the most cited principles in software engineering. Together they prevent two of the most common diseases in codebases: accidental duplication that diverges over time, and speculative complexity that was never actually needed. Every senior engineer has battle scars from both. These three acronyms are the antidote.

But like any tool, they can be misapplied. This article gives you multiple code examples per principle, shows the anti-patterns, and — critically — describes when *not* to apply each one.

Key Takeaways

  • DRY is about authoritative knowledge, not literal copy-paste.
  • KISS means choose the simplest approach that fully solves the problem.
  • YAGNI means don't implement features until there's a concrete use case.
  • All three have failure modes on the over-application side.
  • They create healthy tension with each other.

DRY — Don't Repeat Yourself

The Core Idea

DRY from *The Pragmatic Programmer* (Hunt & Thomas): every piece of *knowledge* must have a single, unambiguous, authoritative representation. The key word is *knowledge*. Two lines of code that look the same may represent different pieces of knowledge.

Example 1: Configuration Values

```java
// VIOLATION: the 20% tax rate is duplicated
double vatAmount(double price)    { return price * 0.20; }  // in OrderService
double invoiceTax(double amount)  { return amount * 0.20; } // in InvoiceService

// DRY FIX: single source of truth @Value("${vat.rate}") private double vatRate;

double vatAmount(double price) { return price * vatRate; } double invoiceTax(double amount) { return amount * vatRate; } // or better: one TaxCalculator @Component ```

Example 2: Validation Logic

```java
// VIOLATION: same email validation in two controllers
boolean isValidEmail(String email) { return email.contains("@") && email.length() > 5; }

// DRY FIX: one validator @Component public class EmailValidator { public boolean isValid(String email) { return email != null && email.contains("@") && email.length() > 5; } } ```

Example 3: Database Queries

```java
// VIOLATION: same complex JPQL in multiple repositories
@Query("SELECT o FROM Order o WHERE o.status = 'PENDING' AND o.createdAt < :cutoff")
List<Order> findStaleOrders(@Param("cutoff") Instant cutoff);
// ... same query copy-pasted in OrderAuditRepository

// DRY FIX: one repository owns the query public interface OrderRepository extends JpaRepository<Order, Long> { @Query("SELECT o FROM Order o WHERE o.status = 'PENDING' AND o.createdAt < :cutoff") List<Order> findPendingBefore(@Param("cutoff") Instant cutoff); } // OrderAuditRepository delegates to OrderRepository ```

When DRY Becomes a Problem

```java
// Two rules that look identical but represent different business domains
double employeeBonus(Employee e)     { return e.salary()   * 0.05; } // HR policy
double contractorFee(Contractor c)   { return c.dailyRate() * 0.05; } // legal contract

// DO NOT merge — these rules evolve independently // Extracting a shared "5% of base" function couples unrelated domains ```

Also, when two similar code paths are in different bounded contexts, duplication is often better than coupling across contexts. This is why microservices sometimes deliberately duplicate validation logic.

KISS — Keep It Simple, Stupid

The Core Idea

KISS is attributed to the US Navy in the 1960s. In software: the simplest design that correctly and completely solves the current problem is the best design. Simplicity is a virtue, not a shortcut.

Example 1: Replacing Framework with Streams

```java
// VIOLATION: custom specification framework for a two-condition filter
List<Order> result = orderRepository.findAll(
    Specification.where(hasStatus(PENDING)).and(olderThan(30, DAYS)));
// ... requires 3 custom Specification classes

// KISS: Spring Data method name query List<Order> findByStatusAndCreatedAtBefore(OrderStatus status, Instant cutoff); ```

One method name, zero custom classes.

Example 2: Simple State Management

```java
// VIOLATION: state machine framework for a two-state toggle
StateMachine<UserState, UserEvent> machine = stateMachineFactory.getStateMachine();
machine.start();
machine.sendEvent(UserEvent.ACTIVATE);

// KISS: a boolean public class User { private boolean active; public void activate() { this.active = true; } public void deactivate() { this.active = false; } public boolean isActive(){ return active; } } ```

Use a state machine when you have 5+ states and guards. Not for two.

Example 3: Configuration Over Code

```java
// VIOLATION: custom BeanPostProcessor to swap implementations
public class FeatureFlagBeanPostProcessor implements BeanPostProcessor {
  @Override
  public Object postProcessAfterInitialization(Object bean, String name) {
    if (bean instanceof PricingService && featureFlags.isEnabled("new-pricing")) {
      return applicationContext.getBean(NewPricingService.class);
    }
    return bean;
  }
}

// KISS: use Spring's built-in profiles or @ConditionalOnProperty @Service @ConditionalOnProperty("feature.new-pricing") public class NewPricingService implements PricingService { /* ... */ } ```

Anti-Patterns KISS Prevents

  • Introducing a message bus for in-process communication between two classes.
  • Creating a plugin architecture for a codebase with two modules.
  • Generic "framework" classes with parameterized types for a single use case.
  • Configuration YAML with 30 keys where 5 would do.

When KISS Conflicts With Other Principles

KISS can conflict with DIP: "just use new — it's simpler". The answer is that testability and changeability are also simplicity, measured over time. A class that requires a full database for testing is *not* simple to work with, even if the source code looks short.

YAGNI — You Aren't Gonna Need It

The Core Idea

YAGNI comes from Extreme Programming (Kent Beck). Don't add functionality until it is actually needed. The cost of building a feature that isn't needed includes: implementation time, testing time, documentation, maintenance forever, cognitive load for every developer who reads the code. Most speculative features are never used.

Example 1: Plugin Architecture

```java
// YAGNI VIOLATION: plugin system added "in case someone needs custom exporters"
public interface ExportPlugin { byte[] export(ReportData data); String format(); }
public class PluginRegistry { /* loads plugins from classpath */ }
public class PluginLoader   { /* scans for META-INF/services entries */ }

// Current reality: only PDF export is needed public class ReportExporter { public byte[] exportPdf(ReportData data) { /* Apache PDFBox */ return new byte[0]; } } ```

When the second format is actually requested, refactor then. Not speculatively.

Example 2: Premature Caching

```java
// YAGNI VIOLATION: Redis cache added before any performance problem is measured
@Cacheable("users")
public User findById(long id) { /* ... */ }
// Now you maintain Redis in dev, integration tests, and prod for a call that takes 3ms

// FIX: add caching when profiling shows it's necessary public User findById(long id) { return repository.findById(id).orElseThrow(); } ```

Example 3: Generic Repository

```java
// YAGNI VIOLATION: generic query DSL for simple CRUD
public class GenericRepository<T, ID> {
  public List<T> findByCriteria(Criteria<T>... criteria) { /* complex */ return null; }
  public Page<T> paginate(Query<T> q, Pageable p) { /* complex */ return null; }
  // ... 200 more lines
}

// FIX: Spring Data JPA already does this public interface OrderRepository extends JpaRepository<Order, Long> { List<Order> findByStatus(OrderStatus status); } ```

The Tension Between YAGNI and Design Quality

YAGNI is not an excuse to write untestable code. It's about *features and premature abstractions*, not about skipping interfaces at architectural seams. You should still apply DIP at the domain boundary — that's architectural clarity, not speculation.

The Three Together: Real-World Scenario

You're building an order export feature. A product manager says: "We need PDF export. Oh, and later we'll probably need CSV and maybe Excel."

DRY: Export format ("PDF") should be in one place, not hardcoded in three controllers.

YAGNI: Build PDF. Write the interface with one implementation. Add CSV when it's on the sprint.

KISS: Don't build a plugin registry for two export formats. A Map<String, Exporter> with two entries is sufficient.

```java
public interface ReportExporter { byte[] export(ReportData data); String format(); }

@Component public class PdfReportExporter implements ReportExporter { public byte[] export(ReportData data) { /* iText */ return new byte[0]; } public String format() { return "PDF"; } }

@Service public class ReportService { private final Map<String, ReportExporter> exporters; public ReportService(List<ReportExporter> all) { this.exporters = all.stream().collect(Collectors.toMap(ReportExporter::format, e -> e)); } public byte[] export(ReportData data, String format) { return Optional.ofNullable(exporters.get(format)) .orElseThrow(() -> new IllegalArgumentException("Format not supported: " + format)) .export(data); } } ```

The interface is DRY (one source of truth per format). YAGNI (only PDF exists today). KISS (a Map, not a plugin registry).

Production Best Practices

  • In code review: challenge every new abstraction with "what's the second use case?"
  • Add a // YAGNI note: add caching here if p99 > 50ms comment instead of adding the cache.
  • Use SonarQube to detect code duplication (DRY violations).
  • Track cyclomatic complexity to catch KISS violations early.

FAQ

Q: YAGNI says don't do things until needed. But what about forward compatibility? Forward compatibility at the data format level (versioned APIs, schema evolution) is a real need. YAGNI applies to application code features, not protocol design.

Q: Is DRY only about code? What about documentation? DRY applies to any knowledge artifact: code, tests, configuration, documentation. A README that duplicates information from the code will drift out of sync.

Q: Can KISS be used to avoid writing tests? No. Tests reduce long-term complexity. "Simple" code without tests is a debt bomb, not simplicity.

Related Tutorials

Coupling vs Cohesion Explained with Spring Boot Examples

Introduction

Every design principle in software engineering is ultimately trying to achieve two goals: high cohesion and low coupling. They are the north star of code quality. SOLID, DRY, SoC, and composition over inheritance all point in their direction. Understanding them deeply is what separates engineers who write code that ages well from those who write code that rots.

Cohesion measures how related the things inside a module are. Coupling measures how dependent a module is on other modules. The ideal is a system of highly cohesive, loosely coupled components — each one does one thing well and knows as little as possible about the others.

Key Takeaways

  • High cohesion: everything in a class/module serves the same purpose.
  • Low coupling: a change here rarely forces a change there.
  • Afferent coupling (Ca): how many things depend on this module.
  • Efferent coupling (Ce): how many things this module depends on.
  • Tight coupling is the root cause of most refactoring nightmares.
  • ArchUnit can enforce coupling rules as part of CI.

Cohesion in Practice

High cohesion means every method and field in a class is directly related to the class's single purpose.

```java
// HIGH COHESION: every method is about calculating invoice amounts
public class InvoiceCalculator {
  private final double taxRate;

public InvoiceCalculator(@Value("${tax.rate}") double taxRate) { this.taxRate = taxRate; }

public double subtotal(Invoice invoice) { return invoice.items().stream().mapToDouble(Item::total).sum(); }

public double tax(Invoice invoice) { return subtotal(invoice) * taxRate; }

public double grandTotal(Invoice invoice) { return subtotal(invoice) + tax(invoice); } } ```

All three methods work on invoices. All use the same field. Change the invoice domain → change this class. Change SMTP → don't touch this class.

java
// LOW COHESION: random mix of concerns
public class UtilityManager {
  public String formatDate(LocalDate date)     { /* date formatting */ return null; }
  public double calculateTax(double amount)    { /* tax logic */ return 0; }
  public void sendEmail(String to, String msg) { /* SMTP */ }
  public User parseUserJson(String json)       { /* JSON parsing */ return null; }
}

No relation between methods. Every team touches this class. It grows infinitely.

Types of Coupling — Worst to Best

1. Content Coupling (Worst)

Module A directly modifies the internal state of module B.

java
// A reaches into B's private internals via reflection or direct field access
Field field = OrderService.class.getDeclaredField("internalCounter");
field.setAccessible(true);
field.set(orderService, 42); // Never do this

Any refactoring of B's internals silently breaks A.

2. Common Coupling

Multiple modules share mutable global state.

```java
// VIOLATION: static mutable state shared across the app
public class AppContext {
  public static User currentUser;
  public static String tenantId;
}

// Module A sets it AppContext.currentUser = user;

// Module B reads it — now A and B are coupled through shared state if (AppContext.currentUser.hasRole("ADMIN")) { /* ... */ } ```

Use thread-local security context (Spring Security's SecurityContextHolder) or pass context explicitly.

3. Control Coupling

A passes a flag to B telling B what to do.

```java
// VIOLATION: the boolean flag controls internal behavior of the callee
void sendNotification(User user, boolean urgent) {
  if (urgent) sendSms(user);
  else        sendEmail(user);
}

// FIX: split the method (or use a strategy) void sendUrgentNotification(User user) { sendSms(user); } void sendNormalNotification(User user) { sendEmail(user); } ```

4. Stamp Coupling

A passes a large object to B, but B only uses one field.

```java
// VIOLATION: entire Order passed when only the total is needed
double calculateTax(Order order) {
  return order.getTotal() * 0.20; // only uses one field
}

// FIX: pass only what's needed double calculateTax(double total) { return total * 0.20; } ```

This also simplifies testing — no need to construct an entire Order to test tax calculation.

5. Data Coupling (Good)

Modules share simple, primitive data through method parameters.

java
double total  = calculator.calculateTotal(cart);
double fee    = feeService.calculate(paymentType, total);
Order  order  = repository.save(new Order(userId, total + fee));

Each method receives exactly what it needs. No shared state.

6. No Coupling (Ideal, Rare)

Modules are completely independent. Achievable for utility classes that are purely functional.

java
public class DateUtils {
  public static boolean isWeekend(LocalDate date) {
    return date.getDayOfWeek() == DayOfWeek.SATURDAY
        || date.getDayOfWeek() == DayOfWeek.SUNDAY;
  }
}

Measuring Coupling: Afferent and Efferent

  • Afferent coupling (Ca): number of classes/modules that depend *on* this module. High Ca = many dependents = risky to change.
  • Efferent coupling (Ce): number of classes/modules this module depends *on*. High Ce = depends on many = fragile.
  • Instability: I = Ce / (Ca + Ce). 0 = maximally stable (many depend on it, it depends on nothing). 1 = maximally unstable.

Your domain core should have near-zero instability (Ca high, Ce low — things depend on it, it depends on nothing). Infrastructure adapters have I near 1 (nothing depends on them, they depend on many things).

Spring Boot Examples of Each Coupling Type

Tight Coupling in a Controller (Bad)

```java
@RestController
public class OrderController {
  // Direct field access, no interface
  @Autowired
  private JpaOrderRepository orderRepository; // infrastructure detail

@PostMapping("/orders") public void place(@RequestBody Order order) { // Business logic in controller — SRP + SoC violation order.setTotal(order.getItems().stream().mapToDouble(i -> i.getPrice() * i.getQty()).sum()); orderRepository.save(order); // inline SMTP send... } } ```

Loosely Coupled (Good)

```java
@RestController
@RequiredArgsConstructor
public class OrderController {
  private final OrderService service; // interface, injected

@PostMapping("/orders") public ResponseEntity<OrderDto> place(@RequestBody @Valid OrderRequest req) { Order order = service.place(req.toOrder()); return ResponseEntity.created(URI.create("/orders/" + order.getId())) .body(OrderDto.from(order)); } } ```

Reducing Coupling in Spring Boot

Use ApplicationEventPublisher for Cross-Module Notifications

```java
// WITHOUT events: OrderService knows about InventoryService and NotificationService
@Service
public class OrderService {
  private final InventoryService inventory;     // coupling
  private final NotificationService notifier;   // coupling

public Order place(Order order) { Order saved = repository.save(order); inventory.reserve(order); // coupled notifier.confirmPlacement(saved); // coupled return saved; } }

// WITH events: OrderService knows only about OrderRepository @Service public class OrderService { private final OrderRepository repository; private final ApplicationEventPublisher events;

public Order place(Order order) { Order saved = repository.save(order); events.publishEvent(new OrderPlacedEvent(saved)); // decoupled return saved; } }

@Component class InventoryListener { @EventListener void on(OrderPlacedEvent e) { /* reserve inventory */ } }

@Component class NotificationListener { @EventListener void on(OrderPlacedEvent e) { /* confirm to customer */ } } ```

Adding a new reaction to order placement (e.g., fraud check) is one new @EventListener. OrderService never changes.

Use Interfaces at Module Boundaries

```java
// users module exposes only an interface
public interface UserLookup { Optional<User> findById(long id); }

// orders module depends on the interface, not the users module's implementation @Service public class OrderService { private final UserLookup users; // no import from users.impl.* } ```

Module-Level Cohesion

Cohesion applies at module level too. A package or module is highly cohesive when all its classes collaborate to serve one business capability.

```
// HIGH MODULE COHESION: feature-oriented packages
com.company.orders/
  Order.java
  OrderItem.java
  OrderRepository.java
  OrderService.java
  OrderController.java
  OrderDto.java

// LOW MODULE COHESION: layer-oriented packages (all services in one package) com.company.services/ OrderService.java UserService.java InventoryService.java PaymentService.java CampaignService.java ReportService.java ```

With layer packages, a change to the order domain touches the same package as payment, user, and campaign. With feature packages, an order change is contained to the orders package.

Enforcing Coupling Rules with ArchUnit

```java
@AnalyzeClasses(packages = "com.company")
public class ArchitectureTest {

@ArchTest static final ArchRule domainDoesNotDependOnInfrastructure = noClasses().that().resideInPackage("..domain..").should() .dependOnClassesThat().resideInPackage("..infrastructure..");

@ArchTest static final ArchRule controllersDontDependOnRepositories = noClasses().that().resideInPackage("..controller..").should() .dependOnClassesThat().resideInPackage("..repository..");

@ArchTest static final ArchRule servicesAreAnnotated = classes().that().resideInPackage("..service..").should() .beAnnotatedWith(Service.class); } ```

Run this in CI. Coupling violations are caught before they merge.

Quick Smell Check

| Symptom | Likely cause | |---|---| | A bug in module A requires reading module C | Common or content coupling | | A feature change touches 8 files in 4 packages | Low cohesion, high coupling | | Tests for one class require 5+ mocked collaborators | Stamp or control coupling | | Adding a new feature breaks unrelated tests | Common coupling via shared state | | One class is imported by 40 other classes | High Ca — risky to change |

Production Best Practices

  • Measure instability per package with tools like JDepend or SonarQube.
  • Enforce coupling rules with ArchUnit in your CI pipeline.
  • Use package-by-feature, not package-by-layer.
  • Prefer ApplicationEventPublisher over direct service-to-service calls within the same application.
  • Make domain entities independent of persistence annotations where possible (clean architecture).

FAQ

Q: High coupling in tests is okay, right? Test coupling to the class under test is fine. But if changing a domain class forces changes in 20 test files, that's a coupling smell in the tests too.

Q: Is Spring's @Autowired coupling? Constructor injection (@Autowired on constructor) is DIP-compliant coupling to an interface — that's good. Field injection is coupling to the Spring container itself, which makes classes harder to test outside Spring.

Q: How do I measure cohesion objectively? Lack of Cohesion in Methods (LCOM) is a formal metric. Practically: if you can extract two independent subsets of methods and fields from a class with no cross-references between them, cohesion is low.

Related Tutorials

Composition Over Inheritance in Java

Introduction

"Favor composition over inheritance" is item 18 in *Effective Java*. Joshua Bloch puts it plainly: inheritance is powerful but fragile. Composition is flexible and robust. Most uses of inheritance in production codebases are actually better served by composition — and the refactoring is always worth it.

Inheritance tightly couples child to parent. When the parent changes, every child must be re-evaluated. When you extend a class you don't own, a future framework update can silently break your subclass. When you build a hierarchy to share code, you create an is-a relationship that may not actually be true.

Composition says: instead of *being* something, an object *has* something. Instead of extending Vehicle to get startEngine(), your Car holds an Engine field. Now you can swap the engine without changing the car.

Key Takeaways

  • Inheritance creates tight compile-time coupling; composition is flexible at runtime.
  • The is-a test: only inherit when the subclass truly *is* the parent type, always.
  • Deep hierarchies are hard to follow and break in surprising ways.
  • Design patterns (Strategy, Decorator, Delegation) are all composition patterns.
  • Composition is easier to test because you inject collaborators.
  • Inheritance is still correct for sealed hierarchies and true is-a relationships.

The Classic Problem: Deep Hierarchy

java
class Animal   { void breathe() {} }
class Bird extends Animal { void fly() {} }
class Penguin extends Bird {
  @Override void fly() {
    throw new UnsupportedOperationException("Penguins can't fly"); // LSP violation!
  }
}

Penguins are birds biologically, but they violate the behavioral contract of Bird.fly(). The hierarchy forced an LSP violation.

Composition Fix

```java
interface FlyBehavior  { void fly(); }
interface SwimBehavior { void swim(); }
interface RunBehavior  { void run(); }

class WingFlight implements FlyBehavior { public void fly() { /* flap wings */ } } class CannotFly implements FlyBehavior { public void fly() { /* no-op or throw */ } } class PaddleSwim implements SwimBehavior { public void swim() { /* paddle */ } }

class Sparrow { private final FlyBehavior fly; private final RunBehavior run; Sparrow() { this.fly = new WingFlight(); this.run = new BipedRun(); } void takeOff() { fly.fly(); } }

class Penguin { private final SwimBehavior swim; private final RunBehavior run; // No fly behavior at all — never forced to lie about it Penguin() { this.swim = new PaddleSwim(); this.run = new WaddleRun(); } } ```

Each bird composes only the behaviors it actually has. No more throwing exceptions in overridden methods.

Strategy Pattern: Composition in Action

The Strategy pattern is composition with a swappable collaborator:

```java
public interface ShippingStrategy {
  double cost(Order order);
  String name();
}

@Component public class StandardShipping implements ShippingStrategy { public double cost(Order order) { return order.weight() * 0.5; } public String name() { return "STANDARD"; } }

@Component public class ExpressShipping implements ShippingStrategy { public double cost(Order order) { return order.weight() * 1.5 + 10.0; } public String name() { return "EXPRESS"; } }

@Service public class ShippingService { private final Map<String, ShippingStrategy> strategies; public ShippingService(List<ShippingStrategy> all) { this.strategies = all.stream().collect(Collectors.toMap(ShippingStrategy::name, s -> s)); } public double cost(Order order, String mode) { return strategies.getOrDefault(mode, strategies.get("STANDARD")).cost(order); } } ```

Decorator Pattern: Stackable Composition

Decorators add behavior by composing around an existing object:

```java
public interface OrderRepository {
  Order save(Order order);
  Optional<Order> findById(long id);
}

@Repository public class JpaOrderRepository implements OrderRepository { /* standard JPA */ }

// Adds logging without touching JpaOrderRepository public class LoggingOrderRepository implements OrderRepository { private final OrderRepository delegate; private final Logger log = LoggerFactory.getLogger(getClass());

public LoggingOrderRepository(OrderRepository delegate) { this.delegate = delegate; }

@Override public Order save(Order order) { log.info("Saving order for customer {}", order.getCustomerId()); Order saved = delegate.save(order); log.info("Saved order {}", saved.getId()); return saved; }

@Override public Optional<Order> findById(long id) { log.debug("Finding order {}", id); return delegate.findById(id); } }

// Adds caching on top of logging on top of JPA public class CachingOrderRepository implements OrderRepository { private final OrderRepository delegate; private final Map<Long, Order> cache = new ConcurrentHashMap<>();

public CachingOrderRepository(OrderRepository delegate) { this.delegate = delegate; }

@Override public Order save(Order order) { Order saved = delegate.save(order); cache.put(saved.getId(), saved); return saved; }

@Override public Optional<Order> findById(long id) { return Optional.ofNullable(cache.computeIfAbsent(id, k -> delegate.findById(k).orElse(null))); } } ```

Stack them in @Configuration:

java
@Bean
public OrderRepository orderRepository(JpaOrderRepository jpa) {
  return new CachingOrderRepository(new LoggingOrderRepository(jpa));
}

Delegation Pattern: Full Before/After

Delegation is the manual form of composition, where an object forwards calls to a collaborator:

```java
// BEFORE (inheritance)
public class AuditedUserService extends UserService {
  @Override
  public User register(RegisterRequest req) {
    User user = super.register(req); // fragile: depends on parent internals
    auditLog.record("User registered: " + user.getId());
    return user;
  }
}

// AFTER (delegation/composition) public class AuditedUserService implements UserRegistration { private final UserRegistration delegate; private final AuditLog auditLog;

public AuditedUserService(UserRegistration delegate, AuditLog auditLog) { this.delegate = delegate; this.auditLog = auditLog; }

@Override public User register(RegisterRequest req) { User user = delegate.register(req); auditLog.record("User registered: " + user.getId()); return user; } } ```

The delegation version doesn't depend on parent internals. Changing UserService doesn't risk breaking AuditedUserService.

HTTP Client with Composed Behaviors

```java
public interface HttpClient {
  HttpResponse send(HttpRequest request);
}

@Component public class BaseHttpClient implements HttpClient { /* actual HTTP calls */ }

@Component public class RetryingHttpClient implements HttpClient { private final HttpClient delegate; private final int maxAttempts;

@Override public HttpResponse send(HttpRequest request) { int attempt = 0; while (true) { try { return delegate.send(request); } catch (TransientException e) { if (++attempt >= maxAttempts) throw e; try { Thread.sleep(200L * attempt); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); throw e; } } } } }

@Component public class MeteredHttpClient implements HttpClient { private final HttpClient delegate; private final MeterRegistry meters;

@Override public HttpResponse send(HttpRequest request) { Timer.Sample sample = Timer.start(meters); try { HttpResponse resp = delegate.send(request); sample.stop(meters.timer("http.client", "status", String.valueOf(resp.statusCode()))); return resp; } catch (Exception e) { sample.stop(meters.timer("http.client", "status", "error")); throw e; } } } ```

When Inheritance IS Right

Composition is usually better, but inheritance has legitimate uses:

1. True is-a relationships in sealed hierarchies: SqlException extends RuntimeException. SQL exceptions *are* runtime exceptions, always, with no exceptions.

2. Framework extension points: AbstractAuthenticationProcessingFilter in Spring Security is designed for inheritance. The framework provides the scaffold; you fill in one method.

3. Template Method pattern: a base class defines the algorithm skeleton, subclasses fill in one or two steps:

```java
public abstract class ReportGenerator {
  // Template method
  public final String generate(Data data) {
    String body    = buildBody(data);    // subclass provides
    String header  = buildHeader();      // common
    String footer  = buildFooter();      // common
    return header + "\n" + body + "\n" + footer;
  }

protected abstract String buildBody(Data data); protected String buildHeader() { return "=== Report ==="; } protected String buildFooter() { return "=== End ==="; } }

public class SalesReport extends ReportGenerator { @Override protected String buildBody(Data data) { /* sales-specific rendering */ return ""; } } ```

Testing Benefits of Composition

```java
// Testing the strategy independently
@Test
void standardShippingCostIsWeightTimesHalf() {
  assertThat(new StandardShipping().cost(orderOf(10.0))).isEqualTo(5.0);
}

// Testing the service by injecting a test double @Test void shippingServiceRoutesToCorrectStrategy() { ShippingStrategy mock = mock(ShippingStrategy.class); when(mock.name()).thenReturn("TEST"); when(mock.cost(any())).thenReturn(7.5);

var svc = new ShippingService(List.of(mock)); assertThat(svc.cost(someOrder(), "TEST")).isEqualTo(7.5); } ```

Strategies are trivially testable. No Spring context. No database.

Production Best Practices

  • Before writing extends, ask: "could a field achieve the same behavior?"
  • Use Lombok's @Delegate to reduce delegation boilerplate.
  • When using Spring AOP for cross-cutting concerns (logging, metrics, caching), it's using composition under the hood.
  • Document why a class uses inheritance when it does — it's now the exception, not the rule.

FAQ

Q: Doesn't composition cause more classes? Yes. Fewer classes per file doesn't make code easier to understand. Small, focused classes with clear names are easier to navigate than a 500-line hierarchy.

Q: Spring uses inheritance in many places. Why? Spring provides base classes as framework extension points. These are designed inheritance contracts with stable APIs. Your application code is not a framework — it doesn't need those guarantees.

Q: Is Lombok @Delegate composition or inheritance? Composition. @Delegate generates forwarding methods on a field. It's syntactic sugar for the delegation pattern.

Related Tutorials

Common Object-Oriented Design Mistakes (and How to Fix Them)

Introduction

Every codebase accumulates design mistakes. Most of them are not signs of incompetent developers — they're signs of developers who learned incrementally, added features under deadline pressure, or simply didn't yet have the vocabulary to name what was wrong. The good news is that these mistakes follow predictable patterns, they have names, and they have well-understood fixes.

This article covers eight of the most common OO design mistakes found in Java and Spring Boot applications, each with a before/after comparison, a detection guide, and an explanation of how fixing it improves testability.

Key Takeaways

  • God classes are the most common smell; split by responsibility.
  • Anemic domain models move logic out of where it belongs.
  • Boolean parameters in method signatures are a sign of two responsibilities.
  • Primitive obsession creates invisible bugs from valid-but-wrong values.
  • Exposing mutable collections violates encapsulation.
  • Static utility everything makes code untestable and unconfigurable.
  • Catching Exception blindly hides real bugs.
  • Deep inheritance hierarchies should be replaced with composition.

1. God Classes

A God class does everything. It has 50+ methods, imports from every layer, and every developer on the team has merged conflicts with it in the last month.

Detection

  • The class has >300 lines.
  • git log --oneline src/.../OrderService.java shows 50+ commits from 8 different authors.
  • The class has imports from javax.mail, java.sql, org.springframework.web, and your domain — all in one file.

Before

java
@Service
public class OrderService {
  void place(Cart cart)               { /* 40 lines */ }
  void cancel(long orderId)           { /* 30 lines */ }
  double calculateTax(double amount)  { /* 10 lines */ }
  void sendConfirmation(Order o)      { /* 20 lines, inline SMTP */ }
  boolean checkInventory(Cart cart)   { /* 25 lines, inline JDBC */ }
  List<Order> report(DateRange range) { /* 35 lines, inline JPQL */ }
  void applyDiscount(Order o, String code) { /* 15 lines */ }
  // ... 200 more lines
}

After

java
@Service public class OrderPlacementService   { /* place */ }
@Service public class OrderCancellationService { /* cancel */ }
@Component public class TaxCalculator         { /* calculateTax */ }
@Component public class OrderNotifier         { /* sendConfirmation */ }
@Repository public class InventoryRepository  { /* checkInventory */ }
@Repository public class OrderReportRepository{ /* report queries */ }
@Component public class DiscountApplicator    { /* applyDiscount */ }

Testing Improvement

Each extracted class needs 2-5 test methods and zero mocks for its internal logic. The original OrderService test needed 7 mocks and touched 30 code paths.

2. Anemic Domain Model

An anemic domain model has entities with only getters and setters, and service classes that contain all the business logic. This inverts object-orientation: objects should encapsulate both data *and* behavior.

Before (Anemic)

```java
// Entity: data bag, no behavior
@Entity
public class Order {
  private Long id;
  private List<OrderItem> items = new ArrayList<>();
  private OrderStatus status;
  private double total;
  // Only getters and setters
}

// Service: all business logic @Service public class OrderService { public void addItem(Order order, OrderItem item) { if (order.getStatus() != OrderStatus.OPEN) throw new IllegalStateException("Order is not open"); order.getItems().add(item); order.setTotal(order.getTotal() + item.getPrice() * item.getQty()); }

public void submit(Order order) { if (order.getItems().isEmpty()) throw new IllegalStateException("Cannot submit empty order"); order.setStatus(OrderStatus.SUBMITTED); } } ```

After (Rich Domain Model)

```java
@Entity
public class Order {
  @Id private Long id;
  @OneToMany(cascade = CascadeType.ALL)
  private List<OrderItem> items = new ArrayList<>();
  private OrderStatus status = OrderStatus.OPEN;

public void addItem(OrderItem item) { if (status != OrderStatus.OPEN) throw new IllegalStateException("Order is not open"); items.add(item); }

public void submit() { if (items.isEmpty()) throw new IllegalStateException("Cannot submit empty order"); this.status = OrderStatus.SUBMITTED; }

public double total() { return items.stream().mapToDouble(i -> i.price() * i.qty()).sum(); }

public List<OrderItem> items() { return Collections.unmodifiableList(items); // see mistake #6 } } ```

The business rule "can't add items to a submitted order" lives on the Order entity. It can't be bypassed by any service — the rule travels with the data.

3. Boolean Parameters in Method Signatures

A boolean parameter in a method signature is almost always a sign that the method has two responsibilities.

Detection

Any call like sendEmail(user, true, false, true) is impossible to understand at the call site.

Before

```java
void send(User user, boolean urgent, boolean withAttachment) {
  if (urgent) setHighPriority();
  if (withAttachment) attachReport();
  // ...
}

// Call site — unreadable send(user, true, false); ```

After — Option A: Split Methods

java
void sendNormal(User user)             { /* ... */ }
void sendUrgent(User user)             { /* ... */ }
void sendWithAttachment(User user)     { /* ... */ }
void sendUrgentWithAttachment(User user){ /* ... */ }

After — Option B: Builder/Options Object

```java
@Value
@Builder
public class SendOptions {
  boolean urgent;
  boolean withAttachment;
}

void send(User user, SendOptions options) { /* clear field access */ }

// Call site — readable send(user, SendOptions.builder().urgent(true).build()); ```

After — Option C: Enum

java
public enum Priority { NORMAL, URGENT }
void send(User user, Priority priority) { /* ... */ }
send(user, Priority.URGENT); // self-documenting

4. Primitive Obsession

Using raw primitives (String, long, int) for domain concepts creates invisible bugs where valid values are semantically wrong.

Before

```java
void transferMoney(long fromAccountId, long toAccountId, double amount) { /* ... */ }

// At the call site — these two are trivially swapped, no compiler error transferMoney(toAccount, fromAccount, amount); // SILENT BUG ```

After — Tiny Value Objects

```java
public record AccountId(long value) {
  public AccountId { if (value <= 0) throw new IllegalArgumentException("Invalid account id"); }
}

public record Money(double amount, Currency currency) { public Money { if (amount < 0) throw new IllegalArgumentException("Amount must be >= 0"); } }

void transferMoney(AccountId from, AccountId to, Money amount) { /* ... */ }

// Now this is a compile error: transferMoney(toAccount, fromAccount, amount); // AccountId is AccountId, but semantics matter ```

Java records make this pattern essentially free to add.

```java
public record EmailAddress(String value) {
  public EmailAddress {
    if (value == null || !value.contains("@"))
      throw new IllegalArgumentException("Invalid email: " + value);
  }
}

public record UserId(long value) {} public record OrderId(long value) {} ```

Passing an OrderId where a UserId is expected is now a compile error.

5. Exposing Internal Collections

```java
// VIOLATION: returns the mutable internal list
@Entity
public class Order {
  private List<OrderItem> items = new ArrayList<>();

public List<OrderItem> getItems() { return items; // caller can items.add(), items.clear(), items.sort()... } }

// Caller bypasses all business rules: order.getItems().clear(); // emptied without triggering any Order business logic ```

Fix

```java
public List<OrderItem> items() {
  return Collections.unmodifiableList(items);
}

// Or, return a defensive copy: public List<OrderItem> items() { return List.copyOf(items); } ```

All mutations go through domain methods that enforce invariants.

6. Static Everything

```java
// VIOLATION: static utility class
public class TaxUtils {
  public static double calculate(double amount) { return amount * 0.20; }
  public static double calculateForEU(double amount) { return amount * 0.21; }
}

// Problems: // - Can't inject a different rate via @Value // - Can't swap for a different TaxCalculator in tests // - Can't configure per-tenant or per-country rates ```

Fix

```java
@Component
public class TaxCalculator {
  @Value("${tax.rate.default:0.20}") private double defaultRate;
  @Value("${tax.rate.eu:0.21}")      private double euRate;

public double calculate(double amount) { return amount * defaultRate; } public double calculateForEU(double amount){ return amount * euRate; } } ```

Now it's injected, configurable, and replaceable with a MockTaxCalculator in tests.

7. Catching Exception to Be Safe

java
// VIOLATION: swallows all exceptions silently
void processOrder(Order order) {
  try {
    repository.save(order);
    notifier.send(order);
  } catch (Exception e) {
    // "Just to be safe"
    log.error("Error", e);
    // caller has no idea whether the order was saved or not
  }
}

This hides OutOfMemoryError, NullPointerException, database constraint violations — all classified as "error, moving on".

Fix

java
void processOrder(Order order) {
  try {
    repository.save(order);
  } catch (DataIntegrityViolationException e) {
    throw new DuplicateOrderException("Order already exists", e);
  }
  // Let SMTP exceptions propagate — the caller can retry
  notifier.send(order);
}

Catch specific, handle specifically. Let unexpected exceptions propagate to a global handler (@ControllerAdvice) that logs and returns a proper error response.

8. Deep Inheritance Hierarchies for Code Reuse

java
// VIOLATION: 4-level hierarchy to share two methods
class BaseEntity       { Long id; LocalDateTime createdAt; }
class AuditedEntity    extends BaseEntity { String createdBy; }
class SoftDeleteEntity extends AuditedEntity { boolean deleted; }
class VersionedEntity  extends SoftDeleteEntity { long version; }
class Order            extends VersionedEntity { /* actual order fields */ }

Order now inherits 6 fields and 10 lifecycle methods it may or may not actually want. Adding a behavior to AuditedEntity potentially breaks every subclass.

Fix via Interfaces + Embeddables

```java
@Embeddable public class AuditMetadata { String createdBy; LocalDateTime createdAt; }
@Embeddable public class SoftDeleteMeta { boolean deleted; }

@Entity public class Order { @Id @GeneratedValue private Long id; @Embedded private AuditMetadata audit; @Embedded private SoftDeleteMeta softDelete; @Version private long version; // actual order fields } ```

Or use Spring Data's @EntityListeners(AuditingEntityListener.class) with @CreatedBy, @CreatedDate for auditing — a cross-cutting concern handled by the framework without inheritance.

Step-by-Step Fix: Refactoring an Anemic Order Service

```java
// BEFORE
if (order.getStatus() == OrderStatus.PENDING
    && !order.getItems().isEmpty()
    && order.getItems().stream().mapToDouble(i -> i.getPrice() * i.getQty()).sum() > 0) {
  order.setStatus(OrderStatus.CONFIRMED);
}

// STEP 1: move the guard to the entity order.confirm(); // Order.confirm() does all the checking internally

// STEP 2: test at the entity level @Test void confirmsWhenValid() { Order o = new Order(); o.addItem(new OrderItem("Widget", 10.0, 2)); o.confirm(); assertThat(o.getStatus()).isEqualTo(OrderStatus.CONFIRMED); }

@Test void cannotConfirmEmptyOrder() { assertThatThrownBy(() -> new Order().confirm()) .isInstanceOf(IllegalStateException.class); } ```

Production Best Practices

  • Enable SonarQube or Checkstyle rules for cognitive complexity, class length, and method count.
  • Adopt Java records for value objects — they generate correct equals, hashCode, and toString for free.
  • Configure Spring Validator constraints (@NotNull, @Email, @Size) on request objects, but also in domain constructors.
  • Review every catch (Exception e) in a code review — they all need justification.

FAQ

Q: Isn't the anemic domain model just the classic MVC pattern? MVC describes layer separation for presentation. It doesn't say business logic belongs in services rather than entities. Rich domain models are the DDD recommendation; anemic models are a pragmatic shortcut that grows into a maintenance problem.

Q: Are value objects (records) worth the extra class? Almost always yes. A UserId(long) prevents misuse, validates on construction, and documents intent — for the cost of five lines of code.

Q: When is a static utility class acceptable? Pure functions with no external dependencies and no configuration needs: StringUtils.capitalize(), DateUtils.isWeekend(). These have no reason to be injected. The moment they need configuration (a rate, a host, a key), make them a Spring bean.

Related Tutorials

Where these principles genuinely conflict

DRY, KISS, YAGNI and 'high cohesion, low coupling' are usually presented as a friendly team, but they pull against each other in practice. DRY vs KISS: extracting a shared helper for two similar-looking functions often couples them semantically, and the next feature that only affects one of them has to fight the abstraction. Two duplications is usually the wrong point to abstract; three is closer to right. YAGNI vs OCP: you avoid building an extension point until you need it, then you need it and refactoring costs three days. The judgement call is where changes are cheap vs where they are expensive; extension points earn their keep near external boundaries and rarely earn it deep inside a domain. Low coupling vs low latency: perfectly decoupled services communicate over the network, and that costs milliseconds you may not have. A modular monolith is often the honest answer.

Go deeper

Further reading

#Design Principles#Clean Code#Architecture#DRY#KISS#YAGNI#Coupling#Cohesion#Design#Composition#Inheritance#Java#OOP#Code Smells#Refactoring

Stay in the Loop

Get the next tutorial in your inbox

Continue reading in Software Design & Architecture

Modular Monolith Architecture in Spring Boot — The Right Way to Scale a Monolith

Why modern teams are returning to modular monoliths — module boundaries, package-by-feature, internal events and a clean migration path to microservices in Spring Boot.

Related tutorials