Problems with Bad Architecture: Stop Writing Spaghetti Code in Node.js (Beginner-Friendly Guide)
Hey there! π
If you're a beginner or intermediate developer building Node.js/Express apps, you've probably written code like this:
app.post("/users", async (req, res) => {
const user = await db.collection("users").insertOne({
email: req.body.email,
password: req.body.password
});
res.json(user);
});
Looks simple, right? But tomorrow if you want to:
- Switch from MongoDB β PostgreSQL
- Add password hashing
- Move to a different ORM
- Write unit tests without a real database
β¦everything breaks. You end up touching the same file over and over. That's spaghetti code β everything tangled together.
This is where Clean Architecture saves the day.
Remember the Hospital Visit? π₯
You don't walk straight to the lab technician or pharmacy counter when you go for treatment.
The flow is:
You (patient) β Reception β Doctor β (maybe) Lab β (maybe) Pharmacy
Each person has one clear job:
- Reception handles check-in (like your API endpoint)
- Doctor makes decisions based on rules (business logic)
- Lab & Pharmacy are tools that can change (database, external services)
If the pharmacy upgrades its billing software or is under renovation? The doctor's job stays exactly the same.
That's Clean Architecture in one sentence: Protect your core business rules so infrastructure changes don't break everything.
The Clean Architecture Layers (The Onion π§ )
Uncle Bob (Robert C. Martin) drew it like concentric circles β dependencies always point inward.
Classic Clean Architecture diagram (source: blog.cleancoder.com)
For a simple Node.js app, we simplify it to:
- Controllers (outermost) β Handle HTTP, requests/responses
- Use Cases β The brain β business rules & logic
- Repositories β Data access (abstracts the database)
- Database / External β Actual MongoDB, PostgreSQL, etc.
Arrows point inward β Inner layers know nothing about outer layers.
Let's Break Down Each Layer with Real Code
1. Controller (API Layer) β Just the Doorway πͺ
Only cares about HTTP.
// controllers/user.controller.js
async function registerUser(req, res) {
try {
const userData = req.body;
const newUser = await registerUserUseCase.execute(userData);
res.status(201).json(newUser);
} catch (error) {
res.status(400).json({ error: error.message });
}
}
- Gets request
- Calls use case
- Sends response
- No business rules, no database code here.
2. Use Case (Business Logic) β The Doctor π©Ί
This is where real decisions happen.
// usecases/registerUser.js
class RegisterUser {
constructor(userRepository) {
this.userRepository = userRepository;
}
async execute(data) {
if (!data.email) {
throw new Error("Email is required");
}
if (!data.password || data.password.length < 8) {
throw new Error("Password must be at least 8 characters");
}
// Could add: check if email already exists, hash password, etc.
const user = { email: data.email, /* hashed password later */ };
return await this.userRepository.create(user);
}
}
- Enforces rules (validation, policies)
- Orchestrates steps
- Depends only on abstractions (repository interface), not concrete DB
3. Repository (Data Access) β The Tools π§
Hides how data is stored.
// repositories/user.repository.js
class UserRepository {
async create(userData) {
// This is the ONLY place that knows about MongoDB
const result = await db.collection("users").insertOne(userData);
return { id: result.insertedId, ...userData };
}
// Later you can swap to:
// async create(userData) { return prisma.user.create({ data: userData }); }
}
Change database? Only touch this file.
Folder Structure Clean Architects Love π
src/
βββ controllers/
β βββ user.controller.js
βββ usecases/
β βββ registerUser.js
βββ repositories/
β βββ user.repository.js
βββ entities/ (optional: domain models)
β βββ user.js
βββ config/ (DB connection, etc.)
βββ database.js
Clean separation = easy to test, easy to change.
Feature-based folder structure example (inspired by Khalil Stemmler)
The Golden Rule β (Never Forget This)
Inner layers DO NOT depend on outer layers.
- Business logic never imports Express req/res
- Business logic never imports mongoose or prisma
- Database code never decides business rules
Dependencies point inward β that's why changes in outer layers (framework, DB, UI) don't break the core.
Quick Test: Did You Get It? π
Question: Tomorrow we switch from MongoDB β PostgreSQL (or add caching, or go serverless). How many files should change?
Correct answer: Only the repository layer (maybe config too).
If you said "controllers" or "use cases" β go back and re-read the golden rule! π
One-Line Summary
Clean Architecture = separating responsibilities so that changing databases, frameworks, or APIs doesn't force you to rewrite your entire business logic.
Your app becomes:
- Testable (mock repositories)
- Maintainable (change one layer)
- Independent (core lives forever)
Ready to refactor your messy Express app? Start small β extract one endpoint into controller + use case + repository. You'll feel the difference immediately.
What We Will Learn Next
In the upcoming sections of this series we will explore:
- First step in building our E-commerce API