Beginner
This beginner level covers Examples 1-28, reaching approximately 0-35% of software architecture fundamentals. Each example demonstrates a core architectural concept using object-oriented idioms with self-contained, runnable code. Java is the canonical language; Kotlin, C#, and TypeScript equivalents appear in the tabbed code blocks. These examples target developers who already know at least one language and want to rapidly build architectural instincts through working code.
Separation of Concerns
Example 1: No Separation vs. Clear Separation
Separation of concerns means grouping code by responsibility so that each module handles exactly one aspect of the system. When multiple responsibilities mix in one function, changing any part risks breaking the others.
Tightly coupled approach (no separation):
// => This method handles THREE distinct responsibilities at once:
// => 1. Data access (reading from a map)
// => 2. Business logic (computing discount)
// => 3. Presentation (formatting a string for display)
import java.util.Map;
public class CoupledExample {
public static String getUserDiscountMessage(int userId) {
// => userDb simulates a database lookup — data access concern
Map<Integer, Map<String, Object>> userDb = Map.of(
1, Map.of("name", "Alice", "purchases", 12)
);
// => user is {"name": "Alice", "purchases": 12}
Map<String, Object> user = userDb.get(userId);
// => Business rule embedded directly here — hard to change independently
double discount = ((int) user.get("purchases") > 10) ? 0.15 : 0.05;
// => discount is 0.15 (15%) because purchases > 10
// => Presentation formatted inline — impossible to reuse discount logic elsewhere
return String.format("Hello %s, your discount is %.0f%%", user.get("name"), discount * 100);
// => Output: "Hello Alice, your discount is 15%"
}
}Mixing all three responsibilities means any change — a new discount rule, a different greeting format, or a different data source — requires editing the same function.
Separated approach (three distinct layers):
// => DATA ACCESS — only knows how to retrieve users
import java.util.Map;
public class SeparatedExample {
private static final Map<Integer, Map<String, Object>> USER_DB =
Map.of(1, Map.of("name", "Alice", "purchases", 12));
static Map<String, Object> findUser(int userId) {
return USER_DB.get(userId); // => returns user map or null
}
// => BUSINESS LOGIC — only knows discount rules, not storage or display
static double calculateDiscount(int purchases) {
if (purchases > 10) return 0.15; // => 15% for loyal customers (>10 purchases)
return 0.05; // => 5% default discount
}
// => PRESENTATION — only knows how to format, not how to compute or fetch
static String formatDiscountMessage(String name, double discount) {
return String.format("Hello %s, your discount is %.0f%%", name, discount * 100);
// => Output: "Hello Alice, your discount is 15%"
}
// => ORCHESTRATION — thin coordinator that wires the three layers together
public static String getUserDiscountMessage(int userId) {
Map<String, Object> user = findUser(userId); // => delegates data access
double discount = calculateDiscount((int) user.get("purchases")); // => delegates business rule
return formatDiscountMessage((String) user.get("name"), discount); // => delegates formatting
}
public static void main(String[] args) {
System.out.println(getUserDiscountMessage(1));
// => Output: Hello Alice, your discount is 15%
}
}Each function now has one reason to change: swap the database without touching the discount rule, change the discount formula without touching the message format.
Key Takeaway: Separate each distinct responsibility into its own function or module. A function should have exactly one reason to change.
Why It Matters: In production systems, business rules change far more often than data storage technology, and display formats change more often than both. When these concerns are mixed, a simple business rule change forces a full regression test of the display layer. Separating concerns so each layer evolves independently is what enables high-frequency, safe deployment pipelines.
Example 2: Single Responsibility Principle
The Single Responsibility Principle (SRP) states that a class or module should have one and only one reason to change. Violating SRP creates fragile code where an unrelated change breaks a seemingly unrelated feature.
Violating SRP — one class does too much:
// => UserManager handles user data AND email sending AND password logic
// => This class has THREE reasons to change: user schema, email templates, auth rules
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
public class UserManager {
private final Map<Integer, Map<String, String>> users = new HashMap<>();
// => users stores id → { "name": ..., "email": ... }
public void addUser(int id, String name, String email) {
users.put(id, Map.of("name", name, "email", email));
// => stores user under id key
}
// => EMAIL CONCERN embedded in user class — mixing responsibilities
public void sendWelcomeEmail(int userId) {
Map<String, String> user = users.get(userId); // => retrieves user or null
if (user != null) {
System.out.println("Sending email to " + user.get("email") + ": Welcome, " + user.get("name") + "!");
// => Output: Sending email to alice@example.com: Welcome, Alice!
}
}
// => PASSWORD CONCERN also embedded — a third responsibility
public String resetPassword(int userId) {
String newPassword = UUID.randomUUID().toString().substring(0, 8);
// => newPassword is a random 8-char string like "a3f9c2b1"
System.out.println("Password reset for user " + userId + ": " + newPassword);
return newPassword; // => returns the new password string
}
}Applying SRP — one class, one responsibility:
// => RESPONSIBILITY 1: User data management only
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
class UserRepository {
private final Map<Integer, Map<String, String>> users = new HashMap<>();
public void add(int id, String name, String email) {
users.put(id, Map.of("name", name, "email", email));
// => stores user record under id key
}
public Map<String, String> get(int id) {
return users.get(id); // => returns user map or null
}
}
// => RESPONSIBILITY 2: Email notifications only
class EmailService {
public void sendWelcome(String name, String email) {
System.out.println("Sending email to " + email + ": Welcome, " + name + "!");
// => Output: Sending email to alice@example.com: Welcome, Alice!
}
}
// => RESPONSIBILITY 3: Password management only
class PasswordService {
public String reset(int userId) {
String newPassword = UUID.randomUUID().toString().substring(0, 8);
// => newPassword is a random 8-char string
System.out.println("Password reset for user " + userId + ": " + newPassword);
return newPassword; // => returns the generated password
}
}Key Takeaway: Each class should have exactly one reason to change. When you add email template logic, only EmailService changes. When you change password policy, only PasswordService changes.
Why It Matters: SRP is the foundational principle behind microservices: each service owns one business capability. Teams that own single-responsibility services deploy independently, reducing the coordination overhead that kills engineering velocity at scale. When a class has multiple reasons to change, every change carries the risk of breaking an unrelated concern within the same unit.
Layered Architecture
Example 3: Three-Layer Architecture
A layered architecture organizes code into a presentation layer (handles user interaction), a business logic layer (enforces rules), and a data access layer (manages persistence). Layers only communicate downward — presentation calls business logic, business logic calls data access, never the reverse.
graph TD
A["Presentation Layer<br/>(Controller / API handler)"]
B["Business Logic Layer<br/>(Service / Use case)"]
C["Data Access Layer<br/>(Repository / DAO)"]
A -->|calls| B
B -->|calls| C
C -->|returns data| B
B -->|returns result| A
style A fill:#0173B2,stroke:#000,color:#fff
style B fill:#DE8F05,stroke:#000,color:#fff
style C fill:#029E73,stroke:#000,color:#fff
// ============================================================
// DATA ACCESS LAYER — only knows about storage
// ============================================================
import java.util.Map;
import java.util.Optional;
record Product(int id, String name, double price, int stock) {}
class ProductRepository {
// => in-memory store simulating a database table
private final Map<Integer, Product> products = Map.of(
1, new Product(1, "Laptop", 1200.0, 5),
2, new Product(2, "Mouse", 25.0, 0)
);
Optional<Product> findById(int productId) {
return Optional.ofNullable(products.get(productId));
// => returns Optional.of(product) or Optional.empty()
}
}
// ============================================================
// BUSINESS LOGIC LAYER — only knows about rules
// ============================================================
class ProductService {
private final ProductRepository repo;
// => repo is injected, not created here (dependency injection)
ProductService(ProductRepository repo) { this.repo = repo; }
Product getAvailableProduct(int productId) {
Product product = repo.findById(productId)
.orElseThrow(() -> new IllegalArgumentException("Product " + productId + " not found"));
// => throws if not found — presentation layer will catch this
if (product.stock() == 0) {
throw new IllegalStateException("Product '" + product.name() + "' is out of stock");
// => business rule: zero stock means unavailable
}
return product; // => returns valid product
}
}
// ============================================================
// PRESENTATION LAYER — only knows about formatting responses
// ============================================================
class ProductController {
static String handleGetProduct(int productId) {
ProductRepository repo = new ProductRepository(); // => creates data layer
ProductService service = new ProductService(repo); // => creates business layer, injects repo
try {
Product p = service.getAvailableProduct(productId);
// => delegates all business logic to service
return String.format("Available: %s at $%.2f", p.name(), p.price());
// => Output (id=1): "Available: Laptop at $1200.00"
} catch (Exception e) {
return "Error: " + e.getMessage();
// => Output (id=2): "Error: Product 'Mouse' is out of stock"
}
}
public static void main(String[] args) {
System.out.println(handleGetProduct(1)); // => Available: Laptop at $1200.00
System.out.println(handleGetProduct(2)); // => Error: Product 'Mouse' is out of stock
}
}Key Takeaway: Each layer communicates only with the layer directly below it. Presentation never touches the database; data access never formats strings for users.
Why It Matters: Layered architecture is the default starting pattern for most enterprise systems (Spring MVC, Django, Rails all enforce it). It enables parallel development — a frontend team can build against an agreed service API while a backend team implements the business rules — and makes testing each layer independently straightforward.
Example 4: Presentation Layer Isolation
The presentation layer should translate raw input into domain calls and translate domain results into output format. It should contain no business logic and no data access code.
// => DATA LAYER — retrieves raw records
import java.util.Map;
import java.util.Optional;
record Order(int id, double total, String status) {}
class OrderRepository {
// => orderDb simulates a database table with two records
private static final Map<Integer, Order> ORDER_DB = Map.of(
101, new Order(101, 299.99, "shipped"),
102, new Order(102, 49.0, "pending")
);
static Optional<Order> findOrder(int id) {
return Optional.ofNullable(ORDER_DB.get(id));
// => returns Optional.of(order) or Optional.empty()
}
}
// => BUSINESS LAYER — applies domain rules
class OrderRules {
static boolean isEligibleForCancellation(Order order) {
return order.status().equals("pending") && order.total() < 500;
// => true only when pending AND total below cancellation threshold
}
}
// => PRESENTATION LAYER — translates, never decides
class OrderController {
static String handleCancelRequest(int orderId) {
Optional<Order> maybeOrder = OrderRepository.findOrder(orderId);
// => fetches from data layer
if (maybeOrder.isEmpty()) {
return "Order " + orderId + " not found";
// => presentation transforms absence to user-readable message
}
Order order = maybeOrder.get();
boolean eligible = OrderRules.isEligibleForCancellation(order);
// => business logic evaluated in business layer, result consumed here
if (eligible) {
return "Order " + orderId + " cancelled successfully";
// => Output (id=102): "Order 102 cancelled successfully"
}
return "Order " + orderId + " cannot be cancelled (status: " + order.status() + ")";
// => Output (id=101): "Order 101 cannot be cancelled (status: shipped)"
}
public static void main(String[] args) {
System.out.println(handleCancelRequest(101)); // => Order 101 cannot be cancelled (status: shipped)
System.out.println(handleCancelRequest(102)); // => Order 102 cancelled successfully
System.out.println(handleCancelRequest(999)); // => Order 999 not found
}
}Key Takeaway: The presentation layer transforms but never decides. All decisions live in the business layer where they can be tested without a UI or HTTP context.
Why It Matters: Teams that keep business logic out of controllers can test their entire rule set with fast in-memory unit tests. When the presentation layer grows — mobile app, CLI tool, REST API — the business layer requires zero modification. A single stable business layer serving multiple client types is only achievable when no presentation logic has leaked into it.
MVC Pattern
Example 5: Model-View-Controller Basics
MVC separates a program into a Model (data and rules), a View (formatting output), and a Controller (coordinating input and response). The Controller receives input, asks the Model to process it, then passes results to the View for display.
graph LR
User["User Input"]
C["Controller<br/>(coordinates)"]
M["Model<br/>(data + rules)"]
V["View<br/>(formats output)"]
User -->|request| C
C -->|queries / commands| M
M -->|data| C
C -->|data| V
V -->|response| User
style User fill:#CA9161,stroke:#000,color:#fff
style C fill:#0173B2,stroke:#000,color:#fff
style M fill:#DE8F05,stroke:#000,color:#fff
style V fill:#029E73,stroke:#000,color:#fff
// ============================================================
// MODEL — data container + business validation
// ============================================================
import java.util.ArrayList;
import java.util.List;
class TodoModel {
record TodoItem(int id, String title, boolean done) {
TodoItem withDone() { return new TodoItem(id, title, true); }
// => creates a new record with done=true (records are immutable)
}
private final List<TodoItem> items = new ArrayList<>();
// => internal list of todo items
private int nextId = 1;
// => auto-incrementing id counter
TodoItem add(String title) {
TodoItem item = new TodoItem(nextId++, title, false);
// => item is TodoItem(id=1, title="Buy milk", done=false)
items.add(item); // => appended to internal list
return item; // => returns the created item
}
List<TodoItem> getAll() {
return List.copyOf(items); // => returns unmodifiable copy to prevent mutation
}
boolean complete(int itemId) {
for (int i = 0; i < items.size(); i++) {
if (items.get(i).id() == itemId) {
items.set(i, items.get(i).withDone()); // => replaces with done=true copy
return true; // => returns true = success
}
}
return false; // => returns false = item not found
}
}
// ============================================================
// VIEW — formats data for display, no logic
// ============================================================
class TodoView {
String renderList(List<TodoModel.TodoItem> items) {
if (items.isEmpty()) return "No todos yet.";
// => Output when empty list
StringBuilder sb = new StringBuilder();
for (TodoModel.TodoItem item : items) {
String status = item.done() ? "✓" : "○";
// => status is "✓" for done items, "○" for pending
sb.append("[").append(status).append("] ")
.append(item.id()).append(". ").append(item.title()).append("\n");
}
return sb.toString().trim(); // => joined with newlines
}
String renderCreated(TodoModel.TodoItem item) {
return "Created todo #" + item.id() + ": " + item.title();
// => Output: "Created todo #1: Buy milk"
}
}
// ============================================================
// CONTROLLER — coordinates model and view
// ============================================================
class TodoController {
private final TodoModel model; // => stores model reference
private final TodoView view; // => stores view reference
TodoController(TodoModel model, TodoView view) {
this.model = model;
this.view = view;
}
String create(String title) {
TodoModel.TodoItem item = model.add(title); // => delegates creation to model
return view.renderCreated(item); // => delegates formatting to view
}
String listAll() {
return view.renderList(model.getAll());
// => fetches all items from model, delegates rendering to view
}
String done(int itemId) {
boolean success = model.complete(itemId); // => delegates completion to model
return success ? "Todo #" + itemId + " marked as done"
: "Todo #" + itemId + " not found";
}
public static void main(String[] args) {
// => Wire the MVC triad together
TodoModel model = new TodoModel();
TodoView view = new TodoView();
TodoController controller = new TodoController(model, view);
System.out.println(controller.create("Buy milk")); // => Created todo #1: Buy milk
System.out.println(controller.create("Write tests")); // => Created todo #2: Write tests
System.out.println(controller.done(1)); // => Todo #1 marked as done
System.out.println(controller.listAll());
// => [✓] 1. Buy milk
// => [○] 2. Write tests
}
}Key Takeaway: The Controller handles input and coordinates. The Model owns data and rules. The View formats output. None of these three should reach into the others' domain.
Why It Matters: MVC is the backbone of virtually every web framework (Rails, Django, Laravel, Spring MVC, ASP.NET). Understanding the pure form of MVC lets you debug framework issues quickly and extend frameworks correctly without fighting the intended structure.
Example 6: Model Encapsulates Validation
The Model is responsible for enforcing its own invariants. If validation logic leaks into controllers or views, the same rule must be duplicated everywhere the data is modified, creating drift over time.
// => MODEL with self-contained validation — the model is the single source of truth
public class BankAccount {
private double balance; // => private: external code cannot bypass validation
private static final double MINIMUM_BALANCE = 0; // => business rule: no overdrafts
public BankAccount(double initialBalance) {
if (initialBalance < 0) {
throw new IllegalArgumentException("Initial balance cannot be negative");
// => enforced at construction time, not by the caller
}
this.balance = initialBalance; // => balance is initialBalance (e.g., 100)
}
public void deposit(double amount) {
if (amount <= 0) {
throw new IllegalArgumentException("Deposit amount must be positive");
// => model rejects invalid inputs without controller involvement
}
balance += amount; // => balance increases by amount
}
public void withdraw(double amount) {
if (amount <= 0) throw new IllegalArgumentException("Withdrawal amount must be positive");
// => reject non-positive withdrawal at the gate
if (balance - amount < MINIMUM_BALANCE) {
throw new IllegalStateException("Insufficient funds: balance is " + balance);
// => business rule enforced in model, not leaked to controller
}
balance -= amount; // => balance decreases by amount
}
public double getBalance() { return balance; }
// => read-only access to private state
// => CONTROLLER — delegates entirely to model, no duplicate validation
static String handleWithdraw(BankAccount account, double amount) {
try {
account.withdraw(amount); // => model enforces all rules
return "Withdrawal successful. Balance: $" + account.getBalance();
// => Output: "Withdrawal successful. Balance: $50.0"
} catch (Exception e) {
return "Withdrawal failed: " + e.getMessage();
// => Output: "Withdrawal failed: Insufficient funds: balance is 50.0"
}
}
public static void main(String[] args) {
BankAccount account = new BankAccount(100); // => account.balance is 100
System.out.println(handleWithdraw(account, 50)); // => Withdrawal successful. Balance: $50.0
System.out.println(handleWithdraw(account, 200)); // => Withdrawal failed: Insufficient funds: balance is 50.0
}
}Key Takeaway: Models that enforce their own invariants are impossible to put into invalid states, regardless of which controller or API endpoint calls them.
Why It Matters: Domain model integrity is the first line of defense against data corruption in production. When the model does not enforce its own rules, invariants get enforced inconsistently across controllers, background jobs, and admin scripts — eventually leading to database records that violate business rules, which are notoriously expensive to clean up.
Dependency Injection
Example 7: Manual Dependency Injection
Dependency injection means passing dependencies into an object rather than creating them inside it. The object declares what it needs; the caller decides what to provide. This makes the object testable and reusable across different contexts.
graph TD
Caller["Caller / Composition Root"]
DB["Database (real or fake)"]
Svc["UserService"]
Caller -->|creates and passes| DB
Caller -->|creates and injects DB| Svc
Svc -->|uses injected DB| DB
style Caller fill:#0173B2,stroke:#000,color:#fff
style DB fill:#029E73,stroke:#000,color:#fff
style Svc fill:#DE8F05,stroke:#000,color:#fff
Without dependency injection (hard to test):
// => UserService creates its own database connection — cannot be tested without real DB
import java.util.Map;
class HardcodedUserService {
// => hardcoded dependency: impossible to substitute a fake in tests
private final Map<Integer, String> db = Map.of(1, "Alice", 2, "Bob");
String getName(int userId) {
return db.get(userId); // => returns name or null — no DI, no seam for testing
}
}With dependency injection (easy to test with any backend):
// => INTERFACE defines what UserService needs from storage
import java.util.Map;
interface UserStore {
String fetch(int userId);
// => any class implementing fetch(int) -> String satisfies this contract
}
// => REAL implementation for production
class DictUserStore implements UserStore {
private final Map<Integer, String> data = Map.of(1, "Alice", 2, "Bob");
// => simulates a real DB
@Override
public String fetch(int userId) {
return data.get(userId); // => returns name or null
}
}
// => FAKE implementation for tests — no DB required
class FakeUserStore implements UserStore {
@Override
public String fetch(int userId) {
return "TestUser"; // => always returns a fixed value for testing
}
}
// => SERVICE accepts any UserStore — decoupled from specific implementation
class UserService {
private final UserStore store;
// => store is injected, not created here
UserService(UserStore store) { this.store = store; }
// => store assigned from outside — caller decides which implementation
String greet(int userId) {
String name = store.fetch(userId); // => delegates to whatever store was injected
if (name == null) return "User " + userId + " not found";
return "Hello, " + name + "!"; // => Output: "Hello, Alice!"
}
public static void main(String[] args) {
// => PRODUCTION: inject real store
UserService service = new UserService(new DictUserStore());
System.out.println(service.greet(1)); // => Hello, Alice!
System.out.println(service.greet(99)); // => User 99 not found
// => TEST: inject fake store — no database needed
UserService testService = new UserService(new FakeUserStore());
System.out.println(testService.greet(1)); // => Hello, TestUser!
}
}Key Takeaway: Inject dependencies from the outside rather than creating them inside. The service only knows the interface it needs, not which implementation provides it.
Why It Matters: Dependency injection is the foundation of testable architectures. DI containers in Java, Python, and other ecosystems exist to automate what this example does manually. Applications built with DI reach high test coverage more easily because every dependency can be substituted with a fast in-memory fake, removing the need for live databases or external services in the test suite.
Example 8: Constructor Injection vs. Method Injection
There are two common styles of dependency injection: constructor injection (dependencies passed when the object is created) and method injection (dependencies passed per-call). Constructor injection is the default for stable dependencies; method injection suits per-request context like loggers or user sessions.
// => CONSTRUCTOR INJECTION — dependency lives for the object's lifetime
// => Use when: dependency is always required and does not change per call
import java.util.function.IntPredicate;
interface PaymentGateway {
boolean charge(int amount); // => contract: charge(amount) -> approved or declined
}
class OrderProcessor {
private final PaymentGateway paymentGateway;
// => paymentGateway is stored for the lifetime of OrderProcessor
OrderProcessor(PaymentGateway paymentGateway) {
this.paymentGateway = paymentGateway; // => injected at construction time
// => caller decides which gateway is used — OrderProcessor is gateway-agnostic
}
String processOrder(int orderId, int amount) {
boolean success = paymentGateway.charge(amount);
// => delegates to whatever gateway was injected at construction
return success
? "Order " + orderId + " paid ($" + amount + ")"
: "Order " + orderId + " payment failed";
// => Output (amount=500): "Order 1 paid ($500)"
// => Output (amount=1500): "Order 2 payment failed"
}
}
// => METHOD INJECTION — dependency passed per call
// => Use when: dependency varies per request (e.g., per-user logger, per-request context)
interface Output {
void write(String msg); // => contract: write(msg) — caller supplies the sink
}
class AuditLogger {
void log(String message, Output output) {
// => output is injected per call — can be console, file, or database
output.write("[AUDIT] " + message);
// => delegates writing to whatever Output was passed in
}
}
public class InjectionExample {
public static void main(String[] args) {
// => CONSTRUCTOR INJECTION: gateway wired once at composition root
PaymentGateway fakeGateway = amount -> amount < 1000;
// => fakeGateway approves amounts below 1000 (simulates approval limit)
OrderProcessor processor = new OrderProcessor(fakeGateway);
System.out.println(processor.processOrder(1, 500)); // => Order 1 paid ($500)
System.out.println(processor.processOrder(2, 1500)); // => Order 2 payment failed
// => METHOD INJECTION: output sink supplied per call
AuditLogger logger = new AuditLogger();
logger.log("User login", msg -> System.out.println(msg));
// => Output: [AUDIT] User login
logger.log("File export", msg -> System.out.println(">> " + msg));
// => Output: >> [AUDIT] File export
}
}Key Takeaway: Prefer constructor injection for required, stable dependencies. Use method injection when the dependency varies per invocation.
Why It Matters: Constructor injection makes dependencies explicit and visible in the object's contract, eliminating invisible global state. Method injection powers extensible APIs like Express middleware and Python decorators, enabling plug-in architectures where behavior can be augmented without modifying the core class.
Interface Segregation
Example 9: Interface Segregation Principle
The Interface Segregation Principle says that classes should not be forced to implement methods they do not use. A fat interface forces every implementor to carry unused methods. Split fat interfaces into focused ones, and implementors pick only what they need.
Fat interface — forces implementors to define methods they do not need:
// => FAT interface: all implementors must provide ALL four methods
// => even when most methods are irrelevant to that implementor
interface EmployeeOperations {
double calculateSalary(); // => relevant for paid employees
void clockIn(); // => relevant for hourly workers
String generateReport(); // => relevant for managers
void requestLeave(int days); // => relevant for all employees
}
// => CONTRACTOR only needs salary, yet must implement the other three
class Contractor implements EmployeeOperations {
@Override public double calculateSalary() { return 500; }
// => useful — flat daily rate
@Override public void clockIn() { /* not applicable */ }
// => forced but meaningless — contractors don't clock in
@Override public String generateReport() { return ""; }
// => forced but meaningless — contractors don't generate reports
@Override public void requestLeave(int days) {}
// => forced but meaningless — contractors don't use this leave system
}Segregated interfaces — each implementor picks only what applies:
// => FOCUSED interfaces: each covers exactly one capability
interface Payable { double calculateSalary(); } // => pay calculation only
interface Trackable { void clockIn(); } // => time tracking only
interface Reportable { String generateReport(); } // => reporting only
interface LeaveEligible { void requestLeave(int days); } // => leave management only
// => CONTRACTOR: only salary matters — no forced empty methods
class SegregatedContractor implements Payable {
@Override
public double calculateSalary() { return 500; }
// => flat daily rate — no other obligations
}
// => FULL-TIME EMPLOYEE: salary + time tracking + leave
class FullTimeEmployee implements Payable, Trackable, LeaveEligible {
private int hoursWorked = 0; // => tracks hours for this period
@Override
public double calculateSalary() { return hoursWorked * 25; }
// => $25/hour rate applied to tracked hours
@Override
public void clockIn() { hoursWorked += 8; }
// => adds one full work day (8 hours)
@Override
public void requestLeave(int days) {
System.out.println("Leave requested: " + days + " day(s)");
// => Output: Leave requested: 5 day(s)
}
}
// => MANAGER: salary + reporting — no clock-in, no leave tracked here
class Manager implements Payable, Reportable {
@Override public double calculateSalary() { return 8000; }
// => fixed monthly salary
@Override
public String generateReport() { return "Team performance: on track"; }
// => manager-specific report string
}
public class ISPExample {
public static void main(String[] args) {
SegregatedContractor contractor = new SegregatedContractor();
System.out.println(contractor.calculateSalary()); // => 500.0
FullTimeEmployee emp = new FullTimeEmployee();
emp.clockIn(); // => hoursWorked is now 8
System.out.println(emp.calculateSalary()); // => 200.0 (8 * 25)
}
}Key Takeaway: Split interfaces by cohesive capability, not by the most complex implementor. Implementors import only the interfaces they genuinely fulfill.
Why It Matters: Interface segregation is why TypeScript's structural typing enables clean plugin architectures and why Java's standard library separated Readable, Writable, Closeable, and Flushable. Applications that ignore ISP accumulate "empty method" implementations that silently succeed or throw UnsupportedOperationException, creating subtle production bugs.
Open/Closed Principle
Example 10: Open for Extension, Closed for Modification
The Open/Closed Principle states that a class should be open for extension (new behaviors can be added) but closed for modification (existing code does not change when behavior is added). Achieving this typically involves polymorphism or strategy objects.
graph TD
Client["Client Code"]
Base["Discount Strategy<br/>(interface/protocol)"]
A["RegularDiscount"]
B["LoyaltyDiscount"]
C["SeasonalDiscount"]
Client -- "uses" --> Base
Base -- "implemented by" --> A
Base -- "implemented by" --> B
Base -- "implemented by" --> C
style Client fill:#0173B2,stroke:#000,color:#fff
style Base fill:#DE8F05,stroke:#000,color:#fff
style A fill:#029E73,stroke:#000,color:#fff
style B fill:#029E73,stroke:#000,color:#fff
style C fill:#029E73,stroke:#000,color:#fff
Closed approach — requires modifying existing code for every new discount:
// => VIOLATION: adding a new discount type requires editing this method
// => Every new case expands this if-else chain — "open for modification" anti-pattern
public class ClosedViolation {
static double calculateDiscount(double price, String discountType) {
if (discountType.equals("regular")) {
return price * 0.10; // => 10% off
} else if (discountType.equals("loyalty")) {
return price * 0.20; // => 20% off for loyal customers
}
// => Every new discount type forces an edit here — risky regression surface
return 0.0;
}
}Open/Closed approach — extend by adding new classes, never editing existing ones:
// => ABSTRACT BASE — defines the contract, never changes
abstract class DiscountStrategy {
abstract double calculate(double price);
// => all strategies must implement calculate(price) -> double
}
// => CONCRETE STRATEGIES — add new ones without touching existing code
class RegularDiscount extends DiscountStrategy {
@Override
double calculate(double price) { return price * 0.10; }
// => 10% discount
}
class LoyaltyDiscount extends DiscountStrategy {
@Override
double calculate(double price) { return price * 0.20; }
// => 20% discount for loyal customers
}
// => EXTENSION: new discount type — existing code is UNTOUCHED
class SeasonalDiscount extends DiscountStrategy {
@Override
double calculate(double price) { return price * 0.30; }
// => 30% seasonal sale discount — added without modifying existing classes
}
// => CLIENT: depends on the abstract base, not concrete classes
class PriceCalculator {
private final DiscountStrategy strategy;
// => strategy is injected — PriceCalculator never changes when new discounts arrive
PriceCalculator(DiscountStrategy strategy) {
this.strategy = strategy; // => stores injected strategy
}
double finalPrice(double price) {
double discount = strategy.calculate(price);
// => delegates discount computation to injected strategy
return price - discount; // => price minus discount amount
}
public static void main(String[] args) {
PriceCalculator calc = new PriceCalculator(new RegularDiscount());
System.out.println(calc.finalPrice(100.0)); // => 90.0 (100 - 10)
PriceCalculator calc2 = new PriceCalculator(new SeasonalDiscount());
System.out.println(calc2.finalPrice(100.0)); // => 70.0 (100 - 30)
}
}Key Takeaway: Depend on abstractions and inject concrete strategies from outside. Adding new behavior means writing a new class, not modifying existing ones.
Why It Matters: The Open/Closed Principle enables extension through new implementations rather than modification of existing code. Pluggable systems — payment processors, logging backends, notification channels — are only maintainable when each extension point uses an interface rather than a conditional. Applications that violate OCP accumulate feature flags and nested conditionals that make every new feature a regression risk.
Liskov Substitution Principle
Example 11: Subtypes Must Be Substitutable
The Liskov Substitution Principle (LSP) says that any subclass instance must be usable wherever its parent class is expected, without breaking the program. A subclass that overrides a method to throw an exception or weaken behavior violates LSP.
LSP violation — subclass breaks the contract:
// => BASE CLASS: Rectangle assumes width and height are independent
class Rectangle {
protected int width;
protected int height;
Rectangle(int width, int height) {
this.width = width; // => width is set independently
this.height = height; // => height is set independently
}
void setWidth(int w) { this.width = w; } // => changes ONLY width
void setHeight(int h) { this.height = h; } // => changes ONLY height
int area() { return width * height; } // => width * height
}
// => SQUARE forces both dimensions equal — violates Rectangle's contract
class Square extends Rectangle {
Square(int side) { super(side, side); }
@Override
void setWidth(int w) {
this.width = w; // => changes width
this.height = w; // => ALSO changes height — breaks Rectangle's contract
}
@Override
void setHeight(int h) {
this.height = h; // => changes height
this.width = h; // => ALSO changes width — breaks Rectangle's contract
}
}
// => CALLER written for Rectangle — breaks silently when given Square
static void testRectangle(Rectangle r) {
r.setWidth(5);
r.setHeight(3);
// => For Rectangle: area is 5 * 3 = 15 (expected)
// => For Square: area is 3 * 3 = 9 (unexpected — LSP violated)
System.out.println("Area: " + r.area());
}
public static void main(String[] args) {
testRectangle(new Rectangle(1, 1)); // => Area: 15 (correct)
testRectangle(new Square(1)); // => Area: 9 (violates LSP)
}LSP-compliant design — use a shared interface, not inheritance:
// => SHAPE interface: defines area() contract without assuming dimension independence
interface Shape { int area(); }
// => RECTANGLE: independent width and height — no coupling to Square behavior
class ProperRectangle implements Shape {
private final int width;
private final int height;
ProperRectangle(int width, int height) {
this.width = width; // => width stored independently
this.height = height; // => height stored independently
}
@Override
public int area() { return width * height; }
// => width * height — always correct for any width/height pair
}
// => SQUARE: single side length, no inherited width/height confusion
class ProperSquare implements Shape {
private final int side;
// => only one dimension: side — square invariant enforced by design
ProperSquare(int side) { this.side = side; }
@Override
public int area() { return side * side; }
// => side * side — always correct
}
// => CALLER: works for any Shape — LSP satisfied
static void printArea(Shape shape) {
System.out.println("Area: " + shape.area());
}
public static void main(String[] args) {
printArea(new ProperRectangle(5, 3)); // => Area: 15
printArea(new ProperSquare(4)); // => Area: 16
}Key Takeaway: Prefer interface composition over inheritance when subclass behavior diverges from parent behavior. Subtypes must honor the behavioral contract of the type they replace.
Why It Matters: LSP violations create runtime surprises that escape static analysis. The classic Rectangle/Square problem appears in real codebases as ReadOnlyList extends ArrayList where add() throws UnsupportedOperationException. These bugs surface in production rather than compilation, making them disproportionately expensive to diagnose.
DRY, KISS, and YAGNI
Example 12: DRY — Don't Repeat Yourself
DRY (Don't Repeat Yourself) means every piece of knowledge should have a single authoritative representation. Duplication is not just about copying code — it's about having the same decision expressed in multiple places.
Violation — business rule duplicated in three places:
// => VIOLATION: the "eligible user" rule is duplicated in every method
// => If the rule changes (e.g., add emailVerified check), all three must be updated
import java.util.Map;
public class DryViolation {
static void sendNotification(Map<String, Object> user) {
if ((boolean) user.get("active") && (int) user.get("age") >= 18) {
// => rule duplicated here — must be changed in all three places if rule evolves
System.out.println("Notifying " + user.get("name"));
}
}
static void generateReport(Map<String, Object> user) {
if ((boolean) user.get("active") && (int) user.get("age") >= 18) {
// => same rule repeated — divergence risk if one copy is missed
System.out.println("Report for " + user.get("name"));
}
}
static boolean allowPurchase(Map<String, Object> user) {
if ((boolean) user.get("active") && (int) user.get("age") >= 18) {
// => rule duplicated a third time
return true;
}
return false;
}
}DRY — single authoritative location for the rule:
// => SINGLE SOURCE OF TRUTH: eligibility rule defined once
import java.util.Map;
public class DryCompliant {
// => isEligibleUser is the one and only definition of the business rule
static boolean isEligibleUser(Map<String, Object> user) {
return (boolean) user.get("active") && (int) user.get("age") >= 18;
// => returns true only if active AND adult
// => changing this rule updates all three callers automatically
}
static void sendNotification(Map<String, Object> user) {
if (isEligibleUser(user)) { // => delegates to single rule
System.out.println("Notifying " + user.get("name"));
// => Output: Notifying Alice
}
}
static void generateReport(Map<String, Object> user) {
if (isEligibleUser(user)) { // => same single rule — no duplication
System.out.println("Report for " + user.get("name"));
}
}
static boolean allowPurchase(Map<String, Object> user) {
return isEligibleUser(user); // => single rule, zero duplication
}
public static void main(String[] args) {
Map<String, Object> user = Map.of("name", "Alice", "active", true, "age", 25);
sendNotification(user); // => Notifying Alice
System.out.println(allowPurchase(user)); // => true
}
}Key Takeaway: Extract repeated decisions into named functions or constants. Code duplication is a symptom of knowledge duplication — fix the knowledge location, not just the syntax.
Why It Matters: The most costly bugs in production are consistency bugs where the same rule was updated in two places but not the third. DRY violations are the primary driver of those failures. Establishing a single authoritative location for each business rule ensures that every caller automatically benefits from corrections and prevents silent divergence over time.
Example 13: KISS — Keep It Simple, Stupid
KISS means preferring the simplest design that satisfies the requirements. Complexity is a cost that must be justified by demonstrable benefit. Over-engineered solutions are harder to debug, test, and hand off.
Over-engineered — excessive abstraction for a simple task:
// => OVER-ENGINEERED: factory + strategy + builder for a simple greeting
// => Five classes to accomplish what one method can do
interface GreetingStrategy {
String greet(String name); // => contract for producing a greeting string
}
class FormalGreetingStrategy implements GreetingStrategy {
@Override
public String greet(String name) {
return "Good day, " + name + "."; // => produces formal greeting
}
}
class GreetingFactory {
// => factory adds a layer of indirection over strategy instantiation
static GreetingStrategy create(String type) {
if (type.equals("formal")) return new FormalGreetingStrategy();
throw new IllegalArgumentException("Unknown greeting type: " + type);
// => throws for any type other than "formal" — fragile extension point
}
}
class GreetingBuilder {
private String type = "formal"; // => default type
GreetingBuilder withType(String type) {
this.type = type; // => allows chaining: new GreetingBuilder().withType("formal")
return this; // => returns this for fluent API
}
GreetingStrategy build() {
return GreetingFactory.create(type);
// => delegates to factory — extra hop with no added value here
}
}
public class KissViolation {
public static void main(String[] args) {
// => 4 collaborating classes just to print one greeting line
String greeting = new GreetingBuilder().withType("formal").build().greet("Alice");
System.out.println(greeting); // => Good day, Alice.
}
}KISS — simplest solution that works:
// => SIMPLE: one method, zero ceremony, achieves the same result
// => No interface, no factory, no builder — plain static method
public class KissCompliant {
static String greet(String name) {
return "Good day, " + name + ".";
// => Output: Good day, Alice.
// => If greeting types are needed later, add them then (YAGNI)
}
public static void main(String[] args) {
System.out.println(greet("Alice")); // => Good day, Alice.
}
}Key Takeaway: Add abstractions only when complexity is demonstrated, not anticipated. The second solution is easier to read, debug, test, extend, and hand off.
Why It Matters: Premature abstraction is one of the top causes of architectural debt in growing codebases. Over-engineered systems take significantly longer to modify than simple ones, even when a modification aligns with the intended abstraction, because developers must navigate layers of indirection before reaching the logic they want to change. Build the simplest thing, then refactor when a pattern genuinely emerges.
Example 14: YAGNI — You Aren't Gonna Need It
YAGNI means do not add functionality until it is actually needed. Speculative features add code complexity without delivering current value, and they are often built for a requirement that never arrives in the form anticipated.
// => YAGNI VIOLATION: speculative features not required by any current use case
public class UserProfile {
private final String name; // => required today
private final String email; // => required today
// => SPECULATIVE: no current feature requires these fields
private String theme = "light"; // => "might need dark mode someday"
private String preferredLanguage = "en"; // => "maybe we'll go international"
private String newsletterFrequency = "weekly"; // => "for a newsletter we haven't built"
private boolean aiRecommendations = true; // => "for an AI feature in the roadmap"
public UserProfile(String name, String email) {
this.name = name;
this.email = email;
}
// => SPECULATIVE: no current caller uses this — dead code increasing maintenance burden
public String exportToXml() {
return "<user><name>" + name + "</name></user>";
// => XML export for a feature that hasn't been requested yet
}
// => SPECULATIVE: no current caller needs analytics integration
public void trackEngagement(String event) {
System.out.println("[Analytics] " + name + ": " + event);
// => analytics hook for a system that doesn't exist yet
}
}
// => YAGNI COMPLIANT: only what the application actually needs right now
class SimpleUserProfile {
private final String name; // => required today
private final String email; // => required today
// => No speculative fields — add when a feature actually needs them
SimpleUserProfile(String name, String email) {
this.name = name;
this.email = email;
}
String displayName() {
return name; // => required today for display
// => Output: "Alice"
}
public static void main(String[] args) {
SimpleUserProfile user = new SimpleUserProfile("Alice", "alice@example.com");
System.out.println(user.displayName()); // => Alice
// => Add exportToXml() when an export feature is actually built
// => Add theme field when a dark mode feature is actually shipped
}
}Key Takeaway: Ship only what the current sprint requires. Code that is never executed in production still costs maintenance, testing, and cognitive load.
Why It Matters: YAGNI reduces the "inventory" of unvalidated code. Lean manufacturing teaches that inventory is waste — software inventory (unshipped features, speculative abstractions) follows the same economics. Speculative features that never arrive as imagined waste the full cost of their development and the ongoing maintenance cost of code that production traffic never exercises.
Coupling and Cohesion
Example 15: High Coupling — The Problem
Coupling measures how much one module depends on the internals of another. High coupling means a change in one module forces changes in others, making the system brittle and hard to evolve.
// => HIGH COUPLING: OrderService reaches into the internals of Customer and Inventory
import java.util.HashMap;
import java.util.Map;
class Customer {
// => public fields expose internals — any caller can read or mutate them
public String name;
public double creditLimit; // => exposed internal detail
public double outstandingBalance = 0.0; // => mutable state visible to all
public Customer(String name, double creditLimit) {
this.name = name;
this.creditLimit = creditLimit; // => set at construction
}
}
class Inventory {
// => public map exposes backing data structure — callers can manipulate it directly
public Map<String, Integer> items = new HashMap<>();
public Inventory() {
items.put("Laptop", 5); // => raw map exposed to all callers
}
}
// => OrderService KNOWS about Customer's internals AND Inventory's internals
class TightlyCoupledOrderService {
public String placeOrder(Customer customer, Inventory inventory, String item, double price) {
// => directly reads customer's internal fields — tight coupling
if (customer.outstandingBalance + price > customer.creditLimit) {
return "Credit limit exceeded"; // => caller must know field names
}
// => directly reads inventory's internal map — tight coupling
if (inventory.items.getOrDefault(item, 0) <= 0) {
return item + " is out of stock"; // => coupled to Map implementation
}
customer.outstandingBalance += price; // => mutates customer state directly
inventory.items.put(item, inventory.items.get(item) - 1); // => mutates inventory state
return "Order placed: " + item + " for " + customer.name;
// => Output: "Order placed: Laptop for Alice"
// => If Customer renames creditLimit, this line breaks
// => If Inventory switches to a database, this line breaks
}
public static void main(String[] args) {
Customer customer = new Customer("Alice", 2000.0);
Inventory inventory = new Inventory();
TightlyCoupledOrderService service = new TightlyCoupledOrderService();
System.out.println(service.placeOrder(customer, inventory, "Laptop", 1200.0));
// => Order placed: Laptop for Alice
}
}Key Takeaway: When one module directly reads or mutates another module's internal fields, every internal change cascades as a breaking change throughout the codebase.
Why It Matters: High coupling is the primary reason legacy migrations fail. Systems where modules directly manipulate each other's internals cannot be changed one component at a time — every change is a big-bang rewrite. The cost of decoupling grows exponentially with system size, which is why architectural standards exist: coupling is far cheaper to prevent than to remediate.
Example 16: Low Coupling Through Encapsulation
Reducing coupling means modules communicate through stable public interfaces, not through internal fields. When internal representation changes, the public interface remains stable and callers are unaffected.
graph LR
OS["OrderService"]
C["Customer<br/>(encapsulated)"]
I["Inventory<br/>(encapsulated)"]
OS -->|canAcceptCharge#40;price#41;| C
OS -->|recordCharge#40;price#41;| C
OS -->|isAvailable#40;item#41;| I
OS -->|decrement#40;item#41;| I
style OS fill:#0173B2,stroke:#000,color:#fff
style C fill:#DE8F05,stroke:#000,color:#fff
style I fill:#029E73,stroke:#000,color:#fff
// => ENCAPSULATED Customer — hides internals behind stable methods
import java.util.HashMap;
import java.util.Map;
class EncapsulatedCustomer {
private final String name;
private final double creditLimit;
private double balance = 0.0; // => private: external code cannot touch directly
public EncapsulatedCustomer(String name, double creditLimit) {
this.name = name;
this.creditLimit = creditLimit; // => stored privately
}
public String getName() { return name; } // => read-only access to name
public boolean canAcceptCharge(double amount) {
return balance + amount <= creditLimit;
// => hides the credit formula — callers don't know the field names
}
public void recordCharge(double amount) {
balance += amount; // => only method that mutates balance
// => internal representation can change without affecting callers
}
}
// => ENCAPSULATED Inventory — hides the backing data structure
class EncapsulatedInventory {
private final Map<String, Integer> stock = new HashMap<>();
// => private backing map
public EncapsulatedInventory() {
stock.put("Laptop", 5); // => initialized privately
}
public boolean isAvailable(String item) {
return stock.getOrDefault(item, 0) > 0;
// => hides how stock is stored — could be a database call tomorrow
}
public void decrement(String item) {
stock.computeIfPresent(item, (k, v) -> v > 0 ? v - 1 : v);
// => only method that mutates stock — controlled access
}
}
// => LOOSELY COUPLED OrderService — talks only through public interfaces
class LooselyCoupldeOrderService {
public String placeOrder(EncapsulatedCustomer customer,
EncapsulatedInventory inventory,
String item, double price) {
if (!customer.canAcceptCharge(price)) {
return "Credit limit exceeded"; // => no internal field access
}
if (!inventory.isAvailable(item)) {
return item + " is out of stock"; // => no map access
}
customer.recordCharge(price); // => delegates mutation to owner
inventory.decrement(item); // => delegates mutation to owner
return "Order placed: " + item + " for " + customer.getName();
// => Output: "Order placed: Laptop for Alice"
// => Renaming private fields has ZERO impact on this service
}
public static void main(String[] args) {
EncapsulatedCustomer customer = new EncapsulatedCustomer("Alice", 2000.0);
EncapsulatedInventory inventory = new EncapsulatedInventory();
LooselyCoupldeOrderService service = new LooselyCoupldeOrderService();
System.out.println(service.placeOrder(customer, inventory, "Laptop", 1200.0));
// => Order placed: Laptop for Alice
}
}Key Takeaway: Define stable public methods that express what the object can do, not what it contains. Callers depend on behavior, not representation.
Why It Matters: Encapsulation is what makes refactoring safe. When the internal representation of an object changes — migrating from a dict to a database row, for example — only that class changes. Systems with high encapsulation maintain a stable change cost over time, while tightly coupled systems see change cost grow superlinearly as every internal detail becomes a dependency for external callers.
Example 17: Cohesion — Grouping Related Behavior
Cohesion measures how related the responsibilities within a module are. High cohesion means everything in a class belongs together; low cohesion means the class mixes unrelated concerns.
Low cohesion — one class mixes unrelated domains:
// => LOW COHESION: MixedUtilities handles three completely unrelated domains
import java.time.LocalDate;
class MixedUtilities {
// => string manipulation
public String formatTitle(String title) {
return title.toUpperCase(); // => "hello" → "HELLO"
}
// => financial calculation — unrelated to strings
public double calculateTax(double price, double rate) {
return price * rate; // => 100 * 0.1 = 10.0
}
// => date manipulation — unrelated to both of the above
public String getDayOfWeek(LocalDate date) {
return date.getDayOfWeek().name();
// => "MONDAY", "TUESDAY", etc.
}
}High cohesion — each class groups only related behavior:
// => HIGH COHESION: each class groups only related behavior
// => REASON: when formatTitle changes, it does not affect tax or date logic
import java.time.LocalDate;
class StringFormatter {
// => All methods relate to text formatting
public String formatTitle(String title) {
return title.toUpperCase(); // => "hello" → "HELLO"
}
public String truncate(String text, int maxLength) {
return text.length() > maxLength ? text.substring(0, maxLength) + "…" : text;
// => "Hello World" with maxLength=5 → "Hello…"
}
}
class TaxCalculator {
// => All methods relate to tax computation
public double calculate(double price, double rate) {
return price * rate; // => 100 * 0.1 = 10.0
}
public double calculateWithCap(double price, double rate, double cap) {
double tax = price * rate; // => 100 * 0.15 = 15.0
return Math.min(tax, cap); // => min(15.0, 10.0) = 10.0 — capped
}
}
class DateHelper {
// => All methods relate to date operations
public String getDayOfWeek(LocalDate date) {
return date.getDayOfWeek().name(); // => "MONDAY" or "TUESDAY" etc.
}
public boolean isWeekend(LocalDate date) {
return date.getDayOfWeek().getValue() >= 6;
// => true on Saturday (6) or Sunday (7)
}
public static void main(String[] args) {
StringFormatter fmt = new StringFormatter();
System.out.println(fmt.formatTitle("hello world")); // => HELLO WORLD
TaxCalculator tax = new TaxCalculator();
System.out.println(tax.calculate(200, 0.1)); // => 20.0
}
}Key Takeaway: Group code by what it does, not by convenience. A class with high cohesion has one clear job, making it easy to name, test, and locate.
Why It Matters: Low-cohesion classes like Utils.java or helpers.ts grow without bound in large codebases, becoming catchalls that nobody wants to modify but everybody is afraid to split. High cohesion enables true modularity: each module can evolve, be tested, and be replaced independently. It is the micro-scale version of the microservices philosophy.
Encapsulation
Example 18: Encapsulation with Private State
Encapsulation means bundling data and the methods that operate on it, then hiding the data behind a controlled interface. External code interacts through methods, never through direct field access.
Poor encapsulation — public mutable fields:
// => POOR ENCAPSULATION: mutable public fields invite inconsistent state
public class PoorTemperature {
public double celsius; // => public: anyone can set this to -999
public double fahrenheit; // => computed at construction only
public PoorTemperature(double celsius) {
this.celsius = celsius;
this.fahrenheit = celsius * 9.0 / 5 + 32;
// => Problem: if caller sets celsius later, fahrenheit stays stale
}
public static void main(String[] args) {
PoorTemperature poor = new PoorTemperature(100);
System.out.println(poor.celsius); // => 100.0
System.out.println(poor.fahrenheit); // => 212.0
poor.celsius = 50; // => direct mutation — fahrenheit is now stale!
System.out.println(poor.fahrenheit); // => 212.0 (wrong! should be 122.0)
}
}Encapsulated — state changes only through controlled methods:
// => WELL ENCAPSULATED: private field, derived properties computed on demand
public class Temperature {
private final double celsius; // => private: external code cannot set directly
public Temperature(double celsius) {
validate(celsius); // => validates at construction time
this.celsius = celsius; // => stored once, never exposed for direct write
}
private static void validate(double celsius) {
if (celsius < -273.15) {
throw new IllegalArgumentException("Temperature below absolute zero: " + celsius);
// => physics constraint enforced by the class, not by callers
}
}
public double getCelsius() { return celsius; }
// => read-only access — no setter means no stale risk
public double getFahrenheit() {
return celsius * 9.0 / 5 + 32;
// => always computed from celsius — never stale
// => celsius=100 → fahrenheit=212.0
}
public double getKelvin() {
return celsius + 273.15; // => always derived from single source of truth
}
public Temperature toCelsius(double value) {
return new Temperature(value); // => returns NEW instance — immutable pattern
}
public static void main(String[] args) {
Temperature t = new Temperature(100);
System.out.println(t.getCelsius()); // => 100.0
System.out.println(t.getFahrenheit()); // => 212.0
System.out.println(t.getKelvin()); // => 373.15
// => t.celsius = 50 ← compile error: field is private — cannot bypass encapsulation
}
}Key Takeaway: Hide mutable state behind methods. Derived values should always be computed from a single source of truth rather than cached in parallel fields.
Why It Matters: Every public field in a class is a potential consistency bug. NASA's Mars Climate Orbiter was lost in 1999 partly due to data without encapsulated unit enforcement — a temperature or distance without controlled access. Systems where state changes are controlled and validated at the class boundary make such invariant violations impossible.
Composition Over Inheritance
Example 19: Preferring Composition
Composition over inheritance means building complex behavior by combining simple, focused objects rather than by building deep inheritance trees. Inheritance creates rigid hierarchies; composition creates flexible assemblies.
graph TD
Bird["Bird"]
Fly["FlyBehavior"]
Swim["SwimBehavior"]
Eagle["Eagle"]
Duck["Duck"]
Penguin["Penguin"]
Eagle -- "has a" --> Fly
Duck -- "has a" --> Fly
Duck -- "has a" --> Swim
Penguin -- "has a" --> Swim
style Bird fill:#CA9161,stroke:#000,color:#fff
style Fly fill:#0173B2,stroke:#000,color:#fff
style Swim fill:#029E73,stroke:#000,color:#fff
style Eagle fill:#DE8F05,stroke:#000,color:#fff
style Duck fill:#DE8F05,stroke:#000,color:#fff
style Penguin fill:#DE8F05,stroke:#000,color:#fff
Inheritance approach — rigid hierarchy:
// => INHERITANCE: rigid "is-a" hierarchy that breaks when adding Penguin
class Bird {
public String fly() {
return "Flying"; // => default fly behavior for all Birds
}
}
class DuckInheritance extends Bird {
public String quack() {
return "Quack"; // => duck-specific behavior
}
}
class PenguinInheritance extends Bird {
@Override
public String fly() {
throw new UnsupportedOperationException("Penguins cannot fly");
// => Penguin is-a Bird, but must break the Bird contract
// => This is an LSP violation — any code using Bird.fly() breaks for Penguin
}
}Composition approach — flexible assembly:
// => BEHAVIOR INTERFACES — define what behaviors exist
interface FlyBehavior {
String fly(); // => contract for any flying behavior
}
interface SwimBehavior {
String swim(); // => contract for any swimming behavior
}
// => CONCRETE BEHAVIORS — small, focused, reusable
class CanFly implements FlyBehavior {
public String fly() {
return "Flapping wings"; // => standard flying behavior
}
}
class CannotFly implements FlyBehavior {
public String fly() {
return "Cannot fly"; // => used by non-flying birds — no exception
}
}
class CanSwim implements SwimBehavior {
public String swim() {
return "Swimming"; // => used by aquatic birds
}
}
// => COMPOSED BIRDS — each bird assembles the behaviors it actually has
class Eagle {
private final FlyBehavior fly = new CanFly(); // => eagles can fly
public String fly() { return fly.fly(); }
// => delegates to behavior object — Output: "Flapping wings"
}
class Duck {
private final FlyBehavior flyBehavior = new CanFly(); // => ducks can fly
private final SwimBehavior swimBehavior = new CanSwim(); // => and swim
public String fly() { return flyBehavior.fly(); } // => Output: "Flapping wings"
public String swim() { return swimBehavior.swim(); } // => Output: "Swimming"
}
class Penguin {
private final FlyBehavior flyBehavior = new CannotFly(); // => cannot fly
private final SwimBehavior swimBehavior = new CanSwim(); // => but can swim
public String fly() { return flyBehavior.fly(); } // => Output: "Cannot fly"
public String swim() { return swimBehavior.swim(); } // => Output: "Swimming"
}
class BirdCompositionDemo {
public static void main(String[] args) {
Duck duck = new Duck();
System.out.println(duck.fly()); // => Flapping wings
System.out.println(duck.swim()); // => Swimming
Penguin penguin = new Penguin();
System.out.println(penguin.fly()); // => Cannot fly (no exception thrown)
}
}Key Takeaway: Model "has-a" relationships with composition and "is-a" relationships with interfaces. Composition lets you mix and match behaviors without inheriting unwanted methods.
Why It Matters: Deep inheritance hierarchies are a primary driver of architectural rigidity. The Gang of Four design patterns book's most cited advice is "favor object composition over class inheritance." Languages that use structural typing and composition as primary extension mechanisms demonstrate that flexible, maintainable systems do not require deep inheritance trees.
Example 20: Mixin vs. Composition
Interfaces with default methods in Java, Kotlin, and C# serve the same role Python mixins play: sharing optional capability across unrelated classes. Understanding when to use default-method interfaces (shallow, optional capability) versus explicit composition (required, configurable behavior) is a foundational architectural decision.
Interface-with-default-methods approach — reuses capability without deep inheritance:
// => INTERFACE WITH DEFAULT METHODS: adds serialization capability to any implementing class
import java.util.Map;
import com.google.gson.Gson; // => conceptual — swap with any JSON library
interface JsonSerializable {
// => default method: any class implementing this interface gets toJson() for free
default String toJson() {
// => in real code, use Jackson or Gson; shown here as a simple map serialization
return this.toString(); // => placeholder — real impl would use reflection
// => Output for Product: '{"name":"Laptop","price":1200.0}'
}
}
interface Timestamped {
long getCreatedAt(); // => contract: implementing class provides creation time
}
// => Product implements BOTH interfaces, gains both capabilities
class Product implements JsonSerializable, Timestamped {
private final String name;
private final double price;
private final long createdAt = System.currentTimeMillis();
// => creation time recorded at construction
public Product(String name, double price) {
this.name = name;
this.price = price; // => stored privately
}
@Override public long getCreatedAt() { return createdAt; }
// => satisfies Timestamped contract
@Override public String toString() {
return "{\"name\":\"" + name + "\",\"price\":" + price + ",\"createdAt\":" + createdAt + "}";
// => simple serialization — toJson() from interface calls this
}
public static void main(String[] args) {
Product product = new Product("Laptop", 1200.0);
System.out.println(product.toJson());
// => {"name":"Laptop","price":1200.0,"createdAt":...}
// => toJson comes from the interface — Product class has zero duplication
}
}Composition as explicit dependency:
// => EXPLICIT COMPOSITION: behaviors are injected, not inherited
interface Serializer {
String serialize(java.util.Map<String, Object> data); // => contract for serialization
}
interface Clock {
long now(); // => contract for time retrieval
}
// => CONCRETE IMPLEMENTATIONS — swappable in tests
class SimpleSerializer implements Serializer {
public String serialize(java.util.Map<String, Object> data) {
return data.toString(); // => simplified; real impl uses Jackson/Gson
// => Output: {id=1, amount=99.9, createdAt=...}
}
}
class SystemClock implements Clock {
public long now() { return System.currentTimeMillis(); }
// => returns current Unix timestamp in milliseconds
}
class Order {
private final int id;
private final double amount;
private final Serializer serializer; // => injected behavior
private final long createdAt; // => recorded via injected clock
public Order(int id, double amount, Serializer serializer, Clock clock) {
this.id = id;
this.amount = amount;
this.serializer = serializer; // => stored — can be swapped in tests
this.createdAt = clock.now(); // => delegates time to clock — testable
}
public String toJson() {
return serializer.serialize(java.util.Map.of(
"id", id, "amount", amount, "createdAt", createdAt
));
// => Output: {id=1, amount=99.9, createdAt=...}
}
public static void main(String[] args) {
Order order = new Order(1, 99.9, new SimpleSerializer(), new SystemClock());
System.out.println(order.toJson());
// => {id=1, amount=99.9, createdAt=...}
// => In tests: inject FakeClock that returns a fixed timestamp — fully deterministic
}
}Key Takeaway: Use mixins for optional, non-configurable capabilities shared across unrelated classes. Use explicit composition when the behavior needs to be swapped, tested, or configured.
Why It Matters: Mixin abuse creates the "mixin hell" antipattern where the method resolution order becomes impossible to reason about and unit tests require instantiating the full mixin chain. Django's class-based views famously suffer from this. Explicit composition avoids MRO confusion and enables clean dependency injection.
Repository Pattern
Example 21: Repository Pattern Basics
The Repository pattern abstracts the data access layer behind a collection-like interface. The rest of the application treats data access as if it were querying an in-memory collection, regardless of whether the underlying store is a database, file, or external API.
graph LR
Service["Business Logic<br/>(Service)"]
Repo["Repository<br/>(interface)"]
ImplA["InMemoryRepository<br/>(tests)"]
ImplB["DatabaseRepository<br/>(production)"]
Service -->|depends on| Repo
Repo -->|implemented by| ImplA
Repo -->|implemented by| ImplB
style Service fill:#0173B2,stroke:#000,color:#fff
style Repo fill:#DE8F05,stroke:#000,color:#fff
style ImplA fill:#029E73,stroke:#000,color:#fff
style ImplB fill:#CC78BC,stroke:#000,color:#fff
// => REPOSITORY INTERFACE — defines the contract; no storage details
import java.util.*;
record Product(int id, String name, double price) {}
interface ProductRepository {
Optional<Product> findById(int productId); // => find or empty — no null
void save(Product product); // => upsert by id
List<Product> findAll(); // => returns all stored products
}
// => IN-MEMORY IMPLEMENTATION — for tests and fast prototyping
class InMemoryProductRepository implements ProductRepository {
private final Map<Integer, Product> store = new HashMap<>();
// => in-memory map as backing store
@Override
public Optional<Product> findById(int productId) {
return Optional.ofNullable(store.get(productId));
// => returns Optional.of(product) or Optional.empty()
}
@Override
public void save(Product product) {
store.put(product.id(), product); // => upsert by id key
}
@Override
public List<Product> findAll() {
return List.copyOf(store.values()); // => defensive copy — no mutation
}
}
// => SERVICE — uses only the repository interface, not any concrete implementation
class ProductService {
private final ProductRepository repo;
// => injected repository — service never knows the backing store type
public ProductService(ProductRepository repo) { this.repo = repo; }
public Product addProduct(int id, String name, double price) {
Product product = new Product(id, name, price);
repo.save(product); // => delegates persistence to repository
return product; // => returns the saved product
}
public Optional<Product> getProduct(int id) {
return repo.findById(id);
// => delegates retrieval — business layer never imports a DB driver
}
public static void main(String[] args) {
ProductRepository repo = new InMemoryProductRepository(); // => inject in-memory impl
ProductService service = new ProductService(repo);
Product p = service.addProduct(1, "Laptop", 1200.0);
System.out.println(p); // => Product[id=1, name=Laptop, price=1200.0]
System.out.println(service.getProduct(1)); // => Optional[Product[id=1, name=Laptop, price=1200.0]]
System.out.println(service.getProduct(99)); // => Optional.empty
}
}Key Takeaway: Define a collection-like interface for persistence and inject the implementation. The business layer never imports a database driver.
Why It Matters: The repository pattern is the reason Django ORM, Rails ActiveRecord, and Spring Data JPA can be swapped for test doubles in unit tests. Applications built with explicit repository interfaces reach high test coverage without requiring a live database, dramatically reducing CI build times and increasing test reliability.
Example 22: Repository with Query Methods
Real repositories go beyond simple CRUD. They expose domain-meaningful query methods that express business questions as named methods rather than raw queries embedded in business logic.
import java.util.*;
import java.util.stream.Collectors;
// => DOMAIN-SPECIFIC REPOSITORY — queries named for business intent
interface OrderRepository {
// => save: upsert an order by its id
void save(Map<String, Object> order);
// => findById: returns order map or null if not found
Map<String, Object> findById(int id);
// => findByCustomerId: named for business question "which orders belong to this customer?"
List<Map<String, Object>> findByCustomerId(int customerId);
// => findPendingAbove: named for business question "which pending orders exceed this threshold?"
List<Map<String, Object>> findPendingAbove(double threshold);
}
// => IN-MEMORY IMPLEMENTATION — satisfies all query methods without a real database
class InMemoryOrderRepository implements OrderRepository {
// => backing store: order id -> order map
private final Map<Integer, Map<String, Object>> orders = new HashMap<>();
@Override
public void save(Map<String, Object> order) {
orders.put((int) order.get("id"), order); // => upsert: overwrites if id already exists
}
@Override
public Map<String, Object> findById(int id) {
return orders.get(id); // => returns order or null
}
@Override
public List<Map<String, Object>> findByCustomerId(int customerId) {
// => stream all orders, keep only those matching the given customerId
return orders.values().stream()
.filter(o -> (int) o.get("customerId") == customerId)
.collect(Collectors.toList());
// => result contains only orders for customerId
}
@Override
public List<Map<String, Object>> findPendingAbove(double threshold) {
// => keep orders that are pending AND exceed the threshold amount
return orders.values().stream()
.filter(o -> "pending".equals(o.get("status")) && (double) o.get("total") > threshold)
.collect(Collectors.toList());
// => result contains only qualifying pending orders
}
}
// => USAGE: business code uses named queries — reads like a business document
public class Example22 {
public static void main(String[] args) {
InMemoryOrderRepository repo = new InMemoryOrderRepository();
// => add three test orders to the in-memory store
repo.save(Map.of("id", 1, "customerId", 10, "total", 150.0, "status", "pending"));
repo.save(Map.of("id", 2, "customerId", 10, "total", 800.0, "status", "pending"));
repo.save(Map.of("id", 3, "customerId", 20, "total", 200.0, "status", "shipped"));
System.out.println(repo.findByCustomerId(10).size()); // => 2 (orders 1 and 2 belong to customer 10)
System.out.println(repo.findPendingAbove(500).size()); // => 1 (only order 2: total 800 > 500)
}
}Key Takeaway: Repository methods should read as business questions. Avoid exposing generic query(sql) methods; every query method should have a domain-meaningful name.
Why It Matters: Named query methods make the repository the living documentation of data access patterns. When a developer reads findPendingAbove(threshold), they understand the business intent immediately. Generic query(sql) leaks persistence technology into the service layer and makes it impossible to swap storage backends without rewriting business logic.
Service Layer Pattern
Example 23: Service Layer Coordinates Use Cases
The Service Layer pattern centralizes application use cases in dedicated service classes. A use case (like "place an order") typically involves multiple domain objects and repositories. The service coordinates them without embedding that coordination logic in controllers or domain objects.
import java.util.Map;
// ============================================================
// DOMAIN OBJECTS — each owns its own rules and state
// ============================================================
class Customer {
private final int id;
private double credit;
// => credit tracks available spending power for this customer
Customer(int id, double credit) {
this.id = id;
this.credit = credit; // => initial credit balance
}
boolean canAfford(double amount) {
return credit >= amount; // => true if credit covers amount
}
void deduct(double amount) {
credit -= amount; // => reduces available credit by amount
}
int getId() { return id; }
}
class Product {
private final int id;
final String name;
final double price;
private int stock;
// => stock tracks how many units remain
Product(int id, String name, double price, int stock) {
this.id = id;
this.name = name;
this.price = price;
this.stock = stock; // => initial inventory count
}
boolean isAvailable() {
return stock > 0; // => true when at least one unit remains
}
void reserve() {
stock -= 1; // => decrements stock by one when an order is placed
}
}
// ============================================================
// SERVICE LAYER — orchestrates the "place order" use case
// ============================================================
class OrderService {
private final Map<Integer, Customer> customers;
private final Map<Integer, Product> products;
// => injected collections act as simple in-memory repositories
OrderService(Map<Integer, Customer> customers, Map<Integer, Product> products) {
this.customers = customers; // => data source for customer lookups
this.products = products; // => data source for product lookups
}
String placeOrder(int customerId, int productId) {
// => STEP 1: retrieve both domain objects — guard if missing
Customer customer = customers.get(customerId);
Product product = products.get(productId);
if (customer == null || product == null) return "Customer or product not found";
// => exit early if either entity is missing
// => STEP 2: apply business rules via domain object methods
if (!product.isAvailable()) return "'" + product.name + "' is out of stock";
// => domain object owns the availability rule
if (!customer.canAfford(product.price))
return String.format("Insufficient credit for '%s' ($%.2f)", product.name, product.price);
// => domain object owns the affordability rule
// => STEP 3: execute state changes — service coordinates, domain mutates
product.reserve(); // => domain: decrements stock
customer.deduct(product.price); // => domain: deducts credit
return "Order placed: " + product.name + " for customer " + customerId;
// => Output: "Order placed: Laptop for customer 1"
}
}
public class Example23 {
public static void main(String[] args) {
var customers = Map.of(
1, new Customer(1, 2000.0), // => customer 1 has $2000 credit
2, new Customer(2, 100.0) // => customer 2 has $100 credit
);
var products = Map.of(
10, new Product(10, "Laptop", 1200.0, 2), // => 2 in stock
11, new Product(11, "Headphones", 150.0, 0) // => out of stock
);
var service = new OrderService(customers, products);
System.out.println(service.placeOrder(1, 10)); // => Order placed: Laptop for customer 1
System.out.println(service.placeOrder(2, 10)); // => Insufficient credit for 'Laptop' ($1200.00)
System.out.println(service.placeOrder(1, 11)); // => 'Headphones' is out of stock
}
}Key Takeaway: The service layer owns the sequence of steps for a use case. Domain objects own their own rules. Neither should cross into the other's territory.
Why It Matters: Without a service layer, use case logic scatters into controllers, domain objects, and scripts — creating duplicate sequences with subtle differences that diverge over time. Martin Fowler identifies the service layer as a key pattern in enterprise applications because it creates a clear API that the presentation layer (web, CLI, background jobs) can all call uniformly.
Example 24: Service Layer with Error Handling
Paradigm Note: The natural form of this pattern is Wlaschin's Railway-Oriented Programming — chaining steps via the
Either/Resultmonad. The OOP encoding below uses sentinel results or domain errors, which approximates the FP construct; exceptions are out-of-band and lose the value-track property. See the FP framing.
A mature service layer handles errors explicitly, returning structured results rather than leaking exceptions to the presentation layer. This makes error handling consistent across all callers (web controller, CLI, background job).
import java.util.HashMap;
import java.util.Map;
// => Java uses exceptions for error handling in service layers
// => Custom exception carries domain-meaningful error information
// => DOMAIN EXCEPTION — carries a business error, not a system failure
class ReservationException extends RuntimeException {
ReservationException(String message) {
super(message); // => wraps the failure reason in an exception
}
}
// => INVENTORY: simple in-memory stock store
class InventoryService {
// => inventory maps item name to available quantity
private final Map<String, Integer> inventory = new HashMap<>(Map.of("Widget", 10, "Gadget", 0));
// => reserve: throws ReservationException on any business rule failure
public Map<String, Object> reserve(String item, int quantity) {
Integer stock = inventory.get(item);
if (stock == null) {
throw new ReservationException("Item '" + item + "' does not exist");
// => explicit failure: item not in catalog
}
if (stock < quantity) {
throw new ReservationException(
"Insufficient stock for '" + item + "': have " + stock + ", requested " + quantity
);
// => explicit failure: not enough stock
}
inventory.put(item, stock - quantity); // => decrements stock by requested quantity
return Map.of("item", item, "reserved", quantity);
// => returns success payload with reservation details
}
}
// => CONTROLLER: catches domain exception — error path is always explicit
class Example24 {
static String handleReserve(InventoryService service, String item, int quantity) {
try {
var result = service.reserve(item, quantity);
// => success: result contains item and reserved count
return "Reserved " + result.get("reserved") + "x " + result.get("item");
// => Output: "Reserved 3x Widget"
} catch (ReservationException e) {
return "Reservation failed: " + e.getMessage();
// => Output: "Reservation failed: Insufficient stock for 'Gadget': have 0, requested 1"
}
}
public static void main(String[] args) {
var service = new InventoryService();
System.out.println(handleReserve(service, "Widget", 3)); // => Reserved 3x Widget
System.out.println(handleReserve(service, "Gadget", 1)); // => Reservation failed: Insufficient stock...
System.out.println(handleReserve(service, "Unknown", 1)); // => Reservation failed: Item 'Unknown' does not exist
}
}Key Takeaway: Returning Result types forces callers to handle both success and failure paths. Unhandled errors become a compile-time concern rather than a production incident.
Why It Matters: Rust's Result<T, E> and Go's multi-return (value, error) are language-level enforcement of this pattern because production systems show that unhandled exceptions in service layers are the top cause of unexpected downtime. TypeScript's discriminated unions bring the same discipline without requiring a new language.
DTO Pattern
Example 25: Data Transfer Objects
A Data Transfer Object (DTO) is a simple container for carrying data between layers or across service boundaries. DTOs have no business logic — they are pure data carriers. Using DTOs decouples the internal domain model from the external representation.
graph LR
Ext["External Client<br/>(API / UI)"]
DTO["Request DTO<br/>(validated input)"]
Svc["Service Layer"]
Domain["Domain Object<br/>(internal)"]
RespDTO["Response DTO<br/>(serialized output)"]
Ext -->|sends| DTO
DTO -->|mapped to| Domain
Domain -->|processed by| Svc
Svc -->|mapped to| RespDTO
RespDTO -->|returned to| Ext
style Ext fill:#CA9161,stroke:#000,color:#fff
style DTO fill:#0173B2,stroke:#000,color:#fff
style Svc fill:#DE8F05,stroke:#000,color:#fff
style Domain fill:#029E73,stroke:#000,color:#fff
style RespDTO fill:#CC78BC,stroke:#000,color:#fff
// => REQUEST DTO — represents data coming IN from the external world
// => Java record: immutable by default, all fields final, auto-generates equals/hashCode/toString
record CreateUserRequest(String name, String email) {
// => no domain logic: just carries data across the HTTP boundary
}
// => RESPONSE DTO — represents data going OUT to the external world
record UserResponse(int userId, String name, String email) {
// => notably MISSING: passwordHash, internalFlags, auditTrail
// => DTOs shape what external clients are allowed to see
}
// => DOMAIN OBJECT — internal representation with full context
class User {
final int userId;
final String name;
final String email;
final String passwordHash; // => internal only — never copied to response DTO
final boolean isAdmin; // => internal only — never copied to response DTO
User(int userId, String name, String email, String passwordHash, boolean isAdmin) {
this.userId = userId;
this.name = name;
this.email = email;
this.passwordHash = passwordHash; // => stored internally, hidden from DTOs
this.isAdmin = isAdmin; // => stored internally, hidden from DTOs
}
}
// => SERVICE — maps between DTOs and domain objects
class UserService {
private final java.util.Map<Integer, User> users = new java.util.HashMap<>();
// => in-memory user store
private int nextId = 1;
UserResponse createUser(CreateUserRequest request) {
// => MAP: Request DTO → Domain Object
var user = new User(
nextId,
request.name(), // => copied from DTO
request.email(), // => copied from DTO
"hashed_secret", // => generated internally — NOT from DTO
false // => default: new users are not admins
);
users.put(nextId++, user); // => persist domain object
// => MAP: Domain Object → Response DTO (selective field copy)
return new UserResponse(user.userId, user.name, user.email);
// => passwordHash and isAdmin intentionally EXCLUDED from response
}
}
public class Example25 {
public static void main(String[] args) {
var service = new UserService();
var request = new CreateUserRequest("Alice", "alice@example.com");
// => request carries only what the caller provides
var response = service.createUser(request);
System.out.println(response);
// => UserResponse[userId=1, name=Alice, email=alice@example.com]
// => passwordHash is NOT in the response — protected by DTO boundary
}
}Key Takeaway: DTOs are the shape of data at a boundary — they protect internal domain structure from external exposure and decouple serialization from business logic.
Why It Matters: Every major data breach involving accidental field exposure (password hashes returned in API responses, admin flags visible to users) is a failure to use DTOs. The DTO pattern makes it structurally impossible to accidentally expose internal fields — you must explicitly copy each field you intend to share.
Example 26: DTO Validation
Paradigm Note: The FP-native form is "parse, don't validate" (Wlaschin, Designing with types): use smart constructors + ADTs so invalid states are unrepresentable. The OOP encoding validates at runtime in methods, which carries different guarantees. See the FP framing.
DTOs are also the right place to validate external input before it enters the domain layer. Centralizing validation in the DTO prevents invalid data from reaching business logic.
// => REQUEST DTO with built-in validation in the constructor
// => If the constructor returns, every field is guaranteed valid
class CreateProductRequest {
final String name; // => validated: non-empty, trimmed
final double price; // => validated: positive
final int stock; // => validated: non-negative integer
CreateProductRequest(String rawName, double rawPrice, int rawStock) {
// => validate name: must not be null or blank
if (rawName == null || rawName.trim().isEmpty()) {
throw new IllegalArgumentException("name must be a non-empty string");
// => invalid name rejected at DTO boundary
}
// => validate price: must be positive
if (rawPrice <= 0) {
throw new IllegalArgumentException("price must be a positive number");
// => negative prices rejected at DTO boundary
}
// => validate stock: must be non-negative
if (rawStock < 0) {
throw new IllegalArgumentException("stock must be a non-negative integer");
// => fractional or negative stock rejected at DTO boundary
}
this.name = rawName.trim(); // => normalized: leading/trailing spaces removed
this.price = rawPrice; // => valid positive price
this.stock = rawStock; // => valid non-negative integer
}
}
// => SERVICE receives only validated DTOs — no re-validation needed inside service
public class Example26 {
static String createProduct(String rawName, double rawPrice, int rawStock) {
try {
var request = new CreateProductRequest(rawName, rawPrice, rawStock);
// => if constructor succeeds, all fields are guaranteed valid
return String.format("Product created: %s at $%.2f (stock: %d)",
request.name, request.price, request.stock);
// => Output: "Product created: Widget at $9.99 (stock: 100)"
} catch (IllegalArgumentException e) {
return "Validation failed: " + e.getMessage();
// => Output: "Validation failed: price must be a positive number"
}
}
public static void main(String[] args) {
System.out.println(createProduct("Widget", 9.99, 100));
// => Product created: Widget at $9.99 (stock: 100)
System.out.println(createProduct("", 9.99, 100));
// => Validation failed: name must be a non-empty string
System.out.println(createProduct("Widget", -5, 100));
// => Validation failed: price must be a positive number
}
}Key Takeaway: Validate external input in the DTO constructor so that a successfully constructed DTO is guaranteed valid. Business logic should never need to re-validate the same fields.
Why It Matters: Validation scattered across controllers, services, and domain objects produces inconsistent enforcement — the same field passes validation on one API endpoint but not another. Libraries like Zod (TypeScript), Pydantic (Python), and Bean Validation (Java) codify this pattern: parse/validate at the boundary, trust internally. This prevents an entire class of injection and data corruption attacks.
Putting It All Together
Example 27: Small Layered Application
This example combines the patterns introduced in Examples 1-26 into a minimal but realistic layered application: a product catalog with separation of concerns, repository, service, and DTO layers all working together.
import java.util.*;
// ============================================================
// DTOs — data shapes at the boundary
// ============================================================
record AddProductRequest(String name, double price) {}
// => input DTO: carries data from caller to service — no business logic
record ProductSummary(int productId, String name, double price) {}
// => output DTO: carries safe data from service to caller — isFeatured intentionally absent
// ============================================================
// DOMAIN — internal representation with full context
// ============================================================
class Product {
final int productId;
final String name;
final double price;
final boolean isFeatured; // => internal flag — intentionally absent from ProductSummary DTO
Product(int productId, String name, double price, boolean isFeatured) {
this.productId = productId;
this.name = name;
this.price = price;
this.isFeatured = isFeatured; // => internal only
}
}
// ============================================================
// REPOSITORY — data access abstraction
// ============================================================
interface ProductRepo {
void save(Product product); // => persist or update a product
List<Product> findAll(); // => retrieve all products
}
class InMemoryProductRepo implements ProductRepo {
private final Map<Integer, Product> data = new HashMap<>();
// => in-memory backing store
private int counter = 1;
@Override
public void save(Product product) {
data.put(product.productId, product); // => upsert by productId
}
@Override
public List<Product> findAll() {
return new ArrayList<>(data.values()); // => returns all products as a list
}
int nextId() {
return counter++; // => auto-increment: returns current then increments
}
}
// ============================================================
// SERVICE — coordinates use cases
// ============================================================
class ProductCatalogService {
private final InMemoryProductRepo repo;
// => injected repository — service delegates all persistence to it
ProductCatalogService(InMemoryProductRepo repo) {
this.repo = repo; // => dependency injection via constructor
}
ProductSummary add(AddProductRequest request) {
if (request.price() <= 0) throw new IllegalArgumentException("Price must be positive");
// => business rule: no free or negative-priced products
var product = new Product(repo.nextId(), request.name(), request.price(), false);
// => isFeatured defaults to false — caller cannot set it via DTO
repo.save(product); // => delegates persistence to repo
return new ProductSummary(product.productId, product.name, product.price);
// => maps domain → output DTO; isFeatured excluded from response
}
List<ProductSummary> listAll() {
return repo.findAll().stream()
.map(p -> new ProductSummary(p.productId, p.name, p.price))
.toList();
// => maps each domain object to output DTO; isFeatured excluded
}
}
// ============================================================
// PRESENTATION — wires and calls; does nothing else
// ============================================================
public class Example27 {
public static void main(String[] args) {
var repo = new InMemoryProductRepo();
var service = new ProductCatalogService(repo);
// => DI: repo injected into service
service.add(new AddProductRequest("Laptop", 1200.0));
service.add(new AddProductRequest("Mouse", 25.0));
// => s1 is ProductSummary[productId=1, name=Laptop, price=1200.0]
// => s2 is ProductSummary[productId=2, name=Mouse, price=25.0]
for (var summary : service.listAll()) {
System.out.printf(" %d. %s: $%.2f%n", summary.productId(), summary.name(), summary.price());
}
// => 1. Laptop: $1200.00
// => 2. Mouse: $25.00
}
}Key Takeaway: Each layer has a clear job: DTOs carry data at boundaries, the domain holds rules, the repository manages storage, and the service coordinates use cases. The presentation layer does nothing but wire and call.
Why It Matters: This four-layer structure reliably scales from a two-person project to a large engineering team because each layer can be replaced, tested, and scaled independently. A database can be swapped, a new presentation layer added, and business rules changed without cascading rewrites — a property that only holds when the layer boundaries are respected throughout development.
Example 28: Recognizing Architecture Smells
Architecture smells are patterns in code structure that signal design problems. Recognizing them early prevents costly refactoring later. These are the most common smells that violate the principles covered in Examples 1-27.
graph TD
A["Architecture Smell"]
B["God Object<br/>Does everything"]
C["Leaking Layers<br/>SQL in controller"]
D["Anemic Domain<br/>Empty domain objects"]
E["Implicit Coupling<br/>Global state / singletons"]
A --> B
A --> C
A --> D
A --> E
style A fill:#0173B2,stroke:#000,color:#fff
style B fill:#DE8F05,stroke:#000,color:#fff
style C fill:#DE8F05,stroke:#000,color:#fff
style D fill:#029E73,stroke:#000,color:#fff
style E fill:#CC78BC,stroke:#000,color:#fff
Smell 1: God Object — one class knows everything:
// => GOD OBJECT: one class handles users, orders, payments, and reports
// => Signs: class has methods spanning many unrelated domains
class ApplicationManager {
void createUser(String name) { /* ... */ } // => user concern
void placeOrder(int userId) { /* ... */ } // => order concern
void chargeCard(double amount) { /* ... */ } // => payment concern
String generateMonthlyReport() { return ""; } // => reporting concern
// => Every new feature gets added here — grows without bound
// => Fix: split into UserService, OrderService, PaymentService, ReportService
}Smell 2: Layer leakage — SQL in the controller:
import java.sql.*;
import java.util.Map;
// => LAYER LEAK: HTTP handler directly queries a database
// => Controller should never see SQL or database connections
class UserController {
Map<String, Object> handleGetUser(int userId) throws SQLException {
// => data access in presentation layer — crosses a layer boundary
Connection conn = DriverManager.getConnection("jdbc:sqlite:app.db");
PreparedStatement stmt = conn.prepareStatement("SELECT id, name FROM users WHERE id = ?");
// => SQL embedded in the controller — impossible to swap storage without touching this class
stmt.setInt(1, userId);
ResultSet rs = stmt.executeQuery();
// => Fix: extract to UserRepository.findById() and call from a service
if (rs.next()) return Map.of("id", rs.getInt("id"), "name", rs.getString("name"));
return Map.of();
}
}Smell 3: Anemic domain — domain objects with no behavior:
// => ANEMIC DOMAIN: Order is just a data bag — all logic lives in the service
class AnemicOrder {
int id;
double total;
String status;
// => No methods, no rules — just fields
// => All business logic forced into OrderService, which becomes a God Object
}
// => FIX: move behavior INTO the domain object where it belongs
class RichOrder {
final int id;
double total;
String status;
RichOrder(int id, double total, String status) {
this.id = id; this.total = total; this.status = status;
}
boolean canCancel() {
return "pending".equals(status); // => business rule lives with the data
// => OrderService calls order.canCancel() instead of repeating the rule
}
}Smell 4: Implicit coupling via global state:
import java.util.Map;
// => GLOBAL STATE: any class can read or write this — invisible coupling
public class SessionGlobal {
private static Map<String, Object> currentUser = null;
// => global mutable state — shared across all threads and callers
public static void login(Map<String, Object> user) {
currentUser = user; // => mutates shared global — side effect hidden from callers
}
public static Map<String, Object> getCurrentUser() {
return currentUser; // => any class can call this anytime — invisible dependency
}
// => Fix: pass user as a parameter or inject a SessionContext object
// => Global state makes execution order matter invisibly — testing and concurrency hazard
}Key Takeaway: God objects, layer leakage, anemic domains, and global state are the four most common beginner architecture smells. Recognizing them early is as important as knowing the correct patterns.
Why It Matters: Architecture smells compound silently: a god object at 500 lines becomes unmaintainable at 5,000. When multiple teams edit the same massive object simultaneously, deployment conflicts and data inconsistencies are inevitable. The patterns in Examples 1-27 exist precisely to prevent these smells from taking root by establishing clear, stable boundaries before the codebase grows past the point where restructuring becomes prohibitively expensive.
Last updated March 19, 2026