A NestJS module is more than a folder
When I first learned NestJS, a module felt mostly like a place to group a controller and a service. It kept files tidy, and that was enough for a small feature.
As applications grow, I have found that a module starts to mean more than its directory. It becomes a boundary around a part of the product: what that part owns, what it makes available to other parts, and which dependencies it needs in return.
Start with a capability, not a technical layer
It can be tempting to organise a project around technical categories: one module for services, another for repositories, another for helpers. Sometimes that works, but it can also make a feature harder to follow because its behaviour is spread across many places.
I tend to find feature boundaries easier to reason about. A users module, an orders module, or a notifications module can hold the controllers, providers, and internal details that belong to one capability. The goal is not to create a perfect domain model from the beginning. It is to give a feature a place where its responsibility is easier to see.
That has made code reviews simpler for me. When a change belongs to a clear capability, it is easier to ask what the module should own and which other parts of the application really need to know about it.
Treat exports as a public API
Nest modules encapsulate their providers by default. A provider becomes available outside its module only when the module exports it and another module imports that boundary.
@Module({
imports: [DatabaseModule],
controllers: [OrdersController],
providers: [OrdersService, OrdersRepository, PricingPolicy],
exports: [OrdersService], // the only part other modules are invited to use
})
export class OrdersModule {}That behaviour can feel like boilerplate at first. Over time, I have come to see the exports array as a useful question: what is this module intentionally offering to the rest of the application?
Exporting every service makes it easy to move quickly in the moment, but it can also let unrelated features reach too deeply into one another. Exporting a smaller, deliberate surface gives a module room to change its internal implementation later. The calling code only needs to understand the part it was invited to use.
Let dependency injection make dependencies visible
Dependency injection is often introduced as a way to avoid creating classes with new everywhere. That is true, but the part I value most is visibility. When a constructor asks for a dependency, it gives the next reader a clue about what the class needs in order to do its job.
@Injectable()
export class OrdersService {
constructor(
private readonly orders: OrdersRepository,
private readonly pricing: PricingPolicy,
) {}
}That visibility is useful for testing too. A dependency can be replaced at the module boundary or in a test without rewriting the class that consumes it. It does not make every design easy, but it can keep a class from quietly reaching into global state or constructing a concrete dependency by itself.
Global modules can be convenient for a small set of truly shared concerns, such as a database connection or configuration. I try to use them with care. Explicit imports make dependencies a little more verbose, but they also make the structure easier to trace later.
Notice when a boundary is under pressure
Circular module imports, a long list of exports, or a provider that seems to know too much about another feature are not always immediate problems. They can be signals that a boundary deserves another look.
Sometimes the answer is a small shared abstraction. Sometimes two features are more closely related than the module structure suggests. Sometimes the dependency is valid and simply needs to be made clearer. I have found it more useful to investigate the reason than to reach for a quick workaround straight away.
For me, modules and dependency injection are less about framework syntax than about keeping the application understandable as it changes. A good boundary does not remove every dependency. It makes the important ones visible enough for the team to discuss and maintain.
Working on something similar? Let's talk