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.
One concern per file
Section titled “One concern per file”The guiding principle for module organization is separation of concerns — each file has one job and only that job.
BudgetBuddy has four distinct concerns:
- Data model — what an expense is and how it behaves
- Persistence — reading and writing to localStorage
- Network — fetching exchange rates from the API
- 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, bindEventsexpense.js
Section titled “expense.js”The data model. Nothing in this file touches the DOM or localStorage — it only knows what an Expense is:
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); }}storage.js
Section titled “storage.js”Reads and writes to localStorage. It imports Expense because it needs to reconstruct instances from stored JSON:
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 []; }}api.js
Section titled “api.js”Handles the network. Nothing in this file knows about the DOM or localStorage:
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:
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 — the entry point
Section titled “main.js — the entry point”main.js imports from all modules and wires them together. It contains the application initialization logic — no business logic of its own:
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();});Exercise
Section titled “Exercise”Set up the BudgetBuddy file structure as shown above:
- Create
expense.jswith theExpenseclass (default export). - Create
storage.jswithsaveExpensesandloadExpenses(named exports). - Create
api.jswith a placeholderfetchRatesthat returns a hardcoded rates object for now. - Create
ui.jswith arenderExpensesfunction that logs the array length. - Create
main.jsthat imports from all four files and logs'App initialized'. - Create
index.htmlwith<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.jsis 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.