Application Architecture

Class Design Basics — SOLID Principles and Inheritance vs Composition

Class Design Basics — SOLID Principles and Inheritance vs Composition

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;
LetterPrincipleMeaning
SSingle ResponsibilityOne class, one responsibility
OOpen/ClosedOpen to extension, closed to modification
LLiskov SubstitutionDerived classes substitutable for base
IInterface SegregationSplit interfaces fine-grained
DDependency InversionDepend 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 togetherSplit 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.

ApproachTrait
InheritanceStrong coupling, LSP-violation trap, multiple-inheritance issues, hard to test
CompositionLoose 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 new directly; 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.”

PatternUse
RepositoryAbstract data access
FactoryHide complex creation logic
StrategyMake behavior swappable
AdapterConvert existing-class interface
ObserverEvent notification, Pub/Sub (publish/subscribe messaging)
DecoratorStack 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.

LanguageTool
Node.js / TypeScriptDependency Cruiser / madge
Java / KotlinArchUnit
Pythonimport-linter
Gogo-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:

MetricThresholdAction on overage
File line count300Consider splitting
Method line count50Extract methods
Public methods per class102+ responsibilities mixed
Cyclomatic complexity10Reduce if/switch; replace with polymorphism
Nesting depth3Early-return / guard clauses
Method arguments34+ -> parameter object
Inter-class dependencies5Too much fan-out -> split responsibilities
Copy-paste detection5+ duplicate linesDRY-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 moveWhy
Growing a God Class”While we’re here…” accumulates into 3000-line classes; later splitting is essentially rewrite
Anemic domain modelEntities hold only data, logic in service layer. Default DDD-form-only failure
Inheritance trees 4+ deepLSP violations, ripple changes, hard understanding — three-fold pain. Composition over inheritance
Classes named Util / Helper / ManagerNames that don’t state responsibility become empty boxes for anything. Clear responsibility produces concrete names
static-method collectionUntestable, 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 designHard to test = bad design sign. Battle through public API
Sharing ORM-generated Entity across all layersDB-schema changes propagate to UI. Convert at boundaries via DTO
Throwing SOLID names around without groundingWithout team alignment, discussions stall. Reduce to the responsibility question, not principle names
Pattern application as the goalApplying 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 favorableAI-era unfavorable
Single-responsibility small classes / functionsGiant God Classes, long methods
Constructor DI, interface-mediated dependenciesnew-direct, global state
Type / interface contracts explicitImplicit conventions, oral tradition
Shallow inheritance, composition-centricDeep inheritance hierarchies
  1. Enforce single responsibility (one reason per class).
  2. Composition over inheritance as default (avoid strong coupling).
  3. DI + pure functions for side-effect separation (testability = AI-fit).
  4. 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.