Skip to content

Organizing a Project with Modules

Knowing import and export is the easy part. Knowing how to organize a multi-file project takes practice. This lesson applies module thinking directly to BudgetBuddy’s file structure.

The guiding principle for module organization is separation of concerns — each file has one job and only that job.

BudgetBuddy has four distinct concerns:

  1. Data model — what an expense is and how it behaves
  2. Persistence — reading and writing to localStorage
  3. Network — fetching exchange rates from the API
  4. UI — rendering the DOM and handling user events

Each concern gets its own file:

budgetbuddy/
index.html
main.js ← entry point: imports from all modules, initializes the app
expense.js ← Expense class
storage.js ← saveExpenses, loadExpenses
api.js ← fetchRates
ui.js ← renderExpenses, bindEvents

The data model. Nothing in this file touches the DOM or localStorage — it only knows what an Expense is:

expense.js
export default class Expense {
static CATEGORIES = ['Food', 'Transport', 'Health', 'Entertainment', 'Other'];
#id;
#createdAt;
constructor({ id, description, amount, category, date, createdAt } = {}) {
if (!description?.trim()) throw new Error('Description is required');
if (typeof amount !== 'number' || amount <= 0) throw new Error('Amount must be positive');
this.#id = id ?? crypto.randomUUID();
this.#createdAt = createdAt ?? Date.now();
this.description = description.trim();
this.amount = amount;
this.category = category ?? 'Other';
this.date = date ?? new Date().toISOString().split('T')[0];
}
get id() { return this.#id; }
get createdAt() { return this.#createdAt; }
format() { return `${this.description}: $${this.amount.toFixed(2)}`; }
isInCategory(c) { return this.category.toLowerCase() === c.toLowerCase(); }
toJSON() {
return { id: this.#id, description: this.description, amount: this.amount,
category: this.category, date: this.date, createdAt: this.#createdAt };
}
static fromJSON(data) { return new Expense(data); }
}

Reads and writes to localStorage. It imports Expense because it needs to reconstruct instances from stored JSON:

storage.js
import Expense from './expense.js';
const KEY = 'budgetbuddy_expenses';
export function saveExpenses(expenses) {
localStorage.setItem(KEY, JSON.stringify(expenses.map(e => e.toJSON())));
}
export function loadExpenses() {
const raw = localStorage.getItem(KEY);
if (!raw) return [];
try {
return JSON.parse(raw).map(data => Expense.fromJSON(data));
} catch {
return [];
}
}

Handles the network. Nothing in this file knows about the DOM or localStorage:

api.js
const BASE_URL = 'https://open.er-api.com/v6/latest';
export async function fetchRates(baseCurrency = 'USD') {
const response = await fetch(`${BASE_URL}/${baseCurrency}`);
if (!response.ok) throw new Error(`Exchange rate fetch failed: ${response.status}`);
const data = await response.json();
return data.rates;
}

(You will fully implement this in Module 05–06. For now, notice it has no DOM code.)

Renders the DOM. It imports Expense for type awareness and accepts data as parameters:

ui.js
export function renderExpenses(expenses, containerSelector = '#expense-list') {
const container = document.querySelector(containerSelector);
if (!container) return;
container.innerHTML = expenses
.map(e => `
<li class="expense-item" data-id="${e.id}">
<span class="expense-desc">${e.description}</span>
<span class="expense-cat">${e.category}</span>
<span class="expense-amount">${e.format()}</span>
<button class="btn-delete" data-id="${e.id}">Delete</button>
</li>
`)
.join('');
}

main.js imports from all modules and wires them together. It contains the application initialization logic — no business logic of its own:

main.js
import Expense from './expense.js';
import { saveExpenses, loadExpenses } from './storage.js';
import { renderExpenses } from './ui.js';
let expenses = loadExpenses();
renderExpenses(expenses);
document.querySelector('#expense-form').addEventListener('submit', event => {
event.preventDefault();
const form = event.target;
const expense = new Expense({
description: form.description.value,
amount: parseFloat(form.amount.value),
category: form.category.value,
});
expenses.push(expense);
saveExpenses(expenses);
renderExpenses(expenses);
form.reset();
});

Set up the BudgetBuddy file structure as shown above:

  1. Create expense.js with the Expense class (default export).
  2. Create storage.js with saveExpenses and loadExpenses (named exports).
  3. Create api.js with a placeholder fetchRates that returns a hardcoded rates object for now.
  4. Create ui.js with a renderExpenses function that logs the array length.
  5. Create main.js that imports from all four files and logs 'App initialized'.
  6. Create index.html with <script type="module" src="main.js"></script> and open it in a local server.

Confirm that the app initializes without errors and each file is imported correctly.

  • One concern per file: data model, persistence, network, and UI each get their own module.
  • main.js is the entry point — it imports from other modules and initializes the app, but contains no business logic.
  • Modules that need data from other modules import it explicitly — no globals.
  • This structure scales cleanly as the app grows — each concern remains isolated.