completefrontend
Open lab My progress
THE JAVASCRIPT FIELD GUIDE

Structuring an application

Structure JavaScript apps with MVC, MVVM, components, dependency injection, SOLID principles, and clear module boundaries.

By the end, you can
  • 01
    Split a big scriptMove loading, rules, saving, drawing, and input handling into separate small functions.
  • 02
    Name common app shapesExplain MVC, MVVM, and components, and say which one a framework is closest to.
  • 03
    Keep code easy to changePass helpers in, use the SOLID ideas, and keep code simple instead of adding layers you don't need yet.

Each part of an app has one job

A JavaScript app often starts small, with a few event listeners. Then it grows. It loads data, checks what the user typed, saves to storage, and draws lists on the page. It gets more screens and tests. If all of that sits in one place, every change gets harder.

Application architecture is how you split an app into parts. Each part gets one job, and you decide which part does what.

Plain definition

To structure an app, you keep these jobs apart:

  • the data and its rules,
  • drawing the page,
  • handling clicks and typing,
  • work that reaches outside the code, like saving or loading (called side effects),
  • and the code that connects the parts.

Then you can change, test, or replace one part without touching all the others.

This lesson uses ideas from earlier lessons. It links back instead of teaching them again: factories in Creational patterns, subscriptions in Observer & pub/sub, strategies and adapters in Structural & behavioral patterns, and keeping one copy of the data in State management.

Real-life analogyA restaurant splits the kitchen, the waiter, and the menu

Picture one person who cooks, prints the menus, takes orders, takes payments, and calls the suppliers. Every new task stops all the other tasks. A real restaurant gives each job to a different person, and each one hands work to the next. An app's parts can hand work to each other in the same way.

In real life: The kitchen's recipes and ingredients
In JavaScript: The model: data and its rules
In real life: The menu the guest reads
In JavaScript: The view: what the page shows
In real life: The waiter takes the order and passes it on
In JavaScript: The controller or view-model: handles input
In real life: A supplier delivers ingredients
In JavaScript: Helpers passed in, like storage or a server client

There are three common ways to split the code that runs a page. The table compares them. Read each row as "this style splits the code like this, and you see it here".

Three ways to split page code
StyleHow it splits the codeWhere you see it
MVCThe model holds the data and its rules. The view draws the page. The controller handles input and tells the other two what to do.Forms, older single-page apps, and server pages with a few small widgets.
MVVMThe view-model holds data the view can watch, plus commands the view can call. When the data changes, the view updates.Knockout, apps that feel like Vue, and screens with many form fields.
ComponentsEach small part of the page gets data (props), sends events, and fits inside bigger parts.React, Vue, Svelte, Web Components, and design systems.

You will use ideas from these lessons: functions, objects, classes, inheritance, OOP principles, modules, Web Components, and coding style. Architecture puts those ideas together for an app that keeps growing.

Start with one big script

STEP THROUGH

Here is a small todo app written as one big function, startTodoPage. Each line on its own is fine. The function does all of these jobs:

  • loads the todos (line 7),
  • clears the list and draws each todo,
  • listens for the form's submit event (line 15),
  • checks the title is at least 3 characters,
  • saves the list to localStorage (line 24),
  • and adds the new todo to the page.

No single line is wrong. The problem is that one function has many reasons to change. A new title rule, a new way to save, or a new look for the list all mean editing the same function.

One big todo scriptPop out in the code editor (opens in a new tab)JavaScript
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));    const item = document.createElement("li");    item.textContent = next.title;    app.append(item);  });   console.log(app.textContent.trim());} startTodoPage();

Fix it one piece at a time. Each step moves one job into its own function:

  1. Give the title rule a name: normalizeTitle (line 1).
  2. Put saving and loading behind load and save, inside createTodoStore (line 7).
  3. Make drawing a function, renderTodoList, that gets the todos and the place on the page to draw them (line 18).

The code at the bottom still connects the parts. But now you can test each function on its own.

Move the rule, the storage, and the drawing into their own functionsPop out in the code editor (opens in a new tab)JavaScript
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());
Left: one function called startTodoPage does four jobs: fetch, render, validate, and save. Right: three small functions, normalizeTitle for the rule, createTodoStore for storage, and renderTodoList for the view.before: one functionstartTodoPagefetchrendervalidatesave4 jobs in 1 placeafter: one job eachnormalizeTitlerulecreateTodoStorestoragerenderTodoListview3 small functions
Before, one function loads, draws, checks, and saves. After, the rule, the storage, and the drawing each get their own small function.

Three words help here:

  • State is the app's current data, like the list of todos.
  • The view turns that data into something on the page.
  • A data service loads or saves the data.

