Writing Maintainable & Scalable Code Architecture
Reviewed & published by Brayan K
Code architecture is the way you organize and structure a JavaScript application — its files, modules, and layers — so the codebase stays easy to understand, change, and grow as the project scales.
Part of the free JavaScript course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.
💡 Running Code Locally
While this online editor runs real JavaScript, some advanced examples may have limitations. For the best experience: Download Node.js to run JavaScript on your computer, use your browser's Developer Console (Press F12) to test code snippets, or create a .html file with <script> tags and open it in your browser.
One thing to know about this editor: it shows output that is printed straight away. Examples further down that use async/await or .then() finish after the output pane has already been drawn, so their lines do not appear here. Run those in Node or your browser console to see them.
Learn how to structure JavaScript applications that are easy to change, extend, and maintain — no matter how big the project grows.
What Is Architecture Really?
Most developers think "architecture" means fancy diagrams and complicated patterns. Not true.
- Maintainable architecture = code that is easy to change
- Scalable architecture = code that doesn't fall apart when features grow
The job of architecture is simple: Keep the codebase understandable, predictable, and safe to modify — no matter how big the project gets.
⭐ The 4 Pillars of Maintainable & Scalable Code
1. Separation of Concerns (SoC)
The most important rule in architecture. Every file/module should do one thing well — not twenty responsibilities.
❌ Bad example (beginners do this):
// login.js - ONE FILE DOING EVERYTHING
// (Written as comments because this is a shape to recognise, not code to run.)
//
// fetch('/login') <- infrastructure: talking to the network
// .then(...)
// localStorage.setItem(...) <- infrastructure: storage
// updateUI(...) <- presentation: the screen
// trackAnalytics(...) <- a third-party concern
//
// Four unrelated responsibilities in one file. Change any one of them and you
// risk all four, and none of them can be tested or reused on their own.
console.log("Responsibilities in this one file: 4");
console.log("Places you must edit to change the login screen: this file, and only this file - which sounds good until two people need to edit it at once.");
// Expected output:
// Responsibilities in this one file: 4
// Places you must edit to change the login screen: this file, and only this file - which sounds good until two people need to edit it at once.// Clean folder structure
// api/
// authApi.js
// services/
// authService.js
// ui/
// loginForm.js
// utils/
// validators.js
// Each responsibility lives in its own place
console.log("Separation of Concerns = Clean Code");2. Modularity
A modular system is like LEGO: pieces are small, pieces connect cleanly, pieces can be replaced without breaking everything.
// utils/calcTotal.js
function calcTotal(items) {
return items.reduce((sum, i) => sum + i.price, 0);
}
// Used everywhere without duplication
const cart = [
{ name: "Shirt", price: 25 },
{ name: "Pants", price: 50 }
];
console.log("Total:", calcTotal(cart));3. Clear Boundaries Between Layers
A scalable app separates logic into layers:
- UI Layer — components, views, DOM
- Service Layer — business logic
- API/Infrastructure Layer — fetch, localStorage, databases
// api/userApi.js
async function fetchUser(id) {
return { id, name: "John" };
}
// services/userService.js
async function getUser(id) {
const user = await fetchUser(id);
return { ...user, displayName: user.name.toUpperCase() };
}
// Usage
getUser(1).then(user => console.log(user));4. Consistency (Naming, Structuring, Coding Style)
A clean codebase is predictable. Always use the same naming conventions and put files in the same structure.
// ❌ Bad (random styles)
// user-service.js
// UserApi.js
// helper.js
// utils2.js
// ✔ Good (consistent)
// services/
// userService.js
// api/
// userApi.js
// utils/
// formatUser.js
console.log("Consistency = Predictability");⭐ The 3-Layer Architecture
This is the most effective structure for large JavaScript apps:
// 3-Layer Architecture
// presentation/ (UI)
// application/ (services, business rules)
// infrastructure/ (APIs, storage, databases)
console.log("3 Layers:");
console.log("1. Presentation (UI)");
console.log("2. Application (Business Logic)");
console.log("3. Infrastructure (APIs, Storage)");Presentation Layer (UI Layer)
Handles ONLY: rendering, user input, event handling
// presentation/LoginForm.jsx
// import { loginUser } from "../application/authService.js";
async function handleSubmit(email, password) {
const user = await loginUser(email, password);
console.log("Logged in:", user);
}
// Mock loginUser for demo
async function loginUser(email, password) {
return { email, name: "Demo User" };
}
// The UI has NO idea how login works internally
// It just calls a function
handleSubmit("[email protected]", "password123");Application Layer (Business Logic)
Contains: rules, validation, orchestration, workflows, state transformations
// application/authService.js
async function loginRequest(credentials) {
// Mock API call
return {
user: { email: credentials.email, active: true },
token: "abc123"
};
}
async function loginUser(email, password) {
const res = await loginRequest({ email, password });
// business rules here
if (!res.user.active) {
throw new Error("Account not active");
}
console.log("Token saved:", res.token);
return res.user;
}
loginUser("[email protected]", "pass").then(console.log);Infrastructure Layer (APIs, Databases, Storage)
Handles ONLY: HTTP requests, storage, browser APIs, third-party integrations
// infrastructure/authApi.js
async function loginRequest(credentials) {
// In real app: fetch("/api/login", { method: "POST", ... })
console.log("Making API request with:", credentials);
// Mock response
return {
success: true,
user: { id: 1, email: credentials.email }
};
}
// Key: If your backend changes — only this file updates
loginRequest({ email: "[email protected]", password: "123" })
.then(res => console.log("Response:", res));🧪 Worked Example — The 3 Layers, Actually Running
Folder diagrams only get you so far. Below is a whole three-layer app in about fifty lines, small enough to hold in your head. Read the comments first — every non-obvious line says what it does and which layer is allowed to do it — then press Run and check the output.
Watch for the single rule that makes it work: every call goes downward. The view calls the service, the service calls the API, and nothing ever calls back up.
// -- WORKED EXAMPLE - a 3-layer app small enough to read in one sitting --
// One rule to watch throughout: each layer only ever calls the layer BELOW it.
// Nothing ever reaches upward. That single constraint is what makes the app
// safe to change later.
// ============ LAYER 3: INFRASTRUCTURE ============
// Talks to the outside world. Knows nothing about business rules or the UI.
// A real one would use fetch(); this returns canned rows so the example stays
// synchronous and you can actually see the output.
const userApi = {
findById(id) {
const rows = {
1: { id: 1, first: "ada", last: "lovelace", credits: 120, active: true },
2: { id: 2, first: "grace", last: "hopper", credits: 0, active: false }
};
return rows[id] || null; // null means "no such row" - it decides nothing else
}
};
// ============ LAYER 2: APPLICATION (services) ============
// Business rules live here and ONLY here. It calls the API below it and never
// touches the DOM, console formatting, or anything a user can see.
const userService = {
getProfile(id) {
const row = userApi.findById(id); // reaching DOWN a layer: allowed
if (!row) {
return { ok: false, reason: "not found" };
}
if (!row.active) { // a business rule, not an API rule
return { ok: false, reason: "account suspended" };
}
return {
ok: true,
profile: {
id: row.id,
name: row.first + " " + row.last, // raw data -> app data
tier: row.credits >= 100 ? "gold" : "standard" // another business rule
}
};
}
};
// ============ LAYER 1: PRESENTATION (UI) ============
// Formats and displays. It has no rules of its own: it asks the service and
// renders whatever comes back. Swap console.log for DOM code and nothing
// below this layer needs to change.
const profileView = {
render(id) {
const result = userService.getProfile(id); // reaching DOWN a layer: allowed
if (!result.ok) {
console.log("[error] " + result.reason);
return;
}
const p = result.profile;
console.log("#" + p.id + " " + p.name.toUpperCase() + " (" + p.tier + ")");
}
};
profileView.render(1); // happy path
profileView.render(2); // stopped by a business rule in layer 2
profileView.render(9); // stopped by layer 3 returning null
// Why bother? Because each of these changes touches exactly ONE layer:
// swap canned rows for a real fetch() -> only userApi changes
// move the gold threshold to 200 -> only userService changes
// render into the DOM instead of logs -> only profileView changes
console.log("---");
console.log("the tier rule lives in one place:", userService.getProfile(1).profile.tier);
// Expected output:
// #1 ADA LOVELACE (gold)
// [error] account suspended
// [error] not found
// ---
// the tier rule lives in one place: goldTry the exercise the comments suggest: change the gold threshold from 120 to 200. You will find exactly one line to edit, and neither the API nor the view needs to know it happened. That is what "maintainable" actually means in practice.
🎯 Your Turn — Write the Service Layer
Two blanks, both in the middle layer, because that is where business rules belong. The layers either side are already written so you can see what the service is expected to hand over.
// 🎯 YOUR TURN - finish the middle layer
// The infrastructure layer and the UI layer are written for you. The service
// layer in between is missing the two things it exists for: the business rules.
// ---------- LAYER 3: INFRASTRUCTURE (done for you) ----------
const orderApi = {
findById(id) {
const rows = {
101: { id: 101, items: ["desk", "lamp"], subtotal: 240, country: "GB" },
102: { id: 102, items: [], subtotal: 0, country: "GB" },
103: { id: 103, items: ["mug"], subtotal: 8, country: "US" }
};
return rows[id] || null;
}
};
// ---------- LAYER 2: APPLICATION (your job) ----------
const orderService = {
summarise(id) {
const row = orderApi.findById(id);
if (!row) return { ok: false, reason: "not found" };
// 👉 1) BUSINESS RULE: an order with no items is invalid.
// Replace ___ with a condition that is true when row.items is empty.
if (___) return { ok: false, reason: "empty order" };
// 👉 2) BUSINESS RULE: shipping is free from 100 upwards, otherwise 4.99.
// Replace ___ with an expression that produces 0 or 4.99.
const shipping = ___;
return {
ok: true,
summary: {
id: row.id,
count: row.items.length,
total: row.subtotal + shipping
}
};
}
};
// ---------- LAYER 1: PRESENTATION (done for you) ----------
// Notice it contains no rules at all. It asks, then it formats.
const orderView = {
render(id) {
const result = orderService.summarise(id);
if (!result.ok) {
console.log("[error] " + result.reason);
return;
}
const s = result.summary;
console.log("#" + s.id + ": " + s.count + " item(s), pay " + s.total.toFixed(2));
}
};
orderView.render(101);
orderView.render(102);
orderView.render(103);
orderView.render(999);
// ✅ Expected output:
// #101: 2 item(s), pay 240.00
// [error] empty order
// #103: 1 item(s), pay 12.99
// [error] not foundAsk yourself where else those two rules could have gone. Put the "empty order" check in the view and every other screen has to repeat it. Put it in the API and your data layer suddenly has opinions about business policy. The middle layer is the only place it belongs.
⭐ Real-World Folder Structure (Proven to Scale)
Here is a structure used by top engineering teams:
// Real-world folder structure
const structure = {
"src/": {
"presentation/": ["components/", "pages/", "hooks/"],
"application/": {
"auth/": ["authService.js", "authValidators.js"],
"users/": ["userService.js", "userRules.js"],
"orders/": ["orderService.js"]
},
"infrastructure/": {
"api/": ["userApi.js", "authApi.js", "orderApi.js"],
"storage/": ["sessionStorage.js", "localStorageAdapter.js"]
},
"domain/": ["models/", "types/"],
"utils/": ["dateUtils.js", "stringUtils.js", "errorUtils.js"]
}
};
console.log("Folder structure:", JSON.stringify(structure, null, 2));💡 This gives you: feature-driven structure, clear boundaries, logical grouping, easy scalability, and massive maintainability.
⭐ API Adapters — The Secret to Scalability
Never call fetch() or Axios inside UI or services. Instead, build API adapters.
⭐ Dependency Direction Matters
The flow of dependencies should always go DOWNWARD:
// Dependency flow: UI → Services → API → External systems
console.log("Dependency Direction:");
console.log("UI → Services → API → External");
console.log("");
console.log("The lower layer must NEVER import the upper layer.");
console.log("");
console.log("❌ Bad: authService imports loginForm");
console.log("❌ Bad: loginForm imports authService (circular!)");
console.log("");
console.log("✔ Good: UI imports services");
console.log("✔ Good: Services import APIs");
console.log("✔ Good: APIs import http clients");
console.log("✔ Good: NOTHING imports UI");⭐ Apply the "Pure Function Core" Rule
For maximum maintainability: business logic should be pure functions, UI/infra should be thin wrappers.
// ❌ Bad architecture (mixed concerns):
let cart = [];
function badAddItemToCart(item) {
cart.push(item);
// updateUI(cart);
// logAnalytics(item);
console.log("Bad: Mixed logic, UI, analytics");
}
// ✔ Good architecture (pure functions):
function addItemToCart(cart, item) {
return [...cart, item]; // Pure function!
}
// Usage in presentation layer:
let myCart = [];
myCart = addItemToCart(myCart, { name: "Shirt", price: 25 });
myCart = addItemToCart(myCart, { name: "Pants", price: 50 });
console.log("Cart:", myCart);
console.log("Pure function = testable, predictable, reusable");⭐ Real Refactoring Example (JUNIOR → SENIOR)
Original messy function:
// ❌ Messy function (everything in one place)
async function checkout(cart) {
// const res = await fetch("/api/checkout", { ... });
// const data = await res.json();
const data = { ok: true, items: cart }; // Mock
if (!data.ok) {
alert("ERROR");
} else {
localStorage.setItem("lastOrder", JSON.stringify(data));
// window.location.href = "/thank-you";
console.log("Redirecting to thank-you page");
}
}
// This has:
// ❌ API logic
// ❌ business logic
// ❌ storage logic
// ❌ UI redirects
// ❌ error handling
// ALL IN ONE FUNCTION.
checkout([{ name: "Item", price: 10 }]);Step 1 — Extract Infrastructure Layer:
// infrastructure/orderApi.js
async function placeOrder(cart) {
console.log("API: Placing order with", cart);
// const res = await fetch("/api/checkout", { ... });
// if (!res.ok) throw new Error("Checkout failed");
// return res.json();
return { success: true, items: cart, orderId: 123 };
}
// Test it
placeOrder([{ name: "Shirt", price: 25 }]).then(console.log);Step 2 — Extract Business Logic Layer:
// Mock API
async function placeOrder(cart) {
return { success: true, items: cart, orderId: 123 };
}
// application/orderService.js
async function checkoutOrder(cart) {
const order = await placeOrder(cart);
// business rule: order must contain items
if (order.items.length === 0) {
throw new Error("Invalid order");
}
return order;
}
// Test it
checkoutOrder([{ name: "Shirt", price: 25 }]).then(console.log);Step 3 — Extract UI Layer:
// Mock service
async function checkoutOrder(cart) {
return { success: true, items: cart, orderId: 123 };
}
// presentation/checkoutUI.js
async function handleCheckout(cart) {
try {
const order = await checkoutOrder(cart);
localStorage.setItem("lastOrder", JSON.stringify(order));
console.log("Order saved! Redirecting...");
// window.location.href = "/thank-you";
} catch (err) {
alert(err.message);
}
}
// Final result:
// ✔ clean
// ✔ testable
// ✔ scalable
handleCheckout([{ name: "Shirt", price: 25 }]);⭐ Guard Your Module Boundaries
Use barrel exports (index.js) to control what other modules can access:
// auth/index.js (barrel export)
// Only export what other modules should use
// export { loginUser, logoutUser } from "./authService.js";
//
// Internal functions stay private:
// - validateCredentials
// - hashPassword
// - refreshToken
console.log("Barrel exports = controlled boundaries");
console.log("");
console.log("Public API:");
console.log("- loginUser()");
console.log("- logoutUser()");
console.log("");
console.log("Internal (private):");
console.log("- validateCredentials()");
console.log("- hashPassword()");
console.log("- refreshToken()");⭐ Avoid These Common Mistakes
// Common Architecture Mistakes:
console.log("❌ MISTAKES TO AVOID:");
console.log("");
console.log("1. Putting everything in one file");
console.log("2. Circular dependencies between modules");
console.log("3. UI components calling fetch() directly");
console.log("4. Business logic in API layer");
console.log("5. No consistent naming conventions");
console.log("6. Giant util files with unrelated functions");
console.log("7. Importing from deep paths instead of barrels");
console.log("");
console.log("✔ BEST PRACTICES:");
console.log("");
console.log("1. Separate concerns into layers");
console.log("2. Dependencies flow downward only");
console.log("3. Pure functions for business logic");
console.log("4. Consistent naming across codebase");
console.log("5. Use barrel exports for public APIs");🚀 Try It Yourself
Practice refactoring this messy function into clean layers:
// CHALLENGE: Refactor this into 3 layers
async function createUser(name, email) {
// Validation
if (!name || !email) {
alert("Name and email required");
return;
}
// API call
const response = { id: 1, name, email }; // Mock
// Store result
localStorage.setItem("newUser", JSON.stringify(response));
// Update UI
console.log("User created:", response);
return response;
}
// Your task:
// 1. Create validateUser() in application layer
// 2. Create saveUser() in infrastructure layer
// 3. Create handleCreateUser() in presentation layer
createUser("John", "[email protected]");🏆 Mini-Challenge: Add a Feature Across the Layers
The last exercise had blanks. This one has none — just a working app and a change request, which is exactly the shape of a real ticket. The test is not whether it produces the right numbers; it is whether you got there by touching one layer.
// 🎯 MINI-CHALLENGE: add a feature without breaking the layers
//
// The app below already works. Run it as-is and you get:
// #101: 2 item(s), pay 240.00
// [error] empty order
// #103: 1 item(s), pay 12.99
// [error] not found
//
// Brief: the business now needs VAT - 20% of the subtotal for orders going to
// "GB", and nothing for anywhere else. Add it so that:
// 1. the VAT rule lives in orderService and NOWHERE else
// 2. orderApi is not touched at all
// 3. orderView does no arithmetic - it only formats what it is handed
//
// Outline:
// - in summarise(): work out vat from row.country and row.subtotal
// - add vat to the returned summary, and include it in total
// - in render(): print #101: 2 item(s), vat 48.00, pay 288.00
//
// ✅ Expected output once you are done:
// #101: 2 item(s), vat 48.00, pay 288.00
// [error] empty order
// #103: 1 item(s), vat 0.00, pay 12.99
// [error] not found
const orderApi = {
findById(id) {
const rows = {
101: { id: 101, items: ["desk", "lamp"], subtotal: 240, country: "GB" },
102: { id: 102, items: [], subtotal: 0, country: "GB" },
103: { id: 103, items: ["mug"], subtotal: 8, country: "US" }
};
return rows[id] || null;
}
};
const orderService = {
summarise(id) {
const row = orderApi.findById(id);
if (!row) return { ok: false, reason: "not found" };
if (row.items.length === 0) return { ok: false, reason: "empty order" };
const shipping = row.subtotal >= 100 ? 0 : 4.99;
return {
ok: true,
summary: { id: row.id, count: row.items.length, total: row.subtotal + shipping }
};
}
};
const orderView = {
render(id) {
const result = orderService.summarise(id);
if (!result.ok) { console.log("[error] " + result.reason); return; }
const s = result.summary;
console.log("#" + s.id + ": " + s.count + " item(s), pay " + s.total.toFixed(2));
}
};
orderView.render(101);
orderView.render(102);
orderView.render(103);
orderView.render(999);If you found yourself writing * 0.2 inside render, undo it. The moment a view does arithmetic, that rule is duplicated the first time a second screen needs it, and the two copies start drifting apart the first time the rate changes.
📌 Key Takeaways
- Separation of Concerns — each file does one thing
- Modularity — small, reusable pieces
- 3-Layer Architecture — Presentation, Application, Infrastructure
- Dependencies flow downward — never import UI into services
- Pure functions — business logic should be testable
- Consistency — same naming and structure everywhere
- Barrel exports — control your module boundaries
Practice quiz
According to the lesson, what does 'maintainable architecture' mean?
- Code that is easy to change
- Code that runs the fastest
- Code with the most comments
- Code that uses the newest syntax
Answer: Code that is easy to change. The lesson defines maintainable architecture as code that is easy to change, and scalable architecture as code that doesn't fall apart when features grow.
What is the most important rule in architecture, described as every file doing one thing well?
- Modularity
- Separation of Concerns (SoC)
- Consistency
- Dependency injection
Answer: Separation of Concerns (SoC). Separation of Concerns is called the most important rule: every file/module should do one thing well, not twenty responsibilities.
What are the three layers in the 3-Layer Architecture taught here?
- Frontend, Backend, Database
- Model, View, Controller
- Presentation, Application, Infrastructure
- UI, State, Router
Answer: Presentation, Application, Infrastructure. The lesson's 3 layers are Presentation (UI), Application (business logic), and Infrastructure (APIs, storage, databases).
In which layer does the lesson say business rules and validation belong?
- Presentation Layer
- Application Layer
- Infrastructure Layer
- Database Layer
Answer: Application Layer. The Application Layer contains rules, validation, orchestration, workflows, and state transformations.
Which direction should dependencies flow, per the lesson?
- Upward: APIs import UI
- Downward: UI to Services to API to External
- Sideways between equal modules
- In a circle so all modules connect
Answer: Downward: UI to Services to API to External. Dependencies flow downward (UI to Services to API to External); a lower layer must never import an upper layer.
What does the lesson say you should NEVER call directly inside UI or services?
- console.log
- fetch() or Axios
- Array.map
- JSON.stringify
Answer: fetch() or Axios. The lesson says never call fetch() or Axios inside UI or services; instead build API adapters in the infrastructure layer.
Under the 'Pure Function Core' rule, how should business logic be written?
- As pure functions
- As global mutable variables
- Inside the UI components
- As classes with side effects
Answer: As pure functions. The Pure Function Core rule says business logic should be pure functions, while UI/infra are thin wrappers.
What technique does the lesson recommend to control what other modules can access?
- Renaming files randomly
- Barrel exports (index.js)
- Deleting unused functions
- Putting everything in one file
Answer: Barrel exports (index.js). Barrel exports (index.js) let you export only the public API while keeping internal functions private.
Which of these is listed as a common architecture mistake to avoid?
- Using pure functions for business logic
- Dependencies flowing downward
- Circular dependencies between modules
- Consistent naming conventions
Answer: Circular dependencies between modules. Circular dependencies between modules is listed among the common mistakes, along with putting everything in one file and UI calling fetch() directly.
What is the benefit of the API Adapters pattern shown in the lesson?
- It removes the need for any testing
- If your backend changes, only the adapter files update
- It makes the UI render faster
- It eliminates all error handling
Answer: If your backend changes, only the adapter files update. By building API adapters (httpGet/httpPost), backend changes only require updating those infrastructure files, keeping services and UI clean.
Continue this course
- Previous: JavaScript Design Patterns (Factory, Singleton, Strategy, etc.)
- Next: Deep Dive into JSON, Parsing & Serialization — Parse, validate, transform, and safely serialise complex JSON data
- Quick reference: JavaScript cheat sheet