Loading content...
A paradox lies at the heart of software design: the skills that make you good at creating abstractions also tempt you to create unnecessary abstractions. The more design patterns you know, the more tempting it becomes to apply them whether needed or not. The more you've been burned by inflexible designs, the more you're inclined to over-engineer for flexibility.
Simplicity checking is the discipline that counterbalances this tendency. It asks not 'Is this design sophisticated?' but 'Is this design appropriately sophisticated?' Every element of complexity must justify its existence—and if it cannot, it must be removed.
By the end of this page, you will understand how to identify unnecessary complexity, apply simplicity heuristics, distinguish essential from accidental complexity, and validate that your design achieves its goals with minimal moving parts.
Simplicity is not the absence of features or capabilities. It is the absence of unnecessary complexity. A simple design accomplishes its goals with the minimum necessary machinery. It is easy to understand, easy to modify, and easy to debug.
Einstein's Maxim Applied to Software:
"Everything should be made as simple as possible, but no simpler."
This captures the essence: we seek optimal simplicity, not maximum simplicity. A design that is too simple will fail to meet requirements or will require extensive rework later. A design that is too complex wastes effort, introduces bugs, and slows future development.
Essential vs. Accidental Complexity:
Fred Brooks distinguished two types of complexity:
Essential Complexity: Inherent in the problem itself. A tax calculation system is complex because tax law is complex. This cannot be reduced without reducing functionality.
Accidental Complexity: Introduced by our solution, not required by the problem. Using six design patterns where two would suffice. Creating inheritance hierarchies for concepts that don't share behavior. Premature optimization that obscures intent.
| Type | Source | Can It Be Reduced? | Example |
|---|---|---|---|
| Essential | Problem domain | Only by reducing scope/requirements | Calculating compound interest requires the compound interest formula |
| Accidental | Our solution choices | Yes—better design choices | Factory-of-factories for objects created only once |
| Accidental | Tooling/framework limitations | Yes—better tools or workarounds | Boilerplate required by verbose frameworks |
| Accidental | Historical technical debt | Yes—refactoring and redesign | Legacy patterns maintained for compatibility |
A well-designed system contains as much complexity as the problem requires and as little as the solution can tolerate. Simplicity checking targets accidental complexity—the complexity that we added unnecessarily and can remove.
While simplicity is somewhat subjective, we can apply objective heuristics and metrics during design validation:
Quantitative Metrics:
| Metric | What It Measures | Warning Threshold | Action If Exceeded |
|---|---|---|---|
| Class Count | Number of classes in design | 3x what seems minimum | Challenge each class's necessity |
| Abstraction Depth | Levels of inheritance/composition | 4 levels | Flatten hierarchy where possible |
| Interface Count | Number of interfaces | Interfaces > 50% of classes | Audit for interfaces without variation |
| Method Count per Class | Average methods in a class | 10 public methods | Split into focused classes |
| Parameter Count | Average parameters per method | 4 parameters | Introduce parameter objects |
| Dependency Count | Average dependencies per class | 5 injected dependencies | Challenge dependency necessity |
Qualitative Heuristics:
Certain design patterns and structures are red flags that warrant scrutiny during simplicity checking:
❌ Speculative Generality:
// The only implementation we have or planinterface IUserRepository { find(id: string): User | null;} // Unnecessary factoryinterface IUserRepositoryFactory { create(): IUserRepository;} // Unnecessary abstract factoryinterface IRepositoryFactoryFactory { createUserRepositoryFactory(): IUserRepositoryFactory; createOrderRepositoryFactory(): IOrderRepositoryFactory; // "We might need different DB someday"} // The actual code we need:const user = repos.users.find(userId); // What we have:const factory = factoryFactory .createUserRepositoryFactory();const repo = factory.create();const user = repo.find(userId); // 3 interfaces, 3 classes for one lookup✅ Appropriate Simplicity:
// If we only have one implementation // and no concrete plans for others: class UserRepository { constructor(private db: Database) {} find(id: string): User | null { return this.db.users.findUnique({ where: { id } }); } save(user: User): void { this.db.users.upsert({ where: { id: user.id }, update: user, create: user }); }} // Simple injection, simple usageclass UserService { constructor(private userRepo: UserRepository) {} getUser(id: string): User | null { return this.userRepo.find(id); }} // If we later need interfaces for testing,// TypeScript makes extraction trivialEvery abstraction has a cost: indirection, mental overhead, more files to navigate. Abstractions should exist because they provide value NOW, not because they might provide value someday. When in doubt, lean toward concreteness.
Apply this structured process during design validation to ensure appropriate simplicity:
Step 1: Element Justification Audit
For every major design element (class, interface, inheritance relationship, design pattern), answer: Why does this exist?
Acceptable answers:
Unacceptable answers:
Step 2: Layer Minimization Check
Examine the layers in your design. Common layers include:
For each layer, ask:
Merge layers that don't add distinct value. A three-layer architecture is not inherently better than a two-layer architecture.
Step 3: Interface Necessity Audit
For every interface in the design:
Interfaces with one implementation and no planned variation are candidates for removal unless:
Step 4: Pattern Justification
For every design pattern in use, document:
If you cannot articulate the concrete benefit, consider removing the pattern.
Apply this comprehensive checklist during design validation:
When the simplicity check reveals unnecessary complexity, apply these techniques to simplify:
1. Inline Classes:
If a class just wraps another class without adding behavior, inline it. A UserRepositoryWrapper that calls UserRepository with no transformation adds only confusion.
2. Collapse Hierarchies:
If an inheritance hierarchy has abstract classes with no actual shared behavior, or subclasses that override everything, flatten it. Move variations to composition or simply distinct classes.
3. Remove Dead Abstractions:
Interfaces, abstract factories, event systems, and plugin architectures created for extension that never materialized should be removed. They can be added back if the need arises.
4. Merge Small Classes:
Classes with only 2-3 methods that are always used together can often be merged. The goal is cohesive modules, not maximum class count.
5. Prefer Composition to Inheritance:
Complex inheritance hierarchies are often simpler as composed behaviors. Replace 'is-a' with 'has-a' when the relationship is about capability rather than identity.
6. Use Direct Dependencies:
If a service locator or complex factory exists for dependencies that could be directly injected, simplify to direct constructor injection.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051
// BEFORE: Unnecessary complexityinterface IUserValidator { validate(user: User): ValidationResult;} interface IUserValidatorFactory { create(config: ValidatorConfig): IUserValidator;} class CompositeUserValidator implements IUserValidator { constructor(private validators: IUserValidator[]) {} validate(user: User): ValidationResult { return this.validators.map(v => v.validate(user)).combine(); }} class EmailValidator implements IUserValidator { /* ... */ }class PasswordValidator implements IUserValidator { /* ... */ }class AgeValidator implements IUserValidator { /* ... */ } // Usage requires factory, config, composition... // AFTER: Appropriate simplicity for the actual needclass UserValidator { validate(user: User): ValidationResult { const errors: ValidationError[] = []; if (!this.isValidEmail(user.email)) { errors.push({ field: 'email', message: 'Invalid email format' }); } if (!this.isStrongPassword(user.password)) { errors.push({ field: 'password', message: 'Password too weak' }); } if (user.age < 13) { errors.push({ field: 'age', message: 'Must be at least 13' }); } return errors.length === 0 ? ValidationResult.success() : ValidationResult.failure(errors); } private isValidEmail(email: string): boolean { /* ... */ } private isStrongPassword(password: string): boolean { /* ... */ }} // Direct usageconst result = new UserValidator().validate(user); // If we later need pluggable validators, we can refactor.// For now, simple and clear.Simplicity checking is not the enemy of good design—it is the completion of good design. A design that solves the problem but drowns in unnecessary complexity is not done. The final step of design validation is ensuring that every element earns its place.
Key Takeaways:
The Balance:
Design validation requires balancing multiple concerns:
These concerns sometimes conflict. A simpler design might be less extensible. A more extensible design might be more complex. The skill of design is finding the balance point for each specific context.
You have completed the Validating the Design module. You now possess a comprehensive framework for design validation: a systematic checklist covering requirements alignment, structural integrity, behavioral correctness, operational readiness, and evolution capability; deep understanding of SOLID compliance evaluation; rigorous extensibility validation techniques; and the discipline of simplicity checking. These skills will serve you throughout your career, transforming you from a developer who creates designs to an engineer who validates them.