The view gets the state it should show. It should not decide where that state came from.

The data and the drawing are two separate stepsPop out in the code editor (opens in a new tab)JavaScript
const todos = [{ title: "Write tests" }];
function renderTodos(items) {
  return items.map((todo) => todo.title).join(", ");
}
console.log(todos.length);
console.log(renderTodos(todos));

Line 1 holds the todo data. Lines 2 to 4 turn any list they get into text, without changing the list. Line 5 prints 1, the number of todos. Line 6 prints Write tests, the text the view made.

Step through adding one todo
Step 0 of 10Ready
Your turn: follow the blue line

Step through adding one todo. The rule, the saving, and the drawing each live in their own function, and addTodo calls them in order.

Running in
  1. script
Next: line 1
  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);
CallStoreChangeResultBlue line: runs next. Ran: already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.

In the step-through, line 8 checks the title. Line 9 makes the new list. Lines 10 and 11 call the two helpers that were passed in: one saves, one draws. It is the same feature as before, but now you can see everything addTodo needs.

MVC and MVVM for the same todo app

RUNNABLE DOM

MVC stands for Model, View, Controller. It splits the code into three parts:

  • The model holds the data and its rules (lines 1 to 11).
  • The view draws the page and tells others what the user did (lines 13 to 34).
  • The controller handles input and tells the model and the view what to do (lines 36 to 41).

The controller also has its own add method. So a form submit and a direct call like controller.add("Review architecture") (line 47) both go through the same model rule.

An MVC todo app in plain JavaScriptPop out in the code editor (opens in a new tab)JavaScript
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);
Three parts. A View window that shows Review architecture, a controller function card, and a model cart that holds todos. Step 1: the view sends the title to the controller with onAdd. Step 2: the controller calls add on the model. Step 3: the controller calls render on the view.controllerViewReview architecturetodosmodel1. onAdd2. add3. render
The view hands the new title to the controller. The controller asks the model to add it, then tells the view to render the new list.

MVVM stands for Model, View, View-model. The view-model sits between the data and the page. It holds two things for the view:

  • data the view can watch, so it knows when to draw again (an observable, line 1),
  • and commands, which are methods the view calls, like addTodo.

The view connects its events to those commands (lines 39 and 40). It also subscribes to the data (line 44): when the data changes, the list is drawn again. Frameworks that redraw the page when data changes feel like this.

An MVVM todo app in plain JavaScriptPop out in the code editor (opens in a new tab)JavaScript
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);

Frameworks mix these ideas:

  • React is not MVC, but its components still keep drawing apart from the data and the event handlers.
  • Vue often feels like MVVM, because its templates watch data and redraw when it changes.
  • Angular uses components, and passes helpers in for you (dependency injection, later in this lesson).

The names are useful when they help you see where one part ends and the next begins.

Build the page from components

PROPS CHANGE

A component is one small, self-contained piece of the page, like one todo item. In plain JavaScript it can be a function that returns a page element. React, Vue, Svelte, and Web Components add more tools, but the rule is the same: data goes in as props (an object with the data to show), and events come out.

Rules for components
  • Build big parts from small parts (composition), instead of extending classes.
  • A presentational component only shows its props and reports what the user did.
  • A container owns the data. It passes props down, handles events, and draws its children again.
  • Data flows one way, from parent to child. A child never changes its parent's data in secret.
New props, new text on the page
Step 0 of 7Ready
Your turn: follow the blue line

Step through a tiny component system. Props go in, a list item comes out, and changing the data then rendering again updates the page.

Running in
  1. script
