When a project is still small, a lot of “just make it work” code can survive for quite a while.
But once the system grows, has more modules, more business flows, and more developers touching it, the problems show up quickly:
- oversized classes
- services doing too many things
- changing one area breaks another
- new features require invasive changes to old code
- tests become hard
- dependencies become tangled
This is where SOLID starts to become genuinely useful.
Quick conclusion
If you only want the short version:
SOLIDis not mainly about theoretical code beauty, but about making large systems less painful- its biggest value is reducing coupling and clarifying responsibilities
- you do not need to apply all five principles mechanically everywhere
- when used well, SOLID makes code easier to change, test, and extend with fewer chain-reaction bugs
Why do large projects become messy so easily?
As a project grows, code often falls into patterns like:
- one class validating input, querying the database, calling external APIs, and formatting responses
- business logic scattered across controllers, services, and helpers
- direct dependency on concrete implementations
- adding one new case means changing many old files
At first, the code still runs. But the long-term cost becomes very clear:
- onboarding is harder
- quick fixes are brittle
- refactoring becomes expensive
- test mocking becomes awkward
- bugs spread across unrelated areas
SOLID does not solve every architecture problem, but it prevents many very common forms of large-project chaos.
What is SOLID?
SOLID refers to five object-oriented design principles:
S- Single Responsibility PrincipleO- Open/Closed PrincipleL- Liskov Substitution PrincipleI- Interface Segregation PrincipleD- Dependency Inversion Principle
The important thing is:
- do not treat SOLID as five interview definitions to memorize
- treat it as five ways to reduce pain in growing systems
1. Single Responsibility Principle: one class should not carry too many jobs
This is often the most immediately useful principle in large projects.
Bad example:
export class UserService {
async createUser(data: CreateUserDto) {
if (!data.email.includes("@")) {
throw new Error("Invalid email");
}
const existed = await this.userRepo.findByEmail(data.email);
if (existed) {
throw new Error("Email existed");
}
const user = await this.userRepo.create(data);
await this.mailer.sendWelcomeEmail(user.email);
return {
id: user.id,
email: user.email,
createdAt: user.createdAt,
};
}
}
One function is doing too many things:
- validating input
- querying the database
- creating the user
- sending email
- formatting output
As logic grows, this quickly becomes a blob.
A better split might involve:
- a validator
- a repository
- a use case / service
- a mail service
- a presenter / mapper
The real point of SRP is not “split everything into more files at any cost.”
The real point is:
- each class or module should have a reasonably clear responsibility
2. Open/Closed Principle: adding new behavior is often safer than editing old behavior
In large projects, one very common source of bugs is:
- adding new features by stuffing more
if/elsebranches into an old service
Example:
if (paymentMethod === "momo") {
// momo logic
} else if (paymentMethod === "vnpay") {
// vnpay logic
} else if (paymentMethod === "stripe") {
// stripe logic
}
At first this seems fine. But as the number of cases grows, stability drops quickly.
A better OCP approach is:
- define a common abstraction
- give each provider its own implementation
- add a new provider by adding a new class instead of rewriting the old service
The practical value:
- lower risk of breaking existing flows
- easier extension
- clearer reviews
3. Liskov Substitution Principle: inheritance is dangerous when replacements do not actually behave safely
This principle often feels abstract at first, but it matters a lot in real systems.
If a child class cannot safely replace its parent type, the design is likely wrong.
Bad example:
- the abstraction says every notifier can send a message
- but one real implementation throws on cases that the caller does not expect
- the caller thinks it is using a common contract, but runtime behavior differs sharply
In large systems, this makes:
- abstractions meaningless
- higher-level code depend on too many implementation details
To avoid that, contracts must be clear:
- what the method accepts
- what it returns
- what it guarantees
- what it does not support
If an implementation does not fit the shared contract, that usually means:
- the abstraction is wrong
- or inheritance is being forced where it should not be
4. Interface Segregation Principle: do not make code depend on things it does not need
An oversized interface creates unnecessary dependency pressure.
Bad example:
interface StorageService {
upload(): Promise<void>;
download(): Promise<void>;
delete(): Promise<void>;
generatePublicUrl(): string;
syncToBackupServer(): Promise<void>;
}
Not every consumer of storage needs all of that.
If one module only needs upload, it should not depend on a huge interface.
In large projects, oversized interfaces often cause:
- awkward test mocks
- harder implementation swaps
- classes implementing methods they do not really need
A better ISP approach is to split interfaces by real use case.
For example:
FileUploaderFileDownloaderPublicUrlGenerator
That way each consumer depends only on what it actually uses.
5. Dependency Inversion Principle: business logic should not be glued to concrete implementations
This principle matters a lot when a project depends on:
- databases
- queues
- mail providers
- payment providers
- external APIs
Bad example:
export class CreateOrderService {
private mailer = new SendGridMailer();
private payment = new StripePaymentService();
}
The business logic is tied directly to concrete implementations.
That leads to:
- harder tests
- harder provider swaps
- harder mocking
- dependency spread
A better DIP approach is to depend on abstractions:
export class CreateOrderService {
constructor(
private readonly mailer: Mailer,
private readonly paymentService: PaymentService,
) {}
}
Now:
- business logic does not care whether mail is SendGrid or SES
- payment can be Stripe or something else
This matters a lot in large systems because concrete implementations change more often than the core business rules.
What kinds of problems does SOLID reduce in large projects?
When used well, SOLID usually helps reduce:
- oversized and unreadable classes
- tight coupling between modules
- editing one area and breaking many others
- difficult unit testing
- difficulty swapping providers or external integrations
- difficulty splitting work across multiple developers
- difficulty extending business flows
That is the real value of SOLID in larger codebases.
Do not apply SOLID dogmatically
This is the opposite mistake people often make.
They learn SOLID and suddenly:
- create interfaces for everything
- split every class into too many layers
- abstract too early
- turn a small code path into a mini-framework
The result:
- code becomes harder to read
- flow becomes fragmented
- debugging becomes slower
SOLID only helps when it makes code:
- clearer
- less tightly coupled
- easier to change
If your “SOLID” refactor makes the code more confusing, it is probably the wrong tradeoff.
Where should you start applying SOLID?
You do not need to refactor an entire system in one go.
A practical place to start is:
- split services that are clearly doing too much
- reduce direct business dependency on external implementations
- stop growing giant
if/elsetrees in business flows that are already expanding
Those three changes alone often make a big difference.
A very useful mindset
Before writing or changing an important module, ask:
- is this module taking on too many responsibilities?
- if a new use case appears, will I have to rewrite old code or add a new extension point?
- does the abstraction actually fit all implementations safely?
- is this class depending on things it does not really need?
- is business logic tied too closely to a framework or vendor?
That is a much more practical way to use SOLID than memorizing textbook definitions.
When is SOLID especially worth the effort?
SOLID becomes especially valuable when:
- the project will live for a long time
- multiple developers work on it
- there are many external integrations
- business logic keeps growing
- stable testing matters
If the code is only a tiny short-lived script or prototype, full SOLID pressure is not always worth it.
Conclusion
SOLID does not magically make a large project simple. But when applied well, it removes many common large-codebase problems:
- messy code
- high coupling
- poor testability
- painful extension
- fragile changes
The real value of SOLID is not in the five letters themselves, but in helping you:
- separate responsibilities more clearly
- reduce hard dependencies
- extend systems more safely
- keep code alive longer as the project grows
If you need a starting point, begin with the most painful area in your current codebase instead of trying to make everything “perfectly SOLID” on day one.