🚨 ¡Nueva review! ✨ Mi ratón favorito para programar: el Logitech MX Master 3S . ¡Échale un ojo! 👀

Modules en NestJS: organiza tu código como un arquitecto

Serie NestJS #4 — Feature modules, shared modules, @Global y dynamic modules

Escrito por domin el 23 de marzo de 2026

Vamos con el cuarto post de la serie NestJS. Ya sabemos montar el entorno (post 1), manejar controllers (post 2) y entender la inyección de dependencias (post 3), y hoy toca lo que organiza todo el tinglao: los Modules.

Si los providers son los tochos y la inyección de dependencias es el cemento, los modules son los planos del edificio. Definen qué piezas existen, cómo se conectan entre sí, qué se comparte y qué se queda privado dentro de cada módulo. Sin una buena organización modular, tu proyecto acaba siendo un monolito de 200 archivos donde nadie sabe qué depende de qué.

EA, vamos al lio.

Diagrama de módulos NestJS conectados entre sí formando una arquitectura modular.

1. ¿Qué es un Module?

Un es una clase decorada con @Module() que agrupa un conjunto de funcionalidades relacionadas. Se podría decir que cada módulo es una caja que contiene todo lo necesario para una feature concreta de tu aplicación.

import { Module } from '@nestjs/common';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';

@Module({
    controllers: [UsersController],
    providers: [UsersService],
})
export class UsersModule {}

Toda aplicación de NestJS tiene mínimo un módule que es el AppModule, que viene a ser el módulo raíz de toda la app. A partir de ahí, la idea es que cada funcionalidad tenga su propio módulo.


2. Las cuatro propiedades de @Module()

El decorador @Module() acepta un objeto con cuatro propiedades que son las siguientes:

PropiedadQué hace
importsLista de módulos cuyos providers exportados necesitas en este módulo
controllersLos controllers que pertenecen a este módulo
providersLos providers que el contenedor IoC debe instanciar y que pueden ser inyectados dentro de este módulo
exportsSubset de providers que este módulo pone a disposición de otros módulos que lo importen

Lo más importante de entender es que los providers son privados por defecto. Si registras UsersService en UsersModule, solo los controllers y providers de UsersModule pueden inyectarlo, para que otro módulo lo use, tienes que exportarlo explícitamente.

Esto es encapsulación y es genial porque cada módulo controla exactamente qué expone al exterior.


3. Feature Modules

La forma profesional de organizar tu app es con feature modules . Es decir, un módulo por feature. Cada módulo es independiente y tiene todo lo que necesita:

src/
├── users/
   ├── dto/
   ├── create-user.dto.ts
   └── update-user.dto.ts
   ├── entities/
   └── user.entity.ts
   ├── users.controller.ts
   ├── users.service.ts
   └── users.module.ts
├── products/
   ├── dto/
   ├── entities/
   ├── products.controller.ts
   ├── products.service.ts
   └── products.module.ts
├── orders/
   ├── dto/
   ├── entities/
   ├── orders.controller.ts
   ├── orders.service.ts
   └── orders.module.ts
├── app.module.ts
└── main.ts

Y el AppModule simplemente importa los módulos de cada feature:

// src/app.module.ts
import { Module } from '@nestjs/common';
import { UsersModule } from './users/users.module';
import { ProductsModule } from './products/products.module';
import { OrdersModule } from './orders/orders.module';

@Module({
    imports: [UsersModule, ProductsModule, OrdersModule],
})
export class AppModule {}

Muy limpio, ¿verdad? Si mañana necesitas una feature nueva, creas su módulo y lo importas e ya.


4. Shared Modules y exports

¿Qué pasa cuando un módulo necesita usar un provider de otro módulo? Para eso tenemos los exports.

Imagina que tienes un MailService en un MailModule y quieres usarlo desde UsersModule y OrdersModule:

// src/mail/mail.module.ts
@Module({
    providers: [MailService],
    exports: [MailService], // 👈 Lo hacemos accesible desde fuera
})
export class MailModule {}

Ahora cualquier módulo que importe MailModule puede inyectar MailService:

// src/users/users.module.ts
@Module({
    imports: [MailModule], // 👈 Importamos el módulo
    controllers: [UsersController],
    providers: [UsersService],
})
export class UsersModule {}
// src/users/users.service.ts
@Injectable()
export class UsersService {
    constructor(private readonly mailService: MailService) {}

    async create(dto: CreateUserDto): Promise<User> {
        const user = /* ... crear usuario ... */;
        await this.mailService.sendWelcomeEmail(user.email);
        return user;
    }
}

Sin el exports: [MailService] en MailModule, petaría con un error al arrancar. NestJS te diría que MailService no está disponible en el contexto de UsersModule.

Los modules son singletons

Un detalle importante es que los módulos en NestJS son singletons. Si UsersModule y OrdersModule ambos importan MailModule, no se crean dos instancias del MailService, se comparte la misma. NestJS monta el grafo de dependencias una vez al arrancar y reutiliza instancias.


