Writing JavaScript that works is one thing. Writing JavaScript that continues to work as your team grows from one person to ten, and your user base grows from a hundred to a million, is a completely different challenge. I’ve been in situations where a small, clever script ballooned into a tangled mess that nobody dared to touch. That’s where these established code structures, or design patterns, become your best friend. They’re not magic spells, but more like reliable blueprints for organizing your code so it can grow without falling apart.
Let’s talk about the Module Pattern first. Think of it as putting your code into a box. Everything inside the box is private, safe from the outside world. You decide what goes in and, more importantly, what little window you open to let others interact with it. This is how you stop different parts of your code from accidentally interfering with each other.
Here’s how I might use it for a shopping cart. Notice how the items array and total variable are locked away inside the function. They can’t be messed with directly. The only way in is through the methods I choose to provide, like addItem or getTotal. This control is powerful.
// My shopping cart module
const ShoppingCart = (function() {
// My private, hidden data
let cartItems = [];
let cartTotal = 0;
// A private helper no one else can call
function updateTotal() {
cartTotal = cartItems.reduce((sum, item) => sum + item.price, 0);
}
// The public controls I expose
return {
addProduct: function(product) {
cartItems.push(product);
updateTotal();
console.log(`Added ${product.name}. Total is now $${cartTotal}`);
},
getCurrentTotal: function() {
return cartTotal;
},
getItemCount: function() {
return cartItems.length;
}
};
})();
// Using the module
ShoppingCart.addProduct({ name: 'Coffee Mug', price: 15 });
console.log(ShoppingCart.getCurrentTotal()); // 15
console.log(ShoppingCart.cartItems); // undefined - it's safely private
Next is the Observer Pattern. I find this incredibly useful for features where one thing happens, and multiple other parts of the app need to know about it instantly. Think of a news alert system. When a big story breaks (the subject), every subscribed news outlet (the observers) gets notified at the same time. The story doesn’t need to know who the outlets are, and the outlets don’t need to constantly check for news.
Here’s a simple version I built for a user interface. When a user logs in, I might need to update a profile widget, refresh a notification badge, and log the event. The login function just broadcasts the event. It doesn’t care who’s listening.
// My event broadcaster
class EventNotifier {
constructor() {
this.subscribers = [];
}
// Let someone listen in
subscribe(callbackFunction) {
this.subscribers.push(callbackFunction);
}
// Let someone stop listening
unsubscribe(callbackFunction) {
this.subscribers = this.subscribers.filter(sub => sub !== callbackFunction);
}
// Shout out to everyone listening
broadcast(eventData) {
this.subscribers.forEach(subscriber => subscriber(eventData));
}
}
// Using it in my app
const userEventNotifier = new EventNotifier();
// Different parts of my app subscribe
const updateUI = data => console.log(`UI: Welcome, ${data.username}!`);
const logActivity = data => console.log(`LOG: Login at ${new Date().toISOString()}`);
userEventNotifier.subscribe(updateUI);
userEventNotifier.subscribe(logActivity);
// A login happens...
userEventNotifier.broadcast({ username: 'alice', userId: 101 });
// Console shows:
// UI: Welcome, alice!
// LOG: Login at 2023-10-27T10:00:00.000Z
Sometimes, creating objects gets complicated. You might have different types of users, or different kinds of documents. This is where the Factory Pattern helps. Instead of scattering new Admin() or new Editor() all over your code, you centralize that creation logic in one place. It’s like having a dedicated front desk that handles all the paperwork for getting the right person or object set up.
If I need to create users in my system, I don’t want every part of my code to know the details of what makes an admin different from a customer. I let the factory handle it.
// Base user class
class UserAccount {
constructor(fullName, accessLevel) {
this.fullName = fullName;
this.accessLevel = accessLevel;
}
getAccessRights() {
return ['view_profile'];
}
}
// Specific user types
class SystemAdmin extends UserAccount {
getAccessRights() {
return ['view_profile', 'edit_content', 'delete_users', 'configure_system'];
}
}
class ContentAuthor extends UserAccount {
getAccessRights() {
return ['view_profile', 'edit_content'];
}
}
// The factory - the single point of creation
class AccountFactory {
static createAccount(type, fullName) {
switch(type) {
case 'admin':
return new SystemAdmin(fullName, 'administrator');
case 'author':
return new ContentAuthor(fullName, 'author');
default:
return new UserAccount(fullName, 'guest');
}
}
}
// My code just asks the factory
const adminUser = AccountFactory.createAccount('admin', 'Alice Chen');
console.log(adminUser.getAccessRights()); // Gets all admin rights
const authorUser = AccountFactory.createAccount('author', 'Bob Smith');
console.log(authorUser.accessLevel); // 'author'
Now, what about when you absolutely, positively need only one of something in your entire application? A global settings manager, a single connection pool to your database. That’s the Singleton Pattern. It ensures that no matter how many times you try to create it, you always get back the exact same instance. It prevents chaos caused by multiple copies.
Here’s how I’d ensure my app has one true source for configuration settings.
class ApplicationSettings {
constructor() {
// This is the key. If an instance already exists, return it.
if (ApplicationSettings._instance) {
return ApplicationSettings._instance;
}
// First-time setup
this.config = {
appTheme: 'dark',
serverEndpoint: 'https://api.myservice.com',
requestTimeout: 10000
};
// Save a reference to this instance
ApplicationSettings._instance = this;
return this;
}
get(settingKey) {
return this.config[settingKey];
}
update(settingKey, newValue) {
this.config[settingKey] = newValue;
}
}
// Testing it
const settingsRef1 = new ApplicationSettings();
const settingsRef2 = new ApplicationSettings();
console.log(settingsRef1 === settingsRef2); // true - they are the same object
settingsRef1.update('appTheme', 'light');
console.log(settingsRef2.get('appTheme')); // 'light' - change is visible everywhere
The Proxy Pattern is like hiring a personal assistant for an object. This assistant stands in front of the real object and can handle calls, add extra checks, or even delay talking to the real object until necessary. It’s perfect for adding validation, logging, or access control without cluttering the original object’s code.
Imagine a bank account object. The core logic for deposit and withdrawal is simple. But in the real world, you need logs, security checks, and rules. A proxy lets you add all that without touching the core account math.
// The core bank account logic
class CoreBankAccount {
constructor(initialBalance = 0) {
this.balance = initialBalance;
}
addFunds(amount) {
this.balance += amount;
return this.balance;
}
removeFunds(amount) {
if (amount > this.balance) {
throw new Error('Not enough money in account');
}
this.balance -= amount;
return this.balance;
}
}
// The proxy - the account's "assistant"
class BankAccountProxy {
constructor(realAccount, clientRole) {
this.realAccount = realAccount;
this.clientRole = clientRole;
}
addFunds(amount) {
console.log(`Attempt to deposit $${amount} by a ${this.clientRole}`);
// Extra security rule: only tellers can make huge deposits
if (this.clientRole !== 'teller' && amount > 5000) {
throw new Error('Large deposits must be made with a teller.');
}
// Call the real account, then log the result
const newBalance = this.realAccount.addFunds(amount);
console.log(`Deposit complete. Balance: $${newBalance}`);
return newBalance;
}
removeFunds(amount) {
console.log(`Attempt to withdraw $${amount}`);
const newBalance = this.realAccount.removeFunds(amount);
console.log(`Withdrawal complete. Balance: $${newBalance}`);
return newBalance;
}
}
// Using it
const myAccount = new CoreBankAccount(1000);
const myAccountProxy = new BankAccountProxy(myAccount, 'customer');
console.log(myAccountProxy.addFunds(200)); // Works fine
console.log(myAccountProxy.addFunds(10000)); // Throws an error for a customer
As your application grows, you often need different ways to do the same thing. You might accept credit cards, PayPal, or cryptocurrency. The Strategy Pattern lets you define a family of these interchangeable algorithms, wrap each one in its own object, and swap them out at runtime. It cleans up massive if-else or switch statements.
Here’s how I handle different payment methods. Each method is a strategy, and my payment context can use any of them.
// The context that will use a strategy
class PaymentProcessor {
constructor(paymentMethod) {
this.paymentMethod = paymentMethod;
}
// Ability to switch strategies on the fly
setPaymentMethod(newMethod) {
this.paymentMethod = newMethod;
}
executePayment(amount) {
return this.paymentMethod.handle(amount);
}
}
// Different strategies
class CreditCardPayment {
handle(amount) {
console.log(`Charging $${amount} to credit card.`);
// Complex card processing logic would go here
return `CC_TRX_${Date.now()}`;
}
}
class DigitalWalletPayment {
handle(amount) {
console.log(`Requesting $${amount} from digital wallet.`);
// Wallet API logic here
return `WALLET_TRX_${Date.now()}`;
}
}
class BankTransferPayment {
handle(amount) {
console.log(`Initiating $${amount} bank transfer.`);
// Bank transfer logic here
return `BANK_TRX_${Date.now()}`;
}
}
// In my checkout flow
const checkout = new PaymentProcessor(new CreditCardPayment());
console.log(checkout.executePayment(49.99)); // Uses credit card
// Customer changes their mind, wants to use PayPal (a digital wallet)
checkout.setPaymentMethod(new DigitalWalletPayment());
console.log(checkout.executePayment(49.99)); // Now uses the wallet
Finally, the Middleware Pattern. If you’ve used Express.js for Node.js, you’ve used this pattern. It’s a way to process a request through a pipeline of functions. Each function does one specific job—like checking if a user is logged in, parsing data, or logging the request—and then passes the request along to the next function in line. It makes complex processing chains simple and modular.
Let me build a simple request pipeline to show you the concept.
// The pipeline manager
class ProcessingPipeline {
constructor() {
this.stages = [];
}
// Add a stage to the pipeline
addStage(stageFunction) {
this.stages.push(stageFunction);
}
// Run the request through all stages
process(requestContext) {
const executeStage = (index) => {
if (index < this.stages.length) {
const currentStage = this.stages[index];
// Call the stage, passing the context and a function to call the next stage
currentStage(requestContext, () => executeStage(index + 1));
}
};
executeStage(0);
}
}
// Define some middleware stages
const requestLogger = (context, next) => {
console.log(`-> ${context.path} [${context.ipAddress}]`);
next(); // Move to the next stage
};
const authChecker = (context, next) => {
if (context.authToken === 'valid_token_123') {
context.user = { id: 'user1', name: 'John Doe' };
next(); // User is authenticated, proceed
} else {
throw new Error('Access denied: Invalid token');
}
};
const dataParser = (context, next) => {
if (context.rawBody) {
context.parsedData = JSON.parse(context.rawBody);
}
next();
};
// Setting up and using the pipeline
const apiPipeline = new ProcessingPipeline();
apiPipeline.addStage(requestLogger);
apiPipeline.addStage(authChecker);
apiPipeline.addStage(dataParser);
const incomingRequest = {
path: '/api/order',
ipAddress: '192.168.1.1',
authToken: 'valid_token_123',
rawBody: '{"productId": 456, "quantity": 2}'
};
try {
apiPipeline.process(incomingRequest);
console.log('Request successful. User:', incomingRequest.user);
console.log('Parsed data:', incomingRequest.parsedData);
} catch (error) {
console.error('Request failed:', error.message);
}
These patterns aren’t rules you must always follow. They are tools. You don’t use a hammer for every job. But when you face a specific problem—like needing a single shared configuration, or a way to cleanly swap algorithms, or a method to notify many parts of your app about an event—recognizing that “this is a Singleton situation” or “this needs an Observer” gives you a huge head start. It means you’re building on a solid, well-understood foundation instead of inventing a new structure from scratch every time. They help you write code that your future self, and your teammates, will thank you for.