Next: line 1
  const item = document.createElement("li");  item.textContent = (todo.done ? "Done: " : "Todo: ") + todo.title;  return item;} function TodoList({ todos }) {  const list = document.createElement("ul");  for (const todo of todos) list.append(TodoItem({ todo }));  return list;} function render(component, props, root) {  root.replaceChildren(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());
CallStoreChangeResultBlue line: runs next. Ran: already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.

The code below is the same tiny example, without the step player:

  • TodoItem doesn't know where its data came from. It just shows it.
  • TodoList is built from TodoItems.
  • The top-level code changes todos and calls render again.

In a real app, the framework updates the page for you, but the idea is the same.

A tiny component examplePop out in the code editor (opens in a new tab)JavaScript
function TodoItem({ todo }) {  const item = document.createElement("li");  item.textContent = (todo.done ? "Done: " : "Todo: ") + todo.title;  return item;} function TodoList({ todos }) {  const list = document.createElement("ul");  for (const todo of todos) list.append(TodoItem({ todo }));  return list;} function render(component, props, root) {  root.replaceChildren(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());
Left, line 18: the props done: false go into the TodoItem function, and the page shows Todo: Draft lesson. Right, line 20: the props done: true go into the same TodoItem, and the page shows Done: Draft lesson.line 18: first renderdone: falsepropsTodoItemPageTodo: Draft lessonline 20: render againdone: truepropsTodoItemPageDone: Draft lesson
Line 19 makes a new todo with done: true. Line 20 renders again, so the same TodoItem gets new props and shows new text.

Dependency injection: pass helpers in

SWAP STORAGE

A dependency is something your code needs to do its job. It might be storage, a client that talks to the server, a function that makes IDs, a clock, or a logger.

Dependency injection means the caller passes that helper in, usually as an argument. Your code doesn't reach for a fixed global like localStorage. It only expects a small contract: a few methods with known names, like get and set.

A service keeps loading data in one small placePop out in the code editor (opens in a new tab)JavaScript
function createTodoService(getTodos) {
  return { list: () => getTodos("/todos") };
}
const getTodos = (path) => {
  console.log("GET " + path);
  return [{ title: "Write tests" }];
};
const todoService = createTodoService(getTodos);
console.log(todoService.list()[0].title);

Line 1 makes a service around a data function that is passed in. Line 2 keeps the address /todos inside the service, so callers ask for todos and never build the request. The data function prints GET /todos. Then line 9 prints Write tests.

A page connects the service and the viewPop out in the code editor (opens in a new tab)JavaScript
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);
}
function createTodoPage(service, root) {
  renderTodoList(root, service.list());
}
const root = document.createElement("div");
createTodoPage(todoService, root);
console.log(root.textContent);

createTodoPage (line 10) gets the service, loads the todos, and passes them to renderTodoList (line 11). The view builds the page elements but knows nothing about the address or the server. It uses textContent (line 5), so a todo title is always shown as plain text and never turned into HTML. Line 15 prints Write tests.

Keep the rule pure and pass in the storagePop out in the code editor (opens in a new tab)JavaScript
function normalizeTitle(raw) {
  return raw.trim();
}
function saveTodo(storage, title) {
  storage.set("todo", title);
}
const storage = { value: "", set(key, value) { this.value = key + ":" + value; } };
const title = normalizeTitle(" Write tests ");
saveTodo(storage, title);
console.log(title);
console.log(storage.value);

normalizeTitle is pure: it only turns one value into another and touches nothing else. So you can test it without a browser. saveTodo does the saving through a storage object that is passed in. The last two lines print Write tests and todo:Write tests.

Three storage cards, memory, prefilled, and logging, each with an arrow into one function card called createTodoRepository. The repository code is the same every time.storage you pass inmemoryprefilledloggingcreateTodoRepositorysame code every time
The repository code never changes. You pick the storage to pass in: with empty memory storage it ends with 1 todo, and with prefilled storage it ends with 2.
Pass in a different storage
A repository that gets its storage passed inPop out in the code editor (opens in a new tab)JavaScript
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);
What ranmemory
Added todoSwap storage
Todos in the repository1
What the storage saw
  1. stored:Swap storage
Try it yourself
Choose the storage to pass into the repository

The repository code stays the same. Only the storage object you pass in changes.

With empty storage in memory, the repository added memory-1. It now holds 1 todo.

This is dependency injection with no framework: you pass the storage in, so you can swap it for a test or for real browser storage.

This is also why tests use mocks and fakes. A fake storage can record each set call, start with a list of todos, or pretend to fail. It never touches real browser storage.

In the real app, one place at the start connects everything: the real storage, the server client, the clock, and the page. This place is called the composition root.

A tiny DI container, as an extraPop out in the code editor (opens in a new tab)JavaScript
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 DI container is an object that creates your helpers for you and hands them out by name. Line 1 makes one. Line 12 tells it how to build storage and todos. It creates each helper once and reuses it, so the last line prints true.

Many JavaScript apps don't need a container. Plain arguments and one composition root are easier to follow. Reach for a container only when you write the same connecting code again and again.

Clean code and SOLID in JavaScript

SORTER

Good app structure starts with clean code: clear names, small functions, and code that shows what it changes.

SOLID is a set of five design rules. Each letter is one rule. They are often taught with Java classes, but they work in JavaScript too. Here they mean small focused functions, small objects, and behavior you can swap.

The table lists the rules that keep design practical. Read each row as "the rule, what it means in JavaScript, and an example".

