
You know that sinking feeling when a “simple” change breaks three unrelated features? Or when fixing a bug requires reading through seven files just to understand one behavior? That’s what happens when your codebase isn’t modular.
Your codebase should be highly cohesive (each piece does one thing well) and loosely coupled (pieces depend on each other minimally). These properties aren’t just academic ideals — they’re what make systems understandable, changeable, and resilient.
Structuring Features and Shared Code
When developing an API, it’s common to follow the MVC pattern, a form of modularity. However, I personally like to recognize the features of the software as modules and split my project into them. Take, for example, you’re building an event system. You might recongnize features like event management systems, ticket management, user management, notification systems and so on.
The core logic for each feature lives in its own service folder within that feature. Here, they can undergo unit and integration testing robustly. The controller of a feature can then make calls to services of that feature to complete an operation. However, you need to be careful not to duplicate implementation accross features in the aim to ensure strict modularity. So it’s important to recognize shared services and utilities in your system. In a backend system, this could be your regular notification service, storage service (S3), authentication, or logging. In the frontend, it could be date formatters, validation helpers, API clients, or calculations used by multiple components. If multiple features rely on the same implementation, make it shared. This gives you reusability (write once, use everywhere), readability (one source of truth, no duplicate logic scattered across features), easier debugging (fix a bug in one place, it’s fixed everywhere), testability (test the shared service once instead of retesting the same logic in every feature), and consistency (all features handle dates, validation, or API errors the same way). These are cross-cutting concerns that every feature needs, so centralize them.
A good rule of thumb: extract to shared services or utilities when you see the same logic duplicated in 2–3 features, not preemptively on the first use. Premature abstraction can be just as problematic as duplication.
Feature-to-feature dependencies require more care. If your Event feature needs to check ticket availability, you can import the Ticket service directly:
import { ticketService } from '../tickets/ticketService'This creates coupling, but it’s intentional (we need that service), explicit and manageable. The key is being intentional.
The principle remains: import shared utilities freely, import other features’ logic sparingly and intentionally.
The Natural Result of Modularity
When you build systems modularly, certain benefits emerge naturally.
Dependency Injection: Swap Implementations Without Breaking Things
In Java, for example, you might define an interface like IEmailService:
public interface IEmailService {
void send(String receiver, String title, String template, String message);
}Your business logic only knows about this interface, it doesn’t care whether the actual implementation uses SendGrid, Resend, or any other provider. Implement our custom operation using Resend for example becomes a module. We create an instance and pass it into the notification controller. We could have multiple instances (Resend, SendGrid) and use them in several places of your code base also but we are sure the implementation is consistent.
public enum EmailTemplate {
DEFAULT,
WELCOME,
PASSWORD_RESET,
ORDER_CONFIRMATION
}
public interface IEmailService {
void send(String receiver, String title, EmailTemplate template, String message);
}
public class NotificationController {
private final IEmailService emailService;
public NotificationController(IEmailService emailService) {
this.emailService = emailService;
}
public void notifyUser(User user, String message) {
emailService.send(user.email, "Notification", EmailTemplate.DEFAULT, message);
}
}Everything else (API keys, transport setup, configuration) is abstracted away. The contract is defined, and as long as any implementation honours that contract, you can swap providers without touching the code that uses them.
Shared Code as Packages: True Reusability
I recently worked on a charting system built with ApexCharts. Over time, chart configurations were duplicated across the codebase, each slightly different. Maintaining them became painful.
I moved all chart logic into a Git submodule and reference it from the main repo. While this technically worked, I quickly ran into the notorious friction of submodules such as pointer mismatches, merge conflicts in .gitmodules, teammates forgetting to run git submodule update. It was technically modular, but operationally painful. So yes, modularity may cause issues if not implemented properly.
I extracted the chart logic into a standalone package and published it to a private npm registry. Suddenly, all charts shared the same structure, updates propagated with a single npm update, and we eliminated both code duplication and Git submodule headaches.
Instead of rewriting chart logic in every feature, we now had one source of truth that behaved like any other dependency.
Final Thoughts
My entire “rant” boils down to this:
Modularity is not just a coding style. It’s a mindset, an architecture, and a strategy.
Whether at the function level, folder level, package level, or infrastructure level, thinking in modules protects your system from fragility, duplication, and unnecessary complexity.
Finally, early on in a project, especially when requirements are fluid, over-modularizing can create unnecessary abstraction layers that change constantly as you learn what you’re actually building. Sometimes the fastest path to clarity is a well-contained mess that you refactor into modules once patterns emerge. So it is not expected at the beginning but if you intend to build a resilient system, build in modules.