Structuring an application
Structure JavaScript apps with MVC, MVVM, components, dependency injection, SOLID principles, and clear module boundaries.
- 01Separate concernsMove fetching, rules, storage, rendering, and input handling behind clear boundaries.
- 02Recognize architecture stylesExplain MVC, MVVM, component-based UI, and how frameworks map to those ideas.
- 03Apply clean design pressureUse dependency injection, SOLID, KISS, DRY, YAGNI, and module boundaries without over-abstracting.
Architecture means boundaries
A JavaScript app starts as a few event listeners. Then it gains fetching, validation, storage, rendering, state, tests, and several screens. Application architecture is the set of boundaries that decides which part owns each responsibility.
Structuring an application means separating data rules, rendering, input handling, side effects, and wiring so each part can be changed, tested, and replaced without dragging the whole app with it.
This lesson links back to the pattern lessons instead of re-teaching them: factories in Creational patterns, subscriptions in Observer & pub/sub, strategies and adapters in Structural & behavioral patterns, and single-source-of-truth ideas in State management.
If one person cooks, prints menus, takes orders, charges cards, and books suppliers, every change interrupts every job. A restaurant works because each role has a clear handoff. Application architecture creates the same handoffs in code.
- In real life: Kitchen recipes and ingredients
- In JavaScript: Model data and business rules
- In real life: Menu the guest reads
- In JavaScript: View rendering
- In real life: Waiter takes the order and coordinates
- In JavaScript: Controller or view-model commands
- In real life: Supplier delivers ingredients
- In JavaScript: Injected services such as storage or API clients
Where the analogy stops: A restaurant has people and rooms. A JavaScript app is still one program: the separation is made by functions, modules, objects, and data flow, not by physical walls.
| Style | Core boundary | Where you see it |
|---|---|---|
| MVC | Model holds data and rules; View renders; Controller handles input and coordinates. | Forms, older SPA code, server-rendered pages with small widgets. |
| MVVM | View-model exposes observable state and commands; binding keeps the view in sync. | Knockout-style bindings, Vue-like mental models, form-heavy screens. |
| Component-based | Self-contained UI units receive props, emit events, and compose into larger screens. | React, Vue, Svelte, Web Components, design systems. |
Keep the fundamentals nearby: functions, objects, classes, inheritance, OOP principles, modules, Web Components, and coding style. Architecture is those ideas arranged for a growing app.
Start with the one big script
STEP THROUGHThe todo app below is realistic because each line is reasonable in isolation. It loads data, clears the DOM, renders items, handles form submission, validates input, writes storage, and appends new DOM. The problem is not that any line is evil. The problem is that one function now has too many reasons to change.
function fetchTodos(url) { console.log("fetch " + url); return [{ id: 1, title: "Read architecture", done: false }];} function startTodoPage() { const todos = fetchTodos("/todos.json"); app.innerHTML = ""; for (const todo of todos) { const item = document.createElement("li"); item.textContent = todo.title; app.append(item); } form.addEventListener("submit", (event) => { event.preventDefault(); const title = input.value.trim(); if (title.length < 3) { error.textContent = "Use at least 3 characters"; return; } const next = { id: Date.now(), title, done: false }; todos.push(next); localStorage.setItem("todos", JSON.stringify(todos)); app.insertAdjacentHTML("beforeend", "<li>" + next.title + "</li>"); }); console.log(app.textContent.trim());} startTodoPage();Refactor one seam at a time. First name the rule: normalizeTitle. Then hide storage behind load and save. Then make rendering a function that receives data and a root. The startup code still wires everything together, but each unit can now be tested in isolation.
function normalizeTitle(raw) { const title = raw.trim(); if (title.length < 3) throw new Error("Use at least 3 characters"); return title;} function createTodoStore(storage) { return { load() { return JSON.parse(storage.getItem("todos") ?? "[]"); }, save(todos) { storage.setItem("todos", JSON.stringify(todos)); }, };} function renderTodoList(root, todos) { const list = document.createElement("ul"); for (const todo of todos) { const item = document.createElement("li"); item.textContent = todo.title; list.append(item); } root.replaceChildren(list);} const fakeStorage = { value: "[]", getItem() { return this.value; }, setItem(_key, value) { this.value = value; },};const todos = [{ id: 1, title: normalizeTitle(" Write tests "), done: false }];const store = createTodoStore(fakeStorage);store.save(todos);renderTodoList(app, todos);console.log(JSON.parse(fakeStorage.value)[0].title);console.log(app.textContent.trim());Step through the same todo feature after splitting validation, data, storage, and rendering responsibilities. Each boundary has one reason to change.
script
const title = raw.trim(); if (title.length < 3) throw new Error("Use at least 3 characters"); return title;} function addTodo({ todos, title, storage, render }) { const cleanTitle = normalizeTitle(title); const nextTodos = [...todos, { id: todos.length + 1, title: cleanTitle }]; storage.save(nextTodos); render(nextTodos); return nextTodos;} const storage = { save: (todos) => console.log("saved:" + todos.length) };const render = (todos) => console.log("rendered:" + todos.length);const state = addTodo({ todos: [], title: " Plan modules ", storage, render });console.log(state.length);The line-by-line replay is deliberately small. Line 8 validates. Line 9 creates the next model state. Lines 9 and 10 call injected collaborators. This is the same feature, but the dependencies are visible.
MVC and MVVM on the same feature
RUNNABLE DOMMVC names three roles. The model owns data and rules. The view renders and reports user intent. The controller handles input and coordinates model plus view. In the runnable example, the controller exposes an add method so the test path and the submit path share the same model rule.
function createTodoModel(initial = []) { const todos = [...initial]; return { all() { return [...todos]; }, add(title) { const clean = title.trim(); if (clean.length < 3) throw new Error("Use at least 3 characters"); todos.push({ id: todos.length + 1, title: clean, done: false }); }, };} function createTodoView(root) { root.innerHTML = '<form><input aria-label="Todo"><button>Add</button></form><ul></ul>'; const form = root.querySelector("form"); const input = root.querySelector("input"); const list = root.querySelector("ul"); return { onAdd(handler) { form.addEventListener("submit", (event) => { event.preventDefault(); handler(input.value); input.value = ""; }); }, render(todos) { list.replaceChildren(...todos.map((todo) => { const item = document.createElement("li"); item.textContent = todo.title; return item; })); }, };} function createTodoController(model, view) { const refresh = () => view.render(model.all()); view.onAdd((title) => { model.add(title); refresh(); }); refresh(); return { add(title) { model.add(title); refresh(); } };} const controller = createTodoController( createTodoModel(), createTodoView(document.querySelector("#mvc-demo")),);controller.add("Review architecture");console.log(document.querySelector("#mvc-demo li").textContent);MVVM shifts the coordination surface. The view-model exposes observable state and commands shaped for the view. The view binds input events to commands and subscribes to state. It feels familiar in frameworks with reactive state: when state changes, rendering follows.
function observableState(initial) { let state = initial; const listeners = new Set(); return { get() { return state; }, set(updater) { state = updater(state); listeners.forEach((listener) => listener(state)); }, subscribe(listener) { listeners.add(listener); listener(state); return () => listeners.delete(listener); }, };} function createTodoViewModel(storage) { const todos = observableState(storage.load()); return { todos, newTitle: "", setTitle(value) { this.newTitle = value; }, addTodo() { const title = this.newTitle.trim(); if (title.length < 3) return; todos.set((items) => [...items, { id: items.length + 1, title, done: false }]); storage.save(todos.get()); this.newTitle = ""; }, };} function bindTodoView(root, viewModel) { root.innerHTML = '<input aria-label="Todo"><button>Add</button><ul></ul>'; const input = root.querySelector("input"); const button = root.querySelector("button"); const list = root.querySelector("ul"); input.addEventListener("input", () => viewModel.setTitle(input.value)); button.addEventListener("click", () => { viewModel.addTodo(); input.value = viewModel.newTitle; }); viewModel.todos.subscribe((todos) => { list.replaceChildren(...todos.map((todo) => { const item = document.createElement("li"); item.textContent = todo.title; return item; })); });} const memoryStorage = { load: () => [], save: (todos) => console.log("saved:" + todos.length) };const vm = createTodoViewModel(memoryStorage);bindTodoView(document.querySelector("#mvvm-demo"), vm);vm.setTitle("Review architecture");vm.addTodo();console.log(document.querySelector("#mvvm-demo li").textContent);React is not literally MVC, but React components still separate render output from state owners and event handlers. Vue often feels MVVM-like because templates bind to reactive state. Angular combines components with dependency injection. The labels are useful when they help you find the boundary.
Component-based architecture
PROPS CHANGEA component is a self-contained UI unit. In plain JavaScript it might be a function that returns DOM or HTML. In React, Vue, Svelte, or Web Components, the component has more tooling, but the design rule stays the same: props in, events out.
Prefer composition over inheritance. A presentational component receives props and emits intent. A container owns data, passes props down, handles events, and re-renders children. Data flows one way, so a child does not secretly edit its parent.
Step through a tiny component system: props go in, rendered output comes out, and a state change re-renders the child.
script
return "<li>" + (todo.done ? "Done: " : "Todo: ") + todo.title + "</li>";} function TodoList({ todos }) { return "<ul>" + todos.map((todo) => TodoItem({ todo })).join("") + "</ul>";} function render(component, props, root) { root.innerHTML = component(props);} let todos = [{ id: 1, title: "Draft lesson", done: false }];render(TodoList, { todos }, document.querySelector("#component-demo"));todos = todos.map((todo) => todo.id === 1 ? { ...todo, done: true } : todo);render(TodoList, { todos }, document.querySelector("#component-demo"));console.log(document.querySelector("#component-demo").textContent.trim());The source below is the same tiny renderer without the step player. TodoItem does not know where the data came from. TodoList composes children. The container changes todos and calls render again. In real apps, the framework optimizes the DOM update, but the mental model is the same.
function TodoItem({ todo }) { return "<li>" + (todo.done ? "Done: " : "Todo: ") + todo.title + "</li>";} function TodoList({ todos }) { return "<ul>" + todos.map((todo) => TodoItem({ todo })).join("") + "</ul>";} function render(component, props, root) { root.innerHTML = component(props);} let todos = [{ id: 1, title: "Draft lesson", done: false }];render(TodoList, { todos }, document.querySelector("#component-demo"));todos = todos.map((todo) => todo.id === 1 ? { ...todo, done: true } : todo);render(TodoList, { todos }, document.querySelector("#component-demo"));console.log(document.querySelector("#component-demo").textContent.trim());Dependency injection: pass collaborators in
SWAP STORAGEA dependency is another object or function your code needs to do its job: storage, an API client, an ID generator, a clock, a logger. Dependency injection means the caller passes that collaborator in. The module depends on a small contract instead of importing a concrete global.
function memoryStorage(seed = {}) { const data = { ...seed }; return { get(key) { return data[key] ?? "[]"; }, set(key, value) { data[key] = value; }, };} function createTodoRepository({ storage, key = "todos", nextId = () => "todo-1" }) { return { all() { return JSON.parse(storage.get(key)); }, add(title) { const todos = [...this.all(), { id: nextId(), title }]; storage.set(key, JSON.stringify(todos)); return todos.at(-1); }, };} const repo = createTodoRepository({ storage: memoryStorage() });console.log(repo.add("Inject storage").title);console.log(repo.all().length);Swap storage1stored:Swap storage
The empty memory storage run added memory-1 and now reports 1 todo.
This is also why tests use mocks and fakes. A fake storage can record set calls, return a seed list, or simulate a failure without touching browser storage. In production, a composition root wires the real storage, API client, clock, and root UI together.
function createContainer(registry) { const cache = new Map(); const container = { get(name) { if (!cache.has(name)) cache.set(name, registry[name](container)); return cache.get(name); }, }; return container;} const container = createContainer({ storage: () => { const data = { todos: "[]" }; return { get: (key) => data[key], set: (key, value) => { data[key] = value; } }; }, todos: ({ get }) => ({ all: () => JSON.parse(get("storage").get("todos")), add(title) { const todos = [...this.all(), { id: "container-1", title }]; get("storage").set("todos", JSON.stringify(todos)); return todos.at(-1); }, }),});console.log(container.get("todos").add("Container aside").title);console.log(container.get("todos") === container.get("todos"));A container can centralize object creation when wiring is genuinely repetitive. Many JavaScript apps do not need one. Explicit parameters and one composition root are easier to trace until the repeated wiring becomes real.
Clean code and SOLID in JavaScript terms
SORTERClean architecture starts with clean code: meaningful names, small functions, and code that says what it changes. SOLID is not a Java-only checklist. In JavaScript, it becomes focused functions, small objects, and replaceable strategies.
| Principle | JavaScript version | Useful check |
|---|---|---|
| KISS | Prefer the simple direct function until complexity is proven. | A named directTitle beats a factory-builder-adapter for one string transform. |
| YAGNI | Do not add plugin systems, containers, or base classes for imagined futures. | Add a strategy map when a second real strategy appears. |
| DRY | Remove duplicated knowledge, not every similar-looking line. | One validation rule is good; a vague abstraction for two unrelated forms is not. |
| SOLID | Use focused modules that can be replaced and tested through small contracts. | A storage object with get and set is easier to fake than direct localStorage calls. |
Single responsibility: split reasons to change
The first function mixes parsing, saving, and rendering. The after version names each reason to change. This is small, but the habit scales to whole modules.
function addTodoMixed(raw, storage, list) { const title = raw.trim(); if (title.length < 3) throw new Error("Use at least 3 characters"); storage.saved = title; list.push(title);} function normalizeTodoTitle(raw) { const title = raw.trim(); if (title.length < 3) throw new Error("Use at least 3 characters"); return title;}function saveTodo(storage, todo) { storage.saved = todo.title; }function renderTodo(list, todo) { list.push(todo.title); } const storage = {};const list = [];const todo = { title: normalizeTodoTitle(" Learn SOLID ") };saveTodo(storage, todo);renderTodo(list, todo);console.log(storage.saved);console.log(list.join(", "));Open/closed: extend with strategies
A strategy is a small function chosen from the outside. Adding a new discount means adding a function, not editing the stable calculation. This links back to the strategy pattern from the structural and behavioral patterns lesson.
const discountStrategies = { none: (total) => total, tenPercent: (total) => total * 0.9, fiveOff: (total) => Math.max(0, total - 5),}; function totalAfterDiscount(total, strategy) { return strategy(total).toFixed(2);} console.log(totalAfterDiscount(10, discountStrategies.tenPercent));console.log(totalAfterDiscount(10, discountStrategies.fiveOff));Liskov: replacements must keep their promises
If code accepts a rectangle, it expects setSize(4, 5) to mean width 4 and height 5. A Square subtype that ignores height breaks that contract. Prefer a smaller shared contract, such as an object with area(), when shapes do not support the same operations.
class Rectangle { setSize(width, height) { this.width = width; this.height = height; } area() { return this.width * this.height; }} class Square extends Rectangle { setSize(width) { this.width = width; this.height = width; }} function areaAfterResize(shape) { shape.setSize(4, 5); return shape.area();} console.log(areaAfterResize(new Rectangle()));console.log(areaAfterResize(new Square())); const square = (size) => ({ area: () => size * size });console.log(square(5).area());Interface segregation and dependency inversion
JavaScript does not need formal interfaces for these principles to help. Pass the smallest focused object or function. Depend on a storage with get and set, not direct localStorage calls deep inside a module.
function sendReceipt({ mailer }, address) { mailer.send(address, "Receipt");} function loadPreference(settingsStore) { return settingsStore.get("theme") ?? "system";} const fakeMailer = { sent: [], send(to, text) { this.sent.push(to + ":" + text); } };sendReceipt({ mailer: fakeMailer }, "ada@example.com");console.log(fakeMailer.sent[0]);console.log(loadPreference({ get: () => "dark" }));KISS, YAGNI, and DRY: avoid architecture theater
Over-abstracting hurts when the abstraction has no real variation yet. DRY removes duplicated knowledge, not every similar line. KISS and YAGNI say to keep the direct code until the second real use case appears.
function createFactoryBuilderStrategyAdapter(value) { return { execute: () => value.trim().toUpperCase() };} const directTitle = (value) => value.trim().toUpperCase();console.log(directTitle(" ship it "));console.log(createFactoryBuilderStrategyAdapter(" ship it ").execute());- A submit handler fetches, validates, saves, and renders all by itself.
Adding a coupon means editing a long `if/else` inside `checkoutTotal`.`Square` extends `Rectangle`, but `setSize(4, 5)` returns area 16.A component receives one huge `services` object but only calls `services.mailer.send`.A repository calls `localStorage` directly, so tests must patch browser globals.
Sort each smell by the principle it most clearly violates. Some designs have more than one problem; choose the main pressure described.
Modules, folders, and circular dependencies
BOUNDARIESFolder structure is a signal, not the architecture itself. Organizing by feature keeps a screen or domain close together. Organizing by type is easy to start and can be clear for small apps. Most professional front ends become a hybrid.
| Structure | Example | Trade-off |
|---|---|---|
| By feature | features/todos/TodoList.js, features/todos/todoRepository.js | Keeps related code close; good when features are owned by teams or screens. |
| By type | components/TodoList.js, models/todoModel.js, services/todoRepository.js | Easy to start and teach; can make feature work require many folders. |
| Hybrid | Feature folders plus shared/ for cross-cutting helpers | Often the practical default once repeated utilities are real. |
src/
features/
todos/
TodoList.js
todoModel.js
todoRepository.js
settings/
SettingsPanel.js
shared/
dom.js
storage.js
src/
components/
TodoList.js
SettingsPanel.js
models/
todoModel.js
services/
todoRepository.jsModule boundaries should point in one direction. Models should not import views to render, and views should not import models to save. A controller, view-model, or composition root can import both and wire them together. If you hit a circular import, review module resolution and then move the wiring to a higher layer.
// todoModel.jsimport { renderTodo } from "./todoView.js"; // model now knows the viewexport function addTodo(title) { const todo = { title }; renderTodo(todo); return todo;} // todoView.jsimport { addTodo } from "./todoModel.js"; // view now knows the modelbutton.addEventListener("click", () => addTodo(input.value));Common misconceptions
- “Architecture is folder names.” Folders help, but runtime dependencies and responsibilities are the real design.
- “MVC means old code.” Modern frameworks still separate data rules, rendering, and input handling.
- “MVVM means magic two-way binding everywhere.” A view-model can expose explicit commands and observable state without hiding rules.
- “Components remove the need for architecture.” Components still need state owners, effects, module boundaries, and tests.
- “Dependency injection requires a container.” Passing collaborators as parameters is DI.
- “SOLID means adding classes.” In JavaScript, functions and plain objects often express the principles better.
| Claim | Better question | Practical move |
|---|---|---|
| We need a base class for every component. | Is there shared behavior or just similar markup? | Prefer composition and shared helpers. |
| Everything should be DRY immediately. | Is this duplicated knowledge or just two similar cases? | Wait for a real pattern before abstracting. |
| The model can render itself for convenience. | Who owns data rules and who owns presentation? | Let a controller or view-model coordinate. |
| Tests need real localStorage. | What contract does the code actually need? | Inject a storage with get and set. |
Practice exercises
5 EXERCISESRead the model-only code and type the number printed by the final line.
function createTodoModel(initial = []) {
const todos = [...initial];
return { add(title) { todos.push({ title }); }, all() { return [...todos]; } };
}
const model = createTodoModel([{ title: "A" }]);
model.add("B");
console.log(model.all().length);The model starts with A, then adds B, so model.all().length is 2.
Name one concern you would extract first.
form.addEventListener("submit", async (event) => {
event.preventDefault();
const response = await fetch("/todos");
const title = input.value.trim();
localStorage.setItem("draft", title);
list.insertAdjacentHTML("beforeend", "<li>" + title + "</li>");
});A good first move is to extract validation, storage, fetching, or rendering. The handler should coordinate instead of owning every detail.
Predict the text printed by the injected fake storage.
function saveTheme(storage, theme) {
storage.set("theme", theme);
}
const fakeStorage = { value: "", set(key, value) { this.value = key + ":" + value; } };
saveTheme(fakeStorage, "dark");
console.log(fakeStorage.value);Passing the fake storage proves the function depends on the small set contract. It prints theme:dark.
Type the exact text printed by the component-style functions.
const TodoItem = ({ todo }) => todo.title;
const TodoList = ({ todos }) => todos.map((todo) => TodoItem({ todo })).join(", ");
console.log(TodoList({ todos: [{ title: "Draft" }, { title: "Review" }] }));The presentational functions receive props and return text, so the list prints Draft, Review.
Which class breaks the caller’s contract?
class Rectangle {
setSize(width, height) { this.width = width; this.height = height; }
area() { return this.width * this.height; }
}
class Square extends Rectangle {
setSize(width) { this.width = width; this.height = width; }
}
const shape = new Square();
shape.setSize(4, 5);
console.log(shape.area());Square violates the rectangle contract because it ignores the height argument. It should not be substituted where callers expect rectangle resizing.
Check your understanding
8 QUESTIONSQuestion 1 of 8In MVC, which part should enforce todo title rules?
Choose an answer to see the explanation.
Question 2 of 8What does this dependency-injected save print?
Read the code, then predictfunction saveTheme(storage, theme) { storage.set("theme", theme); } const fake = { value: "", set(key, value) { this.value = key + ":" + value; } }; saveTheme(fake, "dark"); console.log(fake.value);Choose an answer to see the explanation.
Question 3 of 8What does the Liskov example reveal?
Read the code, then predictclass Rectangle { setSize(width, height) { this.width = width; this.height = height; } area() { return this.width * this.height; } } class Square extends Rectangle { setSize(width) { this.width = width; this.height = width; } } const shape = new Square(); shape.setSize(4, 5); console.log(shape.area());Choose an answer to see the explanation.
Question 4 of 8Which statement best describes MVVM?
Choose an answer to see the explanation.
Question 5 of 8What does unidirectional component data flow mean?
Choose an answer to see the explanation.
Question 6 of 8What does this open/closed strategy code print first?
Read the code, then predictconst discounts = { tenPercent: (total) => total * 0.9 }; function totalAfterDiscount(total, strategy) { return strategy(total).toFixed(2); } console.log(totalAfterDiscount(10, discounts.tenPercent));Choose an answer to see the explanation.
Question 7 of 8Which boundary avoids a circular dependency?
Choose an answer to see the explanation.
Question 8 of 8When is a DI container worth considering in a JavaScript app?
Choose an answer to see the explanation.
Key takeaways
- Architecture is separation of responsibilities, not just folder names.
- MVC separates model, view, and controller; MVVM exposes observable state and commands through a view-model.
- Components work best with props in, events out, composition, and unidirectional data flow.
- Dependency injection is ordinary parameter passing that makes testing and swapping implementations easier.
- SOLID in JavaScript means small contracts, honest subtypes, strategies, and depending on abstractions.
- Use KISS, YAGNI, and DRY to avoid building architecture that solves imaginary problems.
One-line summary: structure JavaScript apps by making ownership and dependencies explicit, then choose the lightest pattern that keeps change local.
Up next: Designing good APIs. That lesson focuses on the public function and module shapes exposed by the boundaries you designed here.