5. Re-exporting modules

Un módulo puede re-exportar un módulo que importa, haciéndolo disponible en cascada:

// src/common/common.module.ts
@Module({
    imports: [MailModule, LoggerModule],
    exports: [MailModule, LoggerModule], // 👈 Re-exporta ambos
})
export class CommonModule {}

Ahora cualquier módulo que importe CommonModule tiene acceso automático a MailService y LoggerService sin tener que importar MailModule y LoggerModule directamente. Es útil para crear un módulo paraguas que agrupa varios módulos auxiliares.


6. @Global() — Módulos globales

Si un módulo se necesita en todas partes (un logger, un config service, un módulo de base de datos…), puedes marcarlo como global para no tener que importarlo manualmente en cada módulo:

import { Module, Global } from '@nestjs/common';

@Global()
@Module({
    providers: [LoggerService],
    exports: [LoggerService],
})
export class LoggerModule {}

Con @Global(), LoggerService está disponible en toda la aplicación sin necesidad de importar LoggerModule en cada módulo, pero ojo:

Úsalo cuando...
  • @Global() para módulos que genuinamente se usan en toda la app: configuración, logging, base de datos
  • Registrar los módulos globales UNA sola vez, normalmente en el AppModule o en un CoreModule
Evítalo cuando...
  • Hacer @Global() todo lo que puedas "por comodidad". Es tentador pero rompe la encapsulación y hace imposible saber de dónde vienen las dependencias
  • Módulos de feature como @Global(). UsersModule no debería ser global, eso es una dependencia explícita

7. Dynamic Modules

Un es un módulo que se configura en el momento de importarlo, en vez de tener una configuración fija.

¿Para qué sirve? Imagina que creas un módulo de base de datos reutilizable. No puedes hardcodear la URL de conexión porque cada proyecto tendrá la suya. Necesitas que quien importe el módulo pueda pasarle la configuración.

7.1. forRoot() — Configuración global única

La convención forRoot() se usa cuando el módulo se configura una sola vez para toda la app:

// src/database/database.module.ts
import { Module, DynamicModule } from '@nestjs/common';
import { DatabaseService } from './database.service';

export interface DatabaseModuleOptions {
    host: string;
    port: number;
    database: string;
    username: string;
    password: string;
}

@Module({})
export class DatabaseModule {
    static forRoot(options: DatabaseModuleOptions): DynamicModule {
        return {
            module: DatabaseModule,
            global: true, // Disponible en toda la app
            providers: [
                {
                    provide: 'DATABASE_OPTIONS',
                    useValue: options,
                },
                DatabaseService,
            ],
            exports: [DatabaseService],
        };
    }
}

Y se usa así:

// src/app.module.ts
@Module({
    imports: [
        DatabaseModule.forRoot({
            host: 'localhost',
            port: 5432,
            database: 'nestjs_course',
            username: 'postgres',
            password: 'secret',
        }),
        UsersModule,
        ProductsModule,
    ],
})
export class AppModule {}

El DatabaseService recibe las opciones por inyección:

// src/database/database.service.ts
@Injectable()
export class DatabaseService {
    constructor(
        @Inject('DATABASE_OPTIONS') private readonly options: DatabaseModuleOptions,
    ) {
        console.warn(`Connecting to ${options.host}:${options.port}/${options.database}`);
    }
}

7.2. forRootAsync() — Configuración asíncrona con DI

El problema de forRoot() es que los valores son estáticos. ¿Y si necesitas leer las credenciales de un ConfigService que todavía no existe cuando se define el módulo? Para eso está forRootAsync():

// src/database/database.module.ts
@Module({})
export class DatabaseModule {
    static forRoot(options: DatabaseModuleOptions): DynamicModule {
        return {
            module: DatabaseModule,
            global: true,
            providers: [
                { provide: 'DATABASE_OPTIONS', useValue: options },
                DatabaseService,
            ],
            exports: [DatabaseService],
        };
    }

    static forRootAsync(options: {
        imports?: any[];
        useFactory: (...args: any[]) => DatabaseModuleOptions | Promise<DatabaseModuleOptions>;
        inject?: any[];
    }): DynamicModule {
        return {
            module: DatabaseModule,
            global: true,
            imports: options.imports ?? [],
            providers: [
                {
                    provide: 'DATABASE_OPTIONS',
                    useFactory: options.useFactory,
                    inject: options.inject ?? [],
                },
                DatabaseService,
            ],
            exports: [DatabaseService],
        };
    }
}

Y ahora puedes usar el ConfigService para pasarle los valores:

@Module({
    imports: [
        ConfigModule.forRoot(),
        DatabaseModule.forRootAsync({
            imports: [ConfigModule],
            useFactory: (configService: ConfigService) => ({
                host: configService.get('DB_HOST'),
                port: configService.get('DB_PORT'),
                database: configService.get('DB_NAME'),
                username: configService.get('DB_USER'),
                password: configService.get('DB_PASSWORD'),
            }),
            inject: [ConfigService],
        }),
        UsersModule,
    ],
})
export class AppModule {}

