Q1: does vibe code == pain maintain?
Q2: How much could/should AI change this "standard model"?

This subject: work in groups; write quality code; maintain alien code; use SE + AI; establish on-line profile. (For more on above pic, see Fig3 of Long et.al, TSE'23.)
"The most fundamental problem in computer science is problem decomposition: how to take a complex problem and divide it up into pieces that can be solved independently." β John Ousterhout
(We thank Macro Valente for the JAVA examples used in this lecture.).
Software engineering is about managing complexity. Bad design makes systems harder to understand, maintain, and evolve. Good design does the opposite. But what makes design "good"?
Three interlocking pieces:
- Properties β qualities like cohesion, coupling, information hiding
- Principles β guidelines like SOLID that help achieve those properties
- Patterns β past solutions that embody both properties and principles
Think of it like cooking:
- Properties = "tasty, nutritious, visually appealing"
- Principles = "balance flavors, cook at proper temp, fresh ingredients"
- Patterns = "recipes that increase the odds of good results"
Definition: A system should reflect a unified set of design ideas, not a hodgepodge of uncoordinated features.
Why it matters: Users familiar with one part of the system should feel comfortable with other parts. The system should feel like it was designed by one mind, even if built by many hands.
Example violations:
- Tables sorted by clicking headers on some columns but not others
- Prices shown in euros on some pages, dollars on others
- Variable naming:
camelCasein some files,snake_casein others - Using different web framework versions in different modules
Frederick Brooks said it best (1975, reaffirmed 1995):
"Conceptual integrity is the most important consideration in system design. It is better to have a system omit certain anomalous features and improvements, but to reflect one set of design ideas, than to have one that contains many good but independent and uncoordinated ideas."
The Camel Problem: "A camel is a horse designed by a committee." When design-by-committee produces systems with features A and B (mutually exclusive but both included to satisfy different factions), conceptual integrity suffers.
So ask yourself, in any design, are there 10% of the requiremetns to throw away to make us 80% simpler?
Definition: Hide design decisions that are likely to change behind stable interfaces.
Advantages:
- Parallel development β teams can work on different classes simultaneously
- Changeability β swap implementations without affecting clients
- Comprehensibility β developers only need to understand their assigned components
The Key Insight: Classes must have public interfaces (the visible contract) and private implementation details (the hidden decisions). Interfaces must be stable because changes ripple to all clients.
Example: Parking Lot Evolution
// β BAD: Exposes internal data structure
class ParkingLot {
public Hashtable<String, String> vehicles; // EXPOSED!
public ParkingLot() {
vehicles = new Hashtable<String, String>();
}
}
// Usage - clients directly manipulate internals
ParkingLot p = new ParkingLot();
p.vehicles.put("TCP-7030", "Accord"); // Direct access!Problem: If we change from Hashtable to another data structure, all client code breaks.
// β
GOOD: Hides implementation behind interface
class ParkingLot {
private Hashtable<String, String> vehicles; // HIDDEN
public ParkingLot() {
vehicles = new Hashtable<String, String>();
}
public void park(String license, String vehicle) {
vehicles.put(license, vehicle);
}
}
// Usage - clients use stable interface
ParkingLot p = new ParkingLot();
p.park("TCP-7030", "Accord"); // Through interfaceReal World: In 2002, Jeff Bezos sent this famous memo to Amazon developers:
- All teams will expose their data/functionality through service interfaces only
- No direct access to another subsystem's data
- Every interface must be designed to potentially be public someday
This was Parnas' 1972 information hiding principle, enforced company-wide.
(More more on this see here.)
Definition: Each class should have a single responsibility β one reason to change.
Benefits:
- Easier to implement, understand, and maintain
- Easier to assign to a single developer
- Easier to reuse and test
Example 1: Method-Level Violation
// β BAD: Does two different things
float sin_or_cos(double x, int op) {
if (op == 1)
return Math.sin(x);
else
return Math.cos(x);
}
// β
GOOD: Separate functions for separate concerns
float sin(double x) { return Math.sin(x); }
float cos(double x) { return Math.cos(x); }Example 2: Class-Level Violation
// β BAD: Mixing parking lot operations with employee management
class ParkingLot {
private String managerName;
private String managerPhone;
private String managerSSN;
private String managerAddress;
void park(String license) { ... }
void calculatePrice() { ... }
void releaseVehicle() { ... }
}
// β
GOOD: Separate concerns
class ParkingLot {
void park(String license) { ... }
void calculatePrice() { ... }
void releaseVehicle() { ... }
}
class Employee {
private String name;
private String phone;
private String ssn;
private String address;
}Two Types of Coupling:
β Acceptable Coupling:
- Class A uses public methods from stable class B
- B's interface rarely changes
- Changes in B rarely impact A
- Example: Using
Hashtablefrom Java's standard library
π« Poor Coupling:
- Classes share global variables
- Direct file/database access across classes
- Unstable interfaces that change frequently
- Hidden dependencies not mediated by interfaces
The Recommendation: Maximize cohesion, minimize coupling β but coupling isn't inherently evil. The goal is to eliminate poor coupling while maintaining acceptable coupling where necessary.
Example: File-Based Coupling (Poor)
// β BAD: Hidden coupling through shared file
class A {
private void f() {
File file = File.open("file1.db");
int total = file.readInt(); // Reading B's file!
...
}
}
class B {
private void g() {
int total = computeTotal();
File file = File.open("file1.db");
file.writeInt(total); // Writing to file
file.close();
}
}Problem: Developer of B might not know A reads this file. Format changes break A silently.
// β
GOOD: Explicit coupling through interface
class A {
private void f(B b) {
int total = b.getTotal(); // Explicit dependency!
...
}
}
class B {
private int total;
public int getTotal() { // Public interface
return total;
}
private void g() {
total = computeTotal();
File file = File.open("file1.db");
file.writeInt(total);
...
}
}Coupling Types Summarized:
- Structural Coupling: Class A explicitly references class B in code
- Evolutionary Coupling: Changes in B propagate to A (this is what we want to minimize)
The Left-Pad Episode (2016): A developer removed an 11-line JavaScript function from npm. Thousands of sites broke due to indirect dependencies: App β Library A1 β A2 β ... β An β left-pad. Most sites didn't even know they depended on it.
Principles are actionable guidelines for achieving good properties. SOLID is an acronym for five key principles.
Principle: A class should have only one reason to change.
Key Application: Separate presentation from business logic.
Example Violation:
// β BAD: Mixing calculation and display
class Course {
void calculateDropoutRate() {
double rate = computeRate();
System.out.println(rate); // Presentation mixed in!
}
}Fixed:
// β
GOOD: Separated concerns
class Course {
double calculateDropoutRate() {
return computeRate(); // Pure business logic
}
}
class Console {
void printDropoutRate(Course course) {
double rate = course.calculateDropoutRate();
System.out.println(rate); // Presentation only
}
}Why it matters: Domain classes can now be reused with web, mobile, or desktop interfaces.
Principle: Interfaces should be small, cohesive, and client-specific. Don't force clients to depend on methods they don't use.
Example Violation:
// β BAD: Fat interface with methods not all clients need
interface Account {
double getBalance();
double getInterest(); // Only for SavingsAccount
double getSalary(); // Only for SalaryAccount
}Fixed:
// β
GOOD: Segregated interfaces
interface Account {
double getBalance();
}
interface SavingsAccount extends Account {
double getInterest();
}
interface SalaryAccount extends Account {
double getSalary();
}Principle: Classes should be open for extension but closed for modification.
Translation: Design classes so they can be customized without changing their source code.
Example: Java Collections.sort()
// Default sorting (alphabetical)
List<String> names = Arrays.asList("john", "megan", "alexander", "zoe");
Collections.sort(names);
// Result: ["alexander", "john", "megan", "zoe"]
// Extended behavior WITHOUT modifying sort()
Collections.sort(names, (s1, s2) -> s1.length() - s2.length());
// Result: ["zoe", "john", "megan", "alexander"]The sort method is:
- Closed β we didn't modify its source code
- Open β we extended its behavior with a custom comparator
Counter-Example:
// β BAD: Requires modification for new student types
double calcTotalScholarships(Student[] list) {
double total = 0.0;
for (Student student : list) {
if (student instanceof UndergraduateStudent) {
total += calcUndergradScholarship((UndergraduateStudent) student);
}
else if (student instanceof MasterStudent) {
total += calcMasterScholarship((MasterStudent) student);
}
// Need to modify this method for DoctoralStudent!
}
return total;
}Important Note: No class can be open to all extensions. For example, Collections.sort() lets you customize comparison logic but not the sorting algorithm itself.
Principle: Subclasses must honor the contracts of their parent classes. If S is a subtype of T, then objects of type T can be replaced with objects of type S without breaking the program.
What this means: When overriding methods, don't violate the original contract.
Example 1: Contract Violation
// Base class contract: getPrime(n) works for n from 1 to 1,000,000
class PrimeNumber {
int getPrime(int n) {
// Returns nth prime number
}
}
// β BAD: Subclass violates contract
class FastPrimeNumber extends PrimeNumber {
int getPrime(int n) {
// Only works for n up to 900,000!
}
}Problem: Code expecting base class behavior will fail with subclass.
Example 2: Semantic Violation
class A {
int sum(int a, int b) {
return a + b; // Returns 3 for sum(1,2)
}
}
// β BAD: Changes semantic meaning
class B extends A {
int sum(int a, int b) {
String r = String.valueOf(a) + String.valueOf(b);
return Integer.parseInt(r); // Returns 12 for sum(1,2)!
}
}
class Client {
void f(A a) {
a.sum(1,2); // Could return 3 or 12 - unpredictable!
}
}Principle: Depend on abstractions (interfaces), not concrete implementations.
Why: Interfaces are more stable than implementations. Depending on interfaces makes code immune to implementation changes.
Example:
// The abstraction
interface Projector {
void project();
}
// Concrete implementations
class EpsonProjector implements Projector { ... }
class SamsungProjector implements Projector { ... }
// β
GOOD: Depends on interface
void presentSlides(Projector projector) {
projector.project(); // Works with any implementation
}
// Usage can change without modifying presentSlides()
presentSlides(new EpsonProjector());
presentSlides(new SamsungProjector());Library Example: Java's List interface with implementations:
ArrayListLinkedListVector
Clients should declare List variables, not ArrayList variables. This makes code work with all current and future List implementations.
Patterns are proven solutions that embody principles and properties. They're reusable templates, not specific code.
Problem: Client needs objects but shouldn't know concrete classes.
Solution: Let a factory method decide which class to instantiate.
// β Without Factory
PDFDocument pdf = new PDFDocument(); // Client knows concrete class
WordDocument word = new WordDocument();
// β
With Factory
interface Document { void open(); }
class PDFDocument implements Document { ... }
class WordDocument implements Document { ... }
class DocumentFactory {
Document createDocument(String type) {
if (type.equals("pdf")) return new PDFDocument();
if (type.equals("word")) return new WordDocument();
return null;
}
}
// Client doesn't know concrete classes
Document doc = factory.createDocument(userInput);Principles satisfied: Dependency Inversion (depend on Document interface), Open-Closed (add new document types without client changes)
Problem: Need exactly one instance of a class throughout the application.
Solution: Private constructor + static method returning single instance.
class Logger {
private static Logger instance;
private Logger() {} // Can't instantiate from outside
public static Logger getInstance() {
if (instance == null) {
instance = new Logger();
}
return instance;
}
}
// Usage
Logger.getInstance().log("message");Drawbacks:
- Global state makes testing harder
- Hidden dependencies (classes silently depend on singleton)
- Tight coupling to concrete class
- Thread safety concerns
When to use: Shared resources like loggers, configuration managers, connection pools.
Problem: Two incompatible interfaces need to work together.
Solution: Create a wrapper that translates one interface to another.
// Legacy system uses XML
class LegacyXMLService {
String getDataAsXML() { return "<data>...</data>"; }
}
// New system expects JSON
interface JSONService {
String getDataAsJSON();
}
// β
Adapter makes them compatible
class XMLtoJSONAdapter implements JSONService {
private LegacyXMLService legacy;
public XMLtoJSONAdapter(LegacyXMLService legacy) {
this.legacy = legacy;
}
public String getDataAsJSON() {
String xml = legacy.getDataAsXML();
return convertXMLtoJSON(xml);
}
}Use case: Integrating legacy systems without rewriting them.
Problem: Complex subsystem with many classes and methods.
Solution: Provide simplified interface hiding complexity.
// Complex subsystem
class Amplifier { void on() {...} void setVolume(int v) {...} }
class DVDPlayer { void on() {...} void play(String movie) {...} }
class Projector { void on() {...} void wideScreenMode() {...} }
// β
Facade simplifies
class HomeTheaterFacade {
private Amplifier amp;
private DVDPlayer dvd;
private Projector projector;
void watchMovie(String movie) {
projector.on();
projector.wideScreenMode();
amp.on();
amp.setVolume(5);
dvd.on();
dvd.play(movie);
}
}
// Client has simple interface
theater.watchMovie("Inception");Principles satisfied: Information Hiding (hides subsystem complexity), reduces coupling
Problem: Need to add responsibilities to objects dynamically without subclassing.
Solution: Wrap objects in decorator objects that add behavior.
interface Coffee {
double cost();
String description();
}
class SimpleCoffee implements Coffee {
public double cost() { return 2.0; }
public String description() { return "Simple coffee"; }
}
// Decorators add features
class MilkDecorator implements Coffee {
private Coffee coffee;
public MilkDecorator(Coffee coffee) { this.coffee = coffee; }
public double cost() { return coffee.cost() + 0.5; }
public String description() { return coffee.description() + ", milk"; }
}
class SugarDecorator implements Coffee {
private Coffee coffee;
public SugarDecorator(Coffee coffee) { this.coffee = coffee; }
public double cost() { return coffee.cost() + 0.2; }
public String description() { return coffee.description() + ", sugar"; }
}
// Stack decorators dynamically
Coffee coffee = new SimpleCoffee();
coffee = new MilkDecorator(coffee);
coffee = new SugarDecorator(coffee);
// Cost: 2.7, Description: "Simple coffee, milk, sugar"Alternative to inheritance: Instead of MilkCoffee and SugarMilkCoffee classes (combinatorial explosion), use composition.
Problem: Need to switch between different algorithms at runtime.
Solution: Encapsulate each algorithm in its own class.
interface CompressionStrategy {
void compress(String file);
}
class ZipCompression implements CompressionStrategy {
public void compress(String file) { /* ZIP algorithm */ }
}
class RarCompression implements CompressionStrategy {
public void compress(String file) { /* RAR algorithm */ }
}
class FileCompressor {
private CompressionStrategy strategy;
void setStrategy(CompressionStrategy strategy) {
this.strategy = strategy;
}
void compressFile(String file) {
strategy.compress(file);
}
}
// Runtime selection
FileCompressor compressor = new FileCompressor();
compressor.setStrategy(new ZipCompression());
compressor.compressFile("data.txt");
compressor.setStrategy(new RarCompression()); // Switch algorithm
compressor.compressFile("data.txt");Principles satisfied: Open-Closed (add new strategies without modifying compressor), Dependency Inversion (depend on interface)
Problem: One object changes state, many objects need to be notified.
Solution: Observers subscribe to subject; subject notifies all observers of changes.
interface Observer {
void update(String news);
}
class NewsPublisher {
private List<Observer> observers = new ArrayList<>();
void subscribe(Observer obs) {
observers.add(obs);
}
void publishNews(String news) {
for (Observer obs : observers) {
obs.update(news);
}
}
}
class NewsReader implements Observer {
private String name;
public void update(String news) {
System.out.println(name + " received: " + news);
}
}
// Usage
NewsPublisher publisher = new NewsPublisher();
publisher.subscribe(new NewsReader("Alice"));
publisher.subscribe(new NewsReader("Bob"));
publisher.publishNews("Breaking: Design patterns are useful!");Also called: Publish-Subscribe pattern
Use cases: Event systems, UI updates, distributed systems
Problem: Algorithm has common structure but variant steps.
Solution: Define skeleton in base class; subclasses override specific steps.
abstract class DataParser {
// Template method
final void parse(String file) {
openFile(file);
extractData(); // Variant step
processData(); // Variant step
closeFile(file);
}
void openFile(String file) { /* Common code */ }
void closeFile(String file) { /* Common code */ }
abstract void extractData(); // Subclasses override
abstract void processData(); // Subclasses override
}
class CSVParser extends DataParser {
void extractData() { /* CSV-specific */ }
void processData() { /* CSV-specific */ }
}
class XMLParser extends DataParser {
void extractData() { /* XML-specific */ }
void processData() { /* XML-specific */ }
}Principles satisfied: Single Responsibility (common code in one place), Open-Closed (extend by subclassing)
Problem: Need to add operations to class hierarchy without modifying classes.
Solution: Visitor objects encapsulate operations; classes accept visitors.
interface Visitor {
void visit(ClothingItem item);
void visit(Electronics item);
void visit(FoodItem item);
}
class TaxVisitor implements Visitor {
void visit(ClothingItem item) {
System.out.println("Tax on clothing: 8%");
}
void visit(Electronics item) {
System.out.println("Tax on electronics: 12%");
}
void visit(FoodItem item) {
System.out.println("Tax on food: 0%");
}
}
interface Item {
void accept(Visitor visitor);
}
class ClothingItem implements Item {
void accept(Visitor visitor) {
visitor.visit(this);
}
}
// Add new operations without modifying Item classes
TaxVisitor taxCalc = new TaxVisitor();
item.accept(taxCalc);When to use: Many unrelated operations across fixed class hierarchy.
Patterns scale from methods β classes β systems. Architectural patterns address system-wide structure.
Structure: Presentation β Business Logic β Data Access β Database
Example: Traditional web app
- Layer 1: UI (HTML/CSS/JavaScript)
- Layer 2: Controllers (route requests)
- Layer 3: Services (business rules)
- Layer 4: Repositories (database access)
Properties:
| Property | Rating | Why |
|---|---|---|
| Agility | LOW | Changes ripple through layers |
| Deployment | LOW | Must redeploy entire app |
| Testability | HIGH | Layers can be mocked |
| Performance | LOW | Overhead from layer traversal |
| Scalability | LOW | Monolithic scaling only |
| Development | HIGH | Clear separation of concerns |
When to use: Small-to-medium systems, teams with limited architectural experience, rapid development.
Structure: Components communicate via events (publish-subscribe).
Example: E-commerce site
- Event:
OrderPlaced - Listeners: InventoryService, ShippingService, NotificationService (all react independently)
Implementation: Message brokers (Kafka, RabbitMQ), event buses
Properties:
| Property | Rating | Why |
|---|---|---|
| Agility | HIGH | Components decoupled |
| Deployment | HIGH | Deploy services independently |
| Testability | LOW | Complex to test async flows |
| Performance | HIGH | Asynchronous = faster |
| Scalability | HIGH | Scale event processors |
| Development | LOW | Async complexity |
When to use: Real-time systems, high concurrency, loosely coupled services.
Structure: Small independent services, each with single business function.
Example: Netflix
- Service 1: User authentication
- Service 2: Video streaming
- Service 3: Recommendations
- Service 4: Billing
Each service: Own database, deployable independently, possibly different tech stacks.
Properties:
| Property | Rating | Why |
|---|---|---|
| Agility | HIGH | Change one service at a time |
| Deployment | HIGH | Independent deployment |
| Testability | HIGH | Isolate service for testing |
| Performance | LOW | Network overhead |
| Scalability | HIGH | Scale services individually |
| Development | HIGH | Small focused teams |
Challenges:
- Distributed system complexity
- Data consistency across services
- Network latency
- Monitoring and debugging harder
When to use: Large systems, multiple teams, need independent scaling.
Structure: Core system + plugin modules.
Example: IDE (like VSCode)
- Core: Editor, file system, UI framework
- Plugins: Language support, themes, debuggers
Properties:
| Property | Rating | Why |
|---|---|---|
| Agility | HIGH | Add plugins without core changes |
| Deployment | HIGH | Deploy plugins separately |
| Testability | HIGH | Test plugins in isolation |
| Performance | HIGH | Load only needed plugins |
| Scalability | LOW | Core can be bottleneck |
| Development | LOW | Contract management complex |
When to use: Systems needing customization, plugin ecosystems, extensible platforms.
Structure: Processing units + distributed shared memory (tuple space).
Example: High-traffic auction system
- In-memory data grid (Hazelcast, Redis)
- Multiple processing nodes
- No central database bottleneck
Properties:
| Property | Rating | Why |
|---|---|---|
| Agility | HIGH | Small deployable units |
| Deployment | HIGH | Cloud-native |
| Testability | LOW | Complex distributed state |
| Performance | HIGH | In-memory access |
| Scalability | HIGH | Horizontal scaling |
| Development | LOW | Distributed systems expertise |
When to use: High concurrency, variable load, user-centric data.
Structure: Data flows through sequence of processing steps (filters).
Example: Unix shell
cat file.txt | grep "error" | sort | uniq | wc -lEach command is a filter; pipes connect them.
Example 2: Compiler
Source Code β Lexer β Parser β Optimizer β Code Generator β Executable
Properties:
| Property | Rating | Why |
|---|---|---|
| Agility | HIGH | Modify individual filters |
| Deployment | HIGH | Deploy filters independently |
| Testability | HIGH | Test filters in isolation |
| Performance | LOW | Sequential processing overhead |
| Scalability | HIGH | Parallelize filters |
| Development | HIGH | Simple filter interface |
When to use: Stream processing, ETL pipelines, data transformation.
Patterns don't exist in isolation. They often work together:
Factory + Singleton
class DatabaseConnectionFactory {
private static DatabaseConnectionFactory instance;
private DatabaseConnectionFactory() {}
public static DatabaseConnectionFactory getInstance() { // Singleton
if (instance == null) instance = new DatabaseConnectionFactory();
return instance;
}
public Connection createConnection(String type) { // Factory
if (type.equals("mysql")) return new MySQLConnection();
if (type.equals("postgres")) return new PostgresConnection();
return null;
}
}Strategy + Factory
class CompressionFactory {
Strategy createStrategy(String type) { // Factory creates strategies
if (type.equals("zip")) return new ZipStrategy();
if (type.equals("rar")) return new RarStrategy();
return null;
}
}Decorator + Factory
Coffee coffee = CoffeeFactory.create("espresso"); // Factory
coffee = new MilkDecorator(coffee); // Decorator
coffee = new SugarDecorator(coffee); // DecoratorObserver + Mediator
- Observer handles one-to-many updates
- Mediator coordinates complex interactions between observers
Microservices often use:
- Factory (create service instances)
- Strategy (algorithm selection per service)
- Observer (event-driven communication)
- Adapter (integrate legacy services)
Event-Driven systems use:
- Observer (core pattern)
- Factory (create event handlers)
- Strategy (different event processing strategies)
Theory is nice. Data is better. Here's what actually happens to code metrics when you apply refactorings.
From empirical measurements on real Java systems.
| Metric | Name | What It Measures | β Better | β Worse |
|---|---|---|---|---|
| AMC | Average Method Complexity | Java bytecode count per method | Simple methods | Complex methods |
| AVG_CC | Average Cyclomatic Complexity | Average decision points per method | Linear flow | Branching logic |
| MAX_CC | Maximum Cyclomatic Complexity | Highest complexity in any method | Simple methods | Complex methods |
| CA | Afferent Coupling | How many classes USE this class | Unused | Highly depended upon |
| CE | Efferent Coupling | How many classes this class USES | Independent | Dependent on many |
| CAM | Cohesion Amongst Methods | Parameter type overlap across methods | Diverse params | Similar params |
| CBM | Coupling Between Methods | Dependencies between inherited methods | Independent | Tangled inheritance |
| CBO | Coupling Between Objects | Total coupling (CA + CE) | Loosely coupled | Tightly coupled |
| DAM | Data Access Metric | Ratio of private/protected attributes | Public fields | Encapsulated |
| DIT | Depth of Inheritance Tree | Distance from root to class | Shallow hierarchy | Deep hierarchy |
| IC | Inheritance Coupling | Parents this class couples to | Few parents | Many parents |
| LCOM | Lack of Cohesion in Methods | Method pairs sharing no attributes | Cohesive | Scattered |
| LCOM3 | Lack of Cohesion (Alt) | Alternative cohesion formula | Cohesive | Scattered |
| LOC | Lines of Code | Total lines in class/file | Smaller | Larger |
| MFA | Functional Abstraction | Inherited + accessible methods | Few methods | Many methods |
| MOA | Measure of Aggregation | Count of user-defined type fields | Simple types | Complex composition |
| NOC | Number of Children | Direct subclasses | No children | Many children |
| NPM | Number of Public Methods | Count of public methods | Few public | Many public |
| RFC | Response for a Class | Methods invoked by this class | Low coupling | High coupling |
| WMC | Weighted Methods per Class | Sum of method complexities | Simple class | Complex class |
Memory Aid:
- "C" metrics = Coupling (CBO, CE, CA, CBM, IC)
- "M" metrics = Methods/Complexity (AMC, WMC, RFC, MFA)
- "L" metrics = Lack of something (LCOM)
Coupling Metrics (Lower = Better):
- CBO, CE, IC, RFC β More dependencies = harder to change
Cohesion Metrics (Lower = Better for LCOM):
- LCOM, LCOM3 β Higher = methods don't work together
Complexity Metrics (Lower = Better):
- AMC, AVG_CC, MAX_CC, WMC β Higher = harder to understand/test
Encapsulation Metrics (Higher = Better for DAM):
- DAM β Higher = better information hiding
- NPM β Lower often better (smaller interface)
Size Metrics (Context-Dependent):
- LOC β Smaller often better, but context matters
- NOC, DIT β Flat hierarchies often preferable
| Refactoring | Impact on Metrics |
|---|---|
| Inline Methods | β AMC?, β AVG_CC, β CA, β CAM?, β CBO?, β CE, β DIT |
| Extract Method | β AMC?, β AVG_CC, β CA, β CAM?, β CBO?, β CE, β DIT |
| Extract Class | β CBO, β LCOM, β CA?, β NOC, β CE, β DAM?, β DIT?, β IC, β LOC, β MAX_CC?, β WMC, β RFC, β DIT |
| Inline Class | β CBO, β LCOM, β CA?, β NOC, β CE, β DAM?, β DIT?, β IC, β LOC, β MAX_CC?, β WMC, β RFC, β DIT |
| Move Method | β CBO, β CE?, β DIT, β IC, β RFC?, β WMC?, β DIT? |
| Hide Delegate | β CBO, β CE |
| Consolidate Conditional | β AVG_CC, β MAX_CC, β WMC, β LOC, β DIT, β AVG_CC, β MAX_CC?, β LOC, β DIT |
| Replace Conditional with Polymorphism | β AVG_CC, β MAX_CC, β NOC, β DIT, β IC, β WMC?, β RFC |
| Flatten Conditional | β AVG_CC, β MAX_CC, β WMC, β DIT? |
| Hide Method | β DAM, β MFA |
| Simplify Parameters | β CBO, β CE, β RFC |
| Factory Method | β NOC, β DIT, β IC, β WMC?, β RFC, β NPM |
| Push Down Method | β RFC, β WMC?, β NPM, β AVG_CC, β MAX_CC |
| Encapsulate Field | β DAM, β NPM, β MFA, β RFC, β WMC |
| Extract Subclass | β CBO, β LCOM, β NOC, β DIT?, β IC, β RFC?, β WMC?, β NPM, β LOC, β AVG_CC?, β MAX_CC, β CA, β CE |
| Inline Subclass | β CBO, β LCOM, β NOC, β DIT?, β IC, β RFC?, β WMC?, β NPM, β LOC, β AVG_CC?, β MAX_CC, β CA, β CE |
Key:
- β = decreases metric
- β = increases metric
- ? = inconsistent (depends on context)
- Empty = no significant effect
1. Patterns Have Tradeoffs
Extract Class:
- β Reduces coupling (CBO β) and improves cohesion (LCOM β)
- β Increases number of classes (NOC β)
Factory Method:
- β Improves extensibility
- β Increases complexity (WMC β, RFC β, NPM β)
2. Context Matters
Same refactoring affects different codebases differently (notice the "?" markers). A blind application of patterns can make things worse.
3. No Silver Bullets
Every pattern improves some metrics while degrading others. Design is about intelligent tradeoffs, not universally "better" solutions.
To Reduce CBO (Coupling Between Objects):
- Extract Class
- Move Method
- Hide Delegate
- Simplify Parameters
- Extract Subclass
To Reduce LCOM (Lack of Cohesion):
- Extract Class
- Extract Subclass
To Reduce Cyclomatic Complexity:
- Extract Method
- Consolidate Conditional
- Replace Conditional with Polymorphism
- Flatten Conditional
- Push Down Method
Warning: Reducing complexity often increases class count (NOC). Choose based on project needs.
Learning good patterns isn't enough. You must also recognize bad patterns.
Symptom: One class does everything.
// β 5000-line class that handles UI, business logic, and data access
class Application {
void handleUserInput() { ... }
void processBusinessRules() { ... }
void saveToDatabase() { ... }
void generateReport() { ... }
void sendEmail() { ... }
// ... 200 more methods
}Fix: Apply Single Responsibility β extract cohesive classes.
Metrics: High LCOM, WMC, RFC, LOC
Symptom: Tangled control flow, no clear structure.
// β Nested conditionals, gotos, complex branching
void process() {
if (condition1) {
if (condition2) {
if (condition3) {
// 50 lines of code
if (condition4) {
// Another 50 lines
}
}
}
}
}Fix: Extract Method, Simplify Conditionals, Strategy Pattern.
Metrics: High Cyclomatic Complexity
Symptom: Dead code kept "just in case."
// β Code frozen in time
void oldMethod() {
// TODO: Remove this after migration (written 5 years ago)
}Fix: Delete it. Version control has your back.
Symptom: Using same solution for every problem ("when you have a hammer, everything looks like a nail").
// β Using Singleton for everything
class UserManager extends Singleton { ... }
class ConfigManager extends Singleton { ... }
class CacheManager extends Singleton { ... }
// Everything's a singleton!Fix: Choose patterns based on problem, not familiarity.
Symptom: Using patterns when simple code suffices.
// β Overkill for simple calculator
interface OperationStrategy { ... }
class AdditionStrategy implements OperationStrategy { ... }
class SubtractionStrategy implements OperationStrategy { ... }
class CalculatorContextFactory { ... }
class OperationVisitor { ... }
// β
Simple is better
class Calculator {
int add(int a, int b) { return a + b; }
int subtract(int a, int b) { return a - b; }
}Remember YAGNI: You Aren't Gonna Need It.
Fix: Start simple. Refactor to patterns when complexity demands it.
Design affects testability. Here's how:
Highly Cohesive Classes
// β
Single responsibility = easy to test
class PriceCalculator {
double calculatePrice(List<Item> items) { ... }
}
@Test
void testPriceCalculation() {
PriceCalculator calc = new PriceCalculator();
List<Item> items = Arrays.asList(new Item(10), new Item(20));
assertEquals(30, calc.calculatePrice(items));
}Dependency Injection
// β
Dependencies injected = easy to mock
class OrderService {
private PaymentGateway gateway;
OrderService(PaymentGateway gateway) {
this.gateway = gateway;
}
void placeOrder(Order order) {
gateway.processPayment(order.getTotal());
}
}
@Test
void testOrderPlacement() {
PaymentGateway mockGateway = mock(PaymentGateway.class);
OrderService service = new OrderService(mockGateway);
service.placeOrder(new Order(100));
verify(mockGateway).processPayment(100);
}Tight Coupling
// β Hard-coded dependency
class OrderService {
void placeOrder(Order order) {
PaymentGateway gateway = new StripeGateway(); // Can't mock!
gateway.processPayment(order.getTotal());
}
}Global State (Singletons)
// β Tests interfere with each other
class Config extends Singleton {
private Map<String, String> settings;
}
// Test 1 modifies singleton, affects Test 2Event-Driven Systems
// β Complex async flows
eventBus.publish(new OrderPlaced());
// How do you test that InventoryService, ShippingService,
// and EmailService all reacted correctly?| Pattern | Testing Difficulty | Strategy |
|---|---|---|
| Factory | Easy | Mock the factory |
| Strategy | Easy | Test each strategy independently |
| Singleton | Hard | Use dependency injection, avoid global state |
| Observer | Medium | Mock observers, verify notifications |
| Decorator | Easy | Test each decorator layer |
| Template Method | Easy | Test concrete implementations |
| Facade | Easy | Mock subsystem |
| Adapter | Easy | Mock adaptee |
Classic patterns evolved. Here's what we use now:
LAMP (2000s): Linux + Apache + MySQL + PHP
- Server-rendered HTML
- Monolithic backend
Jamstack (2020s): JavaScript + APIs + Markup
- Pre-rendered static content
- Dynamic via API calls
- CDN-first delivery
Example Stack:
- Frontend: Next.js (React)
- API: Serverless functions (AWS Lambda)
- Database: Managed service (PostgreSQL on AWS RDS)
- Hosting: Vercel/Netlify (CDN)
Old way: Always-running servers
New way: Functions triggered by events
// AWS Lambda function
exports.handler = async (event) => {
const order = JSON.parse(event.body);
await processOrder(order);
return { statusCode: 200 };
};Benefits:
- Auto-scaling
- Pay per execution
- No server management
Challenges:
- Cold starts
- Limited execution time
- Vendor lock-in
Idea: Apply microservices principles to frontend.
Structure:
- Each team owns a UI component/page
- Components deployed independently
- Integrated at runtime
Example: E-commerce site
- Team A: Product catalog
- Team B: Shopping cart
- Team C: Checkout
Tools: Webpack Module Federation, Single-SPA
Traditional: Process data in central cloud
Edge: Process data near users (CDN, local devices)
Benefits:
- Lower latency
- Reduced bandwidth
- Works offline
Patterns that fit:
- Event-Driven (IoT sensors trigger edge processing)
- Microservices (services deployed at edge)
- Space-Based (distributed memory at edge nodes)
Properties (What)
β
Principles (How)
β
Patterns (Recipes)
β
Architectures (System-wide)
| Principle | Enables Patterns | Used In Architectures |
|---|---|---|
| Information Hiding | Facade, Proxy, Adapter | Layered, Microservices |
| Single Responsibility | Strategy, Command | All (via service boundaries) |
| Open-Closed | Strategy, Template Method, Factory | Microkernel, Event-Driven |
| Liskov Substitution | All behavioral patterns | Any using polymorphism |
| Dependency Inversion | Factory, Abstract Factory | Microservices, Layered |
| Prefer Composition | Decorator, Strategy | All modern architectures |
| Interface Segregation | Adapter, Facade | Microservices |
Architecture: Microservices + Event-Driven
Services:
- User Service: Manages authentication (Factory pattern for auth strategies, Singleton for session manager)
- Product Service: Catalog management (Repository pattern, Strategy for pricing)
- Order Service: Order processing (Observer pattern for order events, Factory for order types)
- Payment Service: Payment processing (Adapter for payment gateways, Strategy for payment methods)
- Notification Service: Email/SMS (Template Method for notification templates, Observer subscribes to order events)
Communication: Event bus (RabbitMQ) β Observer pattern at system level
Principles Applied:
- Single Responsibility: Each service has one concern
- Dependency Inversion: Services depend on event interfaces
- Open-Closed: Add new payment methods without modifying Payment Service
Numbers help us measure what we achieve.
Lines of Code (LOC)
- What: Total lines in file/package/system
- Good: <300 per method, <500 per class
- Warning: Not a productivity metric! Ken Thompson: "One of my most productive days was throwing away 1,000 lines of code."
Cyclomatic Complexity (CC)
- What: Number of decision points + 1
- Formula:
CC = (if statements + loops + case branches) + 1 - Good: CC β€ 10
- Warning: Above 10 suggests method is too complex
Coupling Between Objects (CBO)
- What: Number of classes this class depends on
- Counts: Method calls, field access, inheritance, parameters, exceptions
- Good: Low CBO (fewer dependencies)
- Bad: High CBO (tightly coupled)
Lack of Cohesion (LCOM)
- What: Number of method pairs that share no attributes
- Formula: Count pairs where methods access disjoint attribute sets
- Good: LCOM = 0 (all methods share attributes)
- Bad: High LCOM (methods unrelated)
class A {
int a1, a2, a3;
void m1() { a1 = 10; a2 = 20; } // Uses a1, a2
void m2() { print(a1); a3 = 30; } // Uses a1, a3
void m3() { print(a3); } // Uses a3
}
// Method pairs:
// (m1, m2): share a1 β
// (m1, m3): share nothing β
// (m2, m3): share a3 β
// LCOM = 1 (one pair shares nothing)From studies on thousands of projects:
| Metric | Good | Acceptable | Bad |
|---|---|---|---|
| LOC per method | <50 | 50-100 | >100 |
| LOC per class | <300 | 300-500 | >500 |
| Cyclomatic Complexity | 1-10 | 11-20 | >20 |
| CBO | <5 | 5-10 | >10 |
| LCOM | 0-20 | 21-50 | >50 |
Important: Thresholds vary by domain. Embedded systems tolerate higher complexity. Web apps prefer smaller classes.
Good:
- Track trends over time
- Identify refactoring candidates
- Set team goals (reduce average CC)
Bad:
- Measuring individual productivity by LOC
- Rigid enforcement without context
- Gaming metrics (adding trivial methods to lower CC)
Patterns solve problems. No problem? No pattern.
// β Over-engineered for current needs
interface DatabaseStrategy { ... }
class MySQLStrategy implements DatabaseStrategy { ... }
class PostgresStrategy implements DatabaseStrategy { ... }
class DatabaseFactory { ... }
class DatabaseProxy { ... }
// Current reality: Only using MySQL, no plans to change
// β
Start simple
class Database {
void connect() { /* MySQL connection */ }
}
// Refactor to Strategy when you actually need multiple databasesMartin Fowler's Rule of Three:
- First time: Just write the code
- Second time: Grimace at duplication but duplicate anyway
- Third time: Refactor to pattern
Why? Early abstraction often abstracts the wrong things. Wait until the pattern emerges naturally.
Every pattern has costs:
- More classes (overhead)
- Indirection (harder to trace)
- Learning curve (team must understand pattern)
Ask:
- Does this pattern solve a real problem?
- Is the problem frequent enough to warrant the complexity?
- Can the team maintain this?
You might be over-engineering if:
- You have more interface/abstract classes than concrete classes
- Your class diagram looks like a maze
- New developers need days to understand basic flow
- You can't explain why a pattern is there
Better approach: Evolutionary design β refactor to patterns as needs emerge.
No design is universally "best." Every choice trades one quality for another:
- Microservices: Scalability vs. Complexity
- Caching: Performance vs. Consistency
- Inheritance: Reuse vs. Coupling
- Patterns: Flexibility vs. Simplicity
Your job: Choose tradeoffs appropriate for your context.
Design isn't done once. As requirements evolve:
- Code smells appear
- Patterns become inappropriate
- New patterns become necessary
Kent Beck: "Make the change easy, then make the easy change."
Best design in world fails if team can't maintain it:
- Junior team? Keep it simple.
- Distributed team? Emphasize loose coupling.
- High turnover? Prioritize clarity over cleverness.
Financial system β Video game β Embedded system
- Finance: Correctness, auditability
- Gaming: Performance, low latency
- Embedded: Resource constraints, reliability
Patterns and metrics vary by domain.
Design = Managing Complexity
Three tools:
- Properties tell you what good looks like (cohesion, low coupling, information hiding)
- Principles tell you how to get there (SOLID, composition over inheritance)
- Patterns give you proven solutions (Gang of Four, architectural patterns)
The Process:
Problem
β
Apply Principles β Guide Design Decisions
β
Consider Patterns β Implement Solution
β
Measure with Metrics β Verify Quality
β
Refactor Based on Data β Continuous Improvement
Remember:
- Start simple, refactor to patterns when needed
- Measure objectively (metrics) not subjectively (feelings)
- Design for your team and context, not textbook ideals
- Patterns are tools, not goals
Final Wisdom from the Field:
Jeff Bezos (Amazon): "Expose everything through interfaces."
Kent Beck (Facebook): "Coupling is expensive but some coupling is inevitable. Eliminate coupling triggered by frequent changes."
John Ousterhout: "The most fundamental problem in computer science is problem decomposition."
Now go design something beautiful.
- Gamma et al. Design Patterns: Elements of Reusable Object-Oriented Software. 1994.
- Martin, Robert. Clean Architecture. 2017.
- Ousterhout, John. A Philosophy of Software Design. 2021.
- Brooks, Frederick. The Mythical Man-Month. 1975.
- Parnas, David. "On the Criteria to Be Used in Decomposing Systems into Modules." 1972.
- Fowler, Martin. Refactoring: Improving the Design of Existing Code. 2018.
- Peng & Menzies. "Defect Reduction Planning (using TimeLIME)." IEEE TSE, 2021.
- Software Engineering: A Modern Approach. https://softengbook.org/chapter5
- Meyer, Bertrand. Object-Oriented Software Construction. 1997.