Rules that keep app design practical
RuleWhat it means in JavaScriptAn example
KISS (keep it simple)Use the plain, direct function until you really need more.A named directTitle function is clearer than a factory-builder-adapter for one small text change.
YAGNI (you aren't gonna need it)Don't build plugin systems, containers, or base classes for needs you only imagine.Add a map of strategies when a second real strategy shows up.
DRY (don't repeat yourself)Keep each fact or rule in one place. Lines that only look alike can stay.One shared title rule is good. A vague helper for two unrelated forms is not.
SOLIDBuild small parts that you can test and swap through small, clear contracts.A storage object with get and set is easier to fake than direct localStorage calls.

S, single responsibility: one reason to change

The first function, addTodoMixed (line 1), checks the title, saves it, and draws it. The functions after it give each job its own name: normalizeTodoTitle (line 8), then saveTodo and renderTodo (lines 13 and 14). The example is small, but the same habit works for whole files.

One function with three jobs, then three functionsPop out in the code editor (opens in a new tab)JavaScript
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(", "));

O, open/closed: add new behavior without editing old code

A strategy is a small function you pass in to choose the behavior. Here each discount is one strategy (line 1). To add a new discount, you add a function. You don't edit totalAfterDiscount (line 7). The code is open to new discounts but closed to edits. You saw strategies in the structural and behavioral patterns lesson.

Discounts as strategiesPop out in the code editor (opens in a new tab)JavaScript
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));

L, Liskov substitution: a stand-in must keep the promise

Code that gets a rectangle expects setSize(4, 5) to make the width 4 and the height 5. Square (line 6) extends Rectangle but ignores the height. So areaAfterResize (line 10) gets 20 for a rectangle (line 15) but 16 for a square. The square breaks the rectangle's promise.

A safer choice: give shapes a smaller shared promise, like "every shape has area()". The square function on line 18 does that, and prints 25.

A square that breaks the promise, and a safer shapePop out in the code editor (opens in a new tab)JavaScript
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());

I and D: ask for small helpers, not big ones

These two rules are interface segregation and dependency inversion. JavaScript has no formal interfaces, but the rules still help:

  • Pass in the smallest object or function the code needs.
  • Depend on any storage with get and set, not on direct localStorage calls deep inside a file.

sendReceipt (line 1) only needs a mailer with send. loadPreference (line 5) only needs something with get. Line 9 makes a small fake mailer to try it out.

Small helpers instead of one big servicePop out in the code editor (opens in a new tab)JavaScript
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: don't add layers you don't need

