I needed to add email notifications to a project. We already had email sending in another part of the codebase. Great, I'll reuse it.

Except I couldn't. The email code was tangled with user authentication, database queries, and template rendering. To use the email function, I'd have to import half the application. I ended up writing email sending from scratch—the third implementation in our codebase.

That's when I understood what "modular design" actually means. It's not about organizing files nicely. It's about being able to use pieces independently.

What Makes a Good Module

Two properties:

Think of Lego bricks. Each brick is a solid unit (cohesive) that connects to any other brick through a standard interface (loosely coupled). You can use a brick anywhere without needing a specific other brick.

Hide Your Internals

The most important decision: what do you expose?

// ❌ Exposes everything
export const exchangeRates = { ... };
export function fetchRates() { ... }
export function convert(amount, currency) { ... }

// Some random file starts using exchangeRates directly
// Now you can't change how you store rates
// ✅ Minimal public API
const rates = { ... };  // Private
function _fetchRates() { ... }  // Private

export function convertCurrency(amount, to) {
    if (!rates) _fetchRates();
    return amount * rates[to];
}
// Only this function is exposed
// Internals can change freely

When you expose data structures, other code depends on them. Then you can't change them. Hide everything except what users absolutely need.

The God Function Problem

Here's a common pattern—one function that does everything:

// ❌ Untestable, unreusable
function processUpload(file) {
    const data = fs.readFileSync(file);
    const rows = parseCSV(data);
    
    for (const row of rows) {
        if (row.email && row.isActive) {
            const user = db.query('SELECT...');
            if (!user) {
                db.insert(row);
                emailClient.send(row.email, "Welcome");
            }
        }
    }
}

To test this, you need a real file, a real database, and a real email server. You can't test the CSV parsing without also testing the database. You can't reuse the email logic elsewhere.

Split by Responsibility

// parser.js - Pure logic, easy to test
export function parseUserFile(data) {
    const rows = parseCSV(data);
    return rows.filter(r => r.email && r.isActive);
}

// userService.js - Database access
export async function getOrCreateUser(userData) {
    const existing = await db.find(userData.email);
    if (existing) return { user: existing, isNew: false };
    const user = await db.create(userData);
    return { user, isNew: true };
}

// notifier.js - Email sending
export async function sendWelcome(email) {
    await emailClient.send(email, "Welcome");
}

Now test the parser with a simple string—no database needed. Reuse the notifier in password reset. Each module is independent.

The Orchestrator

The main function just glues modules together:

// main.js
async function processUpload(file) {
    const data = fs.readFileSync(file);
    const users = parser.parseUserFile(data);
    
    for (const userData of users) {
        const { user, isNew } = await userService.getOrCreateUser(userData);
        if (isNew) {
            await notifier.sendWelcome(user.email);
        }
    }
}

This reads like a summary of the business process. Each line is a clear step. If email sending breaks, you know exactly where to look.

Dependency Injection

For even better testability, pass dependencies in instead of importing them:

class UserService {
    constructor(database) {
        this.db = database;
    }
    
    async getUser(id) {
        return this.db.query(id);
    }
}

// Production
const service = new UserService(realDatabase);

// Test
const service = new UserService(mockDatabase);

Now tests don't need a real database. They pass in a mock that returns whatever data you want to test against.

Signs Your Module Is Too Big

When you see these signs, it's time to split.

The Payoff

Well-designed modules compound over time. That email notifier you extracted? You'll use it in five more features. That parser? It becomes part of your standard toolkit.

The upfront work of thinking about boundaries saves hours of duplication and debugging later.

← Back to Debugging & Code Quality

Back to Home