Esto es lo que hacen internamente módulos como TypeOrmModule.forRootAsync(), JwtModule.registerAsync(), etc. Cuando los usemos más adelante en la serie, ya sabrás cómo funcionan por debajo.

7.3. Convenciones de nombres

MétodoSignificadoEjemplo
forRoot()Configuración global una sola vez. Se importa en AppModuleTypeOrmModule.forRoot()
forFeature()Usa la config de forRoot pero adaptándola al módulo. Se importa en cada feature moduleTypeOrmModule.forFeature([User])
register()Configuración independiente por módulo. Cada importación tiene su propia configHttpModule.register()
*Async()Versión asíncrona de cualquiera de los anteriores. Permite usar DI en la configuraciónJwtModule.registerAsync()

Estas convenciones no son obligatorias, pero las sigue toda la comunidad y el ecosistema oficial de NestJS. Si creas módulos reutilizables, usa estos nombres para que todo el mundo sepa qué esperar.


8. Dependencias circulares: forwardRef()

A veces dos módulos se necesitan mutuamente. UsersModule necesita algo de AuthModule y AuthModule necesita algo de UsersModule. Esto es una dependencia circular y NestJS normalmente petaría con un error.

La solución es forwardRef():

// users.module.ts
@Module({
    imports: [forwardRef(() => AuthModule)],
    providers: [UsersService],
    exports: [UsersService],
})
export class UsersModule {}

// auth.module.ts
@Module({
    imports: [forwardRef(() => UsersModule)],
    providers: [AuthService],
    exports: [AuthService],
})
export class AuthModule {}

forwardRef() le dice a NestJS: este módulo todavía no existe, pero lo va a hacer cuando termine de cargar todo. Los dos lados de la relación circular deben usar forwardRef().

Aunque las dependencias circulares suelen querer decir que hay que darle una vuelta a la arquitectura. Si puedes evitarlas reorganizando los módulos, mejor. Opciones:


9. Ejemplo práctico: estructura modular completa

Vamos a ver cómo quedaría nuestro proyecto del curso con una estructura modular bien organizada:

// src/app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from './config/config.module';
import { DatabaseModule } from './database/database.module';
import { UsersModule } from './users/users.module';
import { AuthModule } from './auth/auth.module';

@Module({
    imports: [
        // Módulos globales de infraestructura
        ConfigModule.forRoot({ folder: './config' }),
        DatabaseModule.forRootAsync({
            imports: [ConfigModule],
            useFactory: (config: ConfigService) => ({
                host: config.get('DB_HOST'),
                port: config.get('DB_PORT'),
                database: config.get('DB_NAME'),
                username: config.get('DB_USER'),
                password: config.get('DB_PASSWORD'),
            }),
            inject: [ConfigService],
        }),

        // Feature modules
        UsersModule,
        AuthModule,
    ],
})
export class AppModule {}

Fíjate en la separación clara:

  1. Módulos de infraestructura arriba: configuración y base de datos. Se configuran una vez con forRoot / forRootAsync.
  2. Feature modules abajo: cada uno es una caja independiente con su controller, service y lógica.

Esta es la estructura que vamos a seguir el resto de la serie. Cuando añadamos TypeORM en el post 7, DatabaseModule será TypeOrmModule.forRootAsync(). Cuando añadamos auth en el post 10, será un feature module más.


10. Recapitulando

📦 @Module()

Agrupa controllers, providers e imports. Las 4 propiedades: imports, controllers, providers, exports.

🧩 Feature Modules

Un módulo por feature. Cada uno independiente con todo lo que necesita. El AppModule los importa.

📤 exports

Los providers son privados por defecto. Solo los que están en exports son accesibles desde otros módulos.

🌍 @Global()

Hace los exports del módulo disponibles en toda la app sin importarlo. Solo para módulos de infraestructura.

⚡ Dynamic Modules

forRoot/forRootAsync para config global, register para config por módulo. La forma pro de crear módulos configurables.

🔄 forwardRef()

Resuelve dependencias circulares entre módulos. Úsalo como último recurso, mejor reorganizar la arquitectura.

En el próximo post vamos a echarle un ogt a los Middleware y el ciclo de vida de una request. Veremos cómo interceptar peticiones antes de que lleguen al controller, cómo funciona el pipeline completo (Middleware → Guards → Interceptors → Pipes → Handler) y cómo aprovechar cada pieza.

EA, nos vemos en los bares!! 🍺


Pon a prueba lo aprendido

1. ¿Qué propiedad de @Module() hace que un provider sea accesible desde otros módulos?

2. ¿Qué convención de nombre indica que un dynamic module se configura UNA sola vez para toda la app?

3. ¿Los providers son públicos o privados por defecto en un módulo?

4. ¿Qué problema resuelve forwardRef()?

5. ¿Cuándo es correcto usar @Global() en un módulo?