Extra layers hurt when nothing changes yet. These three rules keep code simple:

  • KISS (keep it simple): use the plain, direct code.
  • YAGNI (you aren't gonna need it): wait until a second real use shows up before you add a layer.
  • DRY (don't repeat yourself): keep each rule in one place. Lines that only look alike can stay.

Line 1 wraps one small text change in a big, vague name. Line 5 does the same work as a plain function. Both print SHIP IT, but the plain one is easier to read.

A simple function beats a layer you don't needPop out in the code editor (opens in a new tab)JavaScript
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());
Which SOLID rule does this break?
  • One submit handler loads data, checks the title, saves, and draws the list.
  • To add a coupon, you edit a long `if/else` inside `checkoutTotal`.
  • `Square` extends `Rectangle`, but `setSize(4, 5)` gives area 16.
  • A component gets one huge `services` object but only calls `services.mailer.send`.
  • A repository calls `localStorage` directly, so tests must replace a browser global.
Try it yourself
0 of 5 correct

Sort each problem by the rule it breaks most clearly. Some designs have more than one problem; pick the main one.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

Files, folders, and imports that go in a circle

BOUNDARIES

Folders give a hint about how an app is split, but they are not the split itself. There are two common ways to sort files:

  • By feature: all the code for one screen or topic sits together.
  • By type: all components in one folder, all models in another. This is easy for a small app.

Most bigger apps end up with a mix of both. The table compares the three choices. Read each row as "the choice, example paths, and what you gain or lose".

Folders by feature or by type
How you sortExample pathsWhat you gain or lose
By featurefeatures/todos/TodoList.js, features/todos/todoRepository.jsAll the code for one feature sits together. This works well when a team or a screen owns each feature.
By typecomponents/TodoList.js, models/todoModel.js, services/todoRepository.jsEasy to start and to explain. But one feature change can touch many folders.
MixedFeature folders, plus a shared/ folder for helpers that many features useA common choice once a helper is really used in many places.
The same files, sorted by feature and then by typetext
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.js

The dependency direction is which file imports which. It is not the way todo data moves while the app runs. Some rules for it:

  • Page code can read the data rules.
  • Data rules should not import the page, browser storage, or a server client.
  • One top file, like a page or the composition root, can import all the parts and connect them.
  • In a bigger app, the rules can ask for a small storage contract, and a browser file can provide it.

If two files import each other, move the connecting code up into a third file that imports both.

Pick folders by the changes you expect

Feature folders keep a feature's view, rules, and service together, so a change to one feature stays in one folder. Type folders make all views or all services easy to find, but one feature can be spread over many folders. Use a mix when a helper is really shared. Don't move code to shared/ just because two places use it today.

In the code below, the model imports the view (line 2), and the view imports the model (line 10). That is a circular dependency: two files that import each other.

Two files that import each otherJavaScript
// 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 just folder names.” Folders help. But the real design is which part does each job, and which file imports which.
  • “MVC means old code.” Modern frameworks still keep data rules, drawing, and input handling apart.
  • “MVVM means hidden magic everywhere.” A view-model can have plain commands and data you watch, with no hidden rules.
  • “With components, you don't need architecture.” Components still need an owner for the data, a place for side effects, clear files, and tests.
  • “Dependency injection needs a container.” Passing helpers in as arguments is already dependency injection.
  • “SOLID means writing more classes.” In JavaScript, functions and plain objects often follow the rules better.

The table turns four common claims into a better question to ask, and a step to take.

Check a claim before you follow it
The claimA better questionWhat to do
We need a base class for every component.Do they really share behavior, or just look alike?Build from small parts and share helper functions.
Everything should be DRY right away.Is this the same rule twice, or two cases that only look alike?Wait for a real repeated rule before you add a layer.
The model can draw itself; it's easier.Who owns the data rules, and who owns the page?Let a controller or view-model connect them.
Tests need real localStorage.What does the code really need from storage?Pass in a storage with get and set.

Practice exercises

5 EXERCISES
Exercise 1 · Warm-upHow many todos are in the model?

This code has only a model, with no page at all. Type the number the last line prints.

Starter codePop out in the code editor (opens in a new tab)JavaScript
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);

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · PracticeFind the mixed-up jobs

    This submit handler does many jobs. Name one job you would move into its own function first.

    Starter codePop out in the code editor (opens in a new tab)JavaScript
    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>");
    });

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticePass in a storage

      Type the text the fake storage prints.

      Starter codePop out in the code editor (opens in a new tab)JavaScript
      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);

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeRead a component that only shows data

        Type the exact text these two component functions print.

        Starter codePop out in the code editor (opens in a new tab)JavaScript
        const TodoItem = ({ todo }) => todo.title;
        const TodoList = ({ todos }) => todos.map((todo) => TodoItem({ todo })).join(", ");
        console.log(TodoList({ todos: [{ title: "Draft" }, { title: "Review" }] }));

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeFind the class that breaks the promise

          Which class breaks the promise that the calling code relies on?

          Starter codePop out in the code editor (opens in a new tab)JavaScript
          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());

          Answer, then press Check. Spacing and letter case don’t matter.

            Check your understanding

            8 QUESTIONS
            App architecture quiz · 8 questionsScore: first tries count
            1. Question 1 of 8In MVC, which part should check the rule for todo titles?

              Choose an answer to see the explanation.

            2. Question 2 of 8The storage is passed in as an argument. What does this print?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              function 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.

            3. Question 3 of 8What does this Liskov example print?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              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());

              Choose an answer to see the explanation.

            4. Question 4 of 8Which sentence best describes MVVM?

              Choose an answer to see the explanation.

            5. Question 5 of 8What does "data flows one way" mean for components?

              Choose an answer to see the explanation.

            6. Question 6 of 8The discount is passed in as a function. What does this print?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              const 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.

            7. Question 7 of 8Which setup keeps two files from importing each other in a circle?

              Choose an answer to see the explanation.

            8. Question 8 of 8When is a DI container worth it in a JavaScript app?

              Choose an answer to see the explanation.

            Key takeaways

            • Architecture means giving each part of the app one job. Folder names alone are not architecture.
            • MVC splits code into a model, a view, and a controller. MVVM adds a view-model with data to watch and commands to call.
            • Components work best when props go in, events come out, big parts are built from small parts, and data flows one way.
            • Dependency injection is just passing helpers in as arguments. It makes code easy to test and easy to swap.
            • SOLID in JavaScript means small contracts, stand-ins that keep their promises, strategies, and asking for helpers instead of grabbing globals.
            • KISS, YAGNI, and DRY stop you from building layers for problems you don't have.

            One-line summary: give each part of the app one clear job, pass its helpers in, and use the simplest pattern that keeps each change in one place.

            Up next: Designing good APIs. That lesson is about the functions and files each part shows to the rest of the app, and how to make them easy to use correctly.

            CompleteFrontend Clear concepts. Working examples.