About this article
This article is the first deep dive in the “Application Architecture” category of the Architecture Crash Course for the Generative-AI Era series, covering class design.
One layer inside module design — at code-writing level — and the design judgment that appears most frequently in daily development. The article covers SOLID principles, inheritance vs composition, testability, design patterns, code-complexity numeric gates, and AI-era design — anchored on the core question: “is there only one reason this class would change?”
What is class design in the first place
Class design is, in a nutshell, “deciding what responsibilities each part (class) of a program has and how to combine them.”
Think of Lego bricks. If each brick (class) is small and simple, rearranging and repairing them is easy. But if you build one massive molded piece, a single broken section means throwing away the whole thing. Class design works the same way: sticking to “one class, one responsibility” keeps the blast radius of changes small, extending the code’s lifespan.
Why class design matters
What happens if class design is neglected? Fixing one feature breaks another, and each fix spawns new bugs in a chain. The “just throw it into the User class for now” code written as a junior tortures the successor five years later — a scene visible everywhere in the industry.
Class-design quality is no exaggeration to call the lifespan of the code. The shared language for this is the SOLID principles — five rules forming OO’s foundation.
SOLID principles
SOLID is the acronym Robert C. Martin (Clean Architecture’s proposer) compiled for the five OO design principles. Each looks obvious individually; observing all five produces “change-resilient code” empirically.
flowchart LR
S["S: Single Responsibility<br/>1 class = 1 responsibility"]
O["O: Open/Closed<br/>Open to extension, closed to modification"]
L["L: Liskov Substitution<br/>Derived replaceable with base"]
I["I: Interface Segregation<br/>Fine-grained IF"]
D["D: Dependency Inversion<br/>Depend on abstraction"]
GOAL([Change-resilient code])
S --> GOAL
O --> GOAL
L --> GOAL
I --> GOAL
D --> GOAL
classDef principle fill:#dbeafe,stroke:#2563eb;
classDef goal fill:#fef3c7,stroke:#d97706,stroke-width:2px;
class S,O,L,I,D principle;
class GOAL goal;
| Letter | Principle | Meaning |
|---|---|---|
| S | Single Responsibility | One class, one responsibility |
| O | Open/Closed | Open to extension, closed to modification |
| L | Liskov Substitution | Derived classes substitutable for base |
| I | Interface Segregation | Split interfaces fine-grained |
| D | Dependency Inversion | Depend on abstractions |
S: Single Responsibility
One class holds one responsibility. Equivalently, “there is only one reason this class would change.” A UserService holding authentication, profile updates, and email sending requires modification when any of authentication, profile, or email specs change — and each change risks breaking the others.
| ❌ Bad | ✅ Good |
|---|---|
| UserService handles auth, profile, email all together | Split into AuthService / ProfileService / NotificationService |
The number of responsibilities = the number of change axes. Narrowing to one keeps each class small, readable, testable.
O: Open/Closed
Open to extension, closed to modification. Aim for being able to add new features without modifying existing code. Branching payment methods with switch (method) requires modifying every switch when a new payment method appears — a textbook bug-introduction pattern.
Define a PaymentMethod interface; new payment methods just implement that interface. Existing code stays untouched. Polymorphism and interfaces realize this principle naturally.
L: Liskov Substitution
Derived classes should be substitutable for the base class. A design where base class Bird has fly() and Penguin overrides it to throw an exception breaks at any caller expecting Bird, violating Liskov.
Avoid this by reconsidering the inheritance — design with has-a (composition / delegation) instead of is-a. The modern best practice: “composition over inheritance.” Pick inheritance carefully.
I: Interface Segregation
Clients should not depend on methods they don’t use. Fat interfaces force impact even from spec changes to unused methods, so split per use as the principle.
IWorker { work(); eat(); sleep() } bundling “work, eat, sleep” into one interface forces classes representing robots to implement eat() and sleep(). Splitting into IWorkable / IEatable / ISleepable lets each class implement only the capabilities needed.
D: Dependency Inversion
Upper modules should not depend on lower modules. Both should depend on abstractions. The key principle in Clean Architecture: the natural dependency direction “OrderService (business logic) depends on MySQLUserRepository (DB implementation)” gets inverted.
Concretely, OrderService defines IUserRepository as an interface; MySQLUserRepository implements it. This puts business logic in a state where it doesn’t know the DB implementation. Switching DBs, mocking in tests — all handled by swapping the abstraction.
Implementation means: DI (Dependency Injection — receiving dependencies via constructor or setter) and placing the interface in the business-logic layer (Clean Architecture’s hallmark) — those are the two basics.
“Upper depends on lower” -> “lower depends on upper’s interface” is the inversion. Hence Dependency Inversion.
Composition over inheritance
In modern OO, composition is preferred over inheritance as best practice. Inheritance creates a strong coupling (is-a), so base-class changes cascade to all derived classes, and the LSP-violation trap waits.
| Approach | Trait |
|---|---|
| Inheritance | Strong coupling, LSP-violation trap, multiple-inheritance issues, hard to test |
| Composition | Loose coupling, easy to test, runtime swap, principled flexibility |
❌ class Admin extends User // Inheritance (strong coupling)
✅ class Admin { private user: User } // Composition (loose coupling)
Composition over inheritance is the rule. Inherit only for “truly variants of the same thing”; otherwise composition is more flexible.
Testability design
Easy-to-test design = good design. Hard-to-test classes have too many dependencies, vague responsibilities, or hidden side effects. Just observing these principles dramatically raises testability.
- Dependencies via constructor (DI): don’t
newdirectly; inject from outside. - Avoid global state: minimize singletons and statics.
- Separate side effects from pure logic: compute logic as pure functions, isolate I/O in thin layers.
- Confine I/O to thin layers: only boundary classes touch DB, files, external APIs.
❌ UserService news up DB / Email / Redis
✅ Inject dependencies; mock in tests
Common patterns (Design Patterns)
Knowing names of recurring design patterns lowers in-team communication cost. But making pattern application the goal becomes “just adding classes — over-engineering.”
| Pattern | Use |
|---|---|
| Repository | Abstract data access |
| Factory | Hide complex creation logic |
| Strategy | Make behavior swappable |
| Adapter | Convert existing-class interface |
| Observer | Event notification, Pub/Sub (publish/subscribe messaging) |
| Decorator | Stack functionality |
Patterns are means to a goal. Making name-attaching the goal fails.
OO pitfalls
Even knowing SOLID, falling into these anti-patterns at implementation is frequent. “God classes” and “anemic domain models” are most often seen when class design has collapsed.
- God Class: a giant class that knows everything. Violates Single Responsibility.
- Anemic domain model: only data, with all logic in service layer. OO in name only.
- Excessive inheritance trees: 4+ levels needs review. Sign of over-complex design.
- All-private, untestable: hard to test = sign of bad design.
- Util-class syndrome: collection of static methods. Becomes a function dump where nothing’s findable.
Visualizing dependencies
Objectively grasp class-design health via dependency visualization. Tools analyzing imports and dependency graphs auto-detect unintended dependencies and cycles.
| Language | Tool |
|---|---|
| Node.js / TypeScript | Dependency Cruiser / madge |
| Java / Kotlin | ArchUnit |
| Python | import-linter |
| Go | go-cleanarch |
Cyclic dependencies are signs of design collapse. Discover A->B->C->A and reconsider responsibility separation.
Code-complexity numeric gates
Note: industry rates as of April 2026. Periodic refresh required.
Judging “good class” vaguely fails; binding via static-analysis tools (ESLint / SonarQube / Ruff) numerically is the modern standard. Industry-adopted defaults:
| Metric | Threshold | Action on overage |
|---|---|---|
| File line count | 300 | Consider splitting |
| Method line count | 50 | Extract methods |
| Public methods per class | 10 | 2+ responsibilities mixed |
| Cyclomatic complexity | 10 | Reduce if/switch; replace with polymorphism |
| Nesting depth | 3 | Early-return / guard clauses |
| Method arguments | 3 | 4+ -> parameter object |
| Inter-class dependencies | 5 | Too much fan-out -> split responsibilities |
| Copy-paste detection | 5+ duplicate lines | DRY-extract |
These are detected by SonarQube / CodeClimate / ESLint by default; “automatic CI block on PRs” is the modern style. Code left “to fix later” always becomes debt; immediate splitting on threshold-violation is the rule.
Run numeric gates as PR-time auto-checks. Don’t rely on visual review.
Class-design traps
Common patterns of failure during implementation despite knowing SOLID. All shorten code lifespan — anti-pattern regulars.
| Forbidden move | Why |
|---|---|
| Growing a God Class | ”While we’re here…” accumulates into 3000-line classes; later splitting is essentially rewrite |
| Anemic domain model | Entities hold only data, logic in service layer. Default DDD-form-only failure |
| Inheritance trees 4+ deep | LSP violations, ripple changes, hard understanding — three-fold pain. Composition over inheritance |
Classes named Util / Helper / Manager | Names that don’t state responsibility become empty boxes for anything. Clear responsibility produces concrete names |
static-method collection | Untestable, no DI, no mocking. Avoid utility classes |
| Cyclic dependencies (A→B→A) | One-class fix cascades. Auto-detect with import/no-cycle |
new in constructors (no DI) | Untestable mocking. Inject dependencies |
| Test private methods via test design | Hard to test = bad design sign. Battle through public API |
| Sharing ORM-generated Entity across all layers | DB-schema changes propagate to UI. Convert at boundaries via DTO |
| Throwing SOLID names around without grounding | Without team alignment, discussions stall. Reduce to the responsibility question, not principle names |
| Pattern application as the goal | Applying Abstract Factory where Strategy suffices; over-engineering that just adds classes |
Fat Service / anemic domain / God Class are anti-patterns seen in 95% of projects claiming “we adopted DDD.” Question “where does the logic live” instead of pattern names.
The moment you name something Util / Manager / Helper, you’ve abandoned responsibility. If a concrete name doesn’t fit, design is wrong.
AI decision axes
| AI-era favorable | AI-era unfavorable |
|---|---|
| Single-responsibility small classes / functions | Giant God Classes, long methods |
| Constructor DI, interface-mediated dependencies | new-direct, global state |
| Type / interface contracts explicit | Implicit conventions, oral tradition |
| Shallow inheritance, composition-centric | Deep inheritance hierarchies |
- Enforce single responsibility (one reason per class).
- Composition over inheritance as default (avoid strong coupling).
- DI + pure functions for side-effect separation (testability = AI-fit).
- Patterns as means, not goal (don’t make pattern-application the goal).
A 3,000-line UserService (industry case)
In an inherited project, a 3,000-line UserService contained authentication, billing, profile, email, notifications, all together. A single-line fix in an email template would break a billing test — “touch and break” state. Each new feature consumed half a day investigating “who pressing which button does what.”
Stories of inheriting similar projects: the first task is “sticky-noting what this class does”, and giving up manual splitting once stickies exceed 20. God Classes aren’t born in a day — they grow via accumulated “while we’re here, throw it in.”
The road begins the moment SOLID’s “S” is broken once; later splitting requires understanding all behaviors and rewriting. Class design is decided by “the first class’s” responsibility scope.
If you’ve grown a God Class, splitting is essentially rebuilding. Narrowing responsibility to one at the start is critical.
What you must decide — what’s your project’s answer?
Articulate your project’s answer in 1-2 sentences for each:
- Class-granularity guidance (how strictly to apply Single Responsibility)
- Dependency-injection method (constructor / DI container)
- Inheritance vs composition default
- Interface ownership layer (when adopting Clean)
- Package / namespace boundaries
- Test granularity and coverage targets
Summary
This article covered class design — SOLID, inheritance vs composition, testability, code-complexity numeric gates.
Always ask “is there one reason this class would change?”, composition over inheritance, separate side effects via DI. The 2026 realistic answer for class design including AI era.
The next article covers domain logic (Transaction Script vs DDD, Value Object, aggregates).
Back to series TOC -> ‘Architecture Crash Course for the Generative-AI Era’: How to Read This Book
I hope you’ll read the next article as well.
📚 Series: Architecture Crash Course for the Generative-AI Era (26/89)