Designing Small Modules That Are Easy to Reuse and Test
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:
- High cohesion: Everything inside belongs together, shares a purpose
- Low coupling: Minimal dependencies on other modules
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
- You can't describe what it does in one sentence
- Testing it requires setting up external systems
- You want to reuse part of it but can't
- Changes to one part break unrelated parts
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.