Por que a estrutura modular importa
Em projetos pequenos, qualquer organização funciona. Em SaaS com múltiplos tenants, time de desenvolvimento crescendo e domínios de negócio complexos, a estrutura de módulos se torna a fundação de tudo: manutenibilidade, testabilidade e escala.
O NestJS foi projetado em torno de módulos. Usar bem essa estrutura é a diferença entre um codebase que cresce com o produto e um que acumula dívida técnica.
A anatomia de um módulo bem definido
Um módulo do NestJS encapsula um domínio: tem seus próprios controllers, services, repositórios e providers. A regra prática: um módulo = uma responsabilidade de negócio.
src/
modules/
auth/
auth.module.ts
auth.controller.ts
auth.service.ts
strategies/
jwt.strategy.ts
refresh.strategy.ts
dto/
login.dto.ts
refresh.dto.ts
billing/
billing.module.ts
billing.controller.ts
billing.service.ts
billing.repository.ts
dto/
users/
users.module.ts
users.controller.ts
users.service.ts
users.repository.ts
shared/
database/
database.module.ts
prisma.service.ts
cache/
cache.module.ts
cache.service.ts
tenant/
tenant.module.ts
tenant.context.ts
tenant.interceptor.ts
Módulos compartilhados (shared)
Providers que qualquer módulo pode precisar: banco de dados, cache, filas: ficam em SharedModule com @Global():
// shared/database/database.module.ts
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class DatabaseModule {}
// shared/cache/cache.module.ts
@Global()
@Module({
imports: [
CacheModule.registerAsync({
useFactory: (config: ConfigService) => ({
store: redisStore,
host: config.get("REDIS_HOST"),
port: config.get("REDIS_PORT"),
ttl: 300,
}),
inject: [ConfigService],
}),
],
exports: [CacheModule],
})
export class AppCacheModule {}Com @Global(), esses módulos são registrados uma vez no AppModule e ficam disponíveis em toda a aplicação sem precisar importar explicitamente.
Multi-tenancy: o coração do SaaS
A abordagem mais comum é row-level tenancy: todos os tenants compartilham o mesmo banco, com um tenantId em cada tabela.
Extraindo o tenant do contexto
// shared/tenant/tenant.interceptor.ts
@Injectable()
export class TenantInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const request = context.switchToHttp().getRequest();
const tenantId = this.extractTenantId(request);
if (!tenantId) {
throw new UnauthorizedException("Tenant não identificado");
}
// Armazena no contexto AsyncLocalStorage para acesso em qualquer service
tenantContext.run({ tenantId }, () => next.handle());
return next.handle();
}
private extractTenantId(request: Request): string | null {
// Pode vir do JWT, subdomínio, ou header
return (
request.user?.tenantId ??
this.extractFromSubdomain(request.hostname) ??
(request.headers["x-tenant-id"] as string) ??
null
);
}
private extractFromSubdomain(hostname: string): string | null {
const parts = hostname.split(".");
// acme.satechsys.com.br → tenantId = "acme"
return parts.length >= 3 ? parts[0] : null;
}
}AsyncLocalStorage para o tenantId
// shared/tenant/tenant.context.ts
import { AsyncLocalStorage } from "async_hooks";
interface TenantStore {
tenantId: string;
}
export const tenantContext = new AsyncLocalStorage<TenantStore>();
export function getCurrentTenantId(): string {
const store = tenantContext.getStore();
if (!store) throw new Error("Fora de contexto de tenant");
return store.tenantId;
}AsyncLocalStorage propaga o tenantId por toda a cadeia de Promises de uma request, sem precisar passar como parâmetro em cada função.
Repositório tenant-aware
// users/users.repository.ts
@Injectable()
export class UsersRepository {
constructor(private readonly prisma: PrismaService) {}
async findAll() {
const tenantId = getCurrentTenantId();
return this.prisma.user.findMany({
where: { tenantId }, // aplicado automaticamente
});
}
async findById(id: string) {
const tenantId = getCurrentTenantId();
return this.prisma.user.findFirst({
where: { id, tenantId }, // nunca vaza dados de outro tenant
});
}
async create(data: CreateUserData) {
const tenantId = getCurrentTenantId();
return this.prisma.user.create({
data: { ...data, tenantId },
});
}
}Toda query automaticamente filtra pelo tenantId do contexto. Impossível vazar dados entre tenants por esquecer o filtro.
Módulo de billing isolado
Billing é um domínio sensível: pagamentos, cobranças, faturas. Mantenha isolado com sua própria camada de repositório e nunca acesse diretamente pela camada de negócio de outros módulos:
// billing/billing.module.ts
@Module({
controllers: [BillingController],
providers: [BillingService, BillingRepository, StripeService],
exports: [BillingService], // só exporta o service, não o repositório
})
export class BillingModule {}
// users/users.service.ts: acessa billing pelo service, não diretamente
@Injectable()
export class UsersService {
constructor(
private readonly usersRepo: UsersRepository,
private readonly billingService: BillingService // importado via módulo
) {}
async upgradeSubscription(userId: string, plan: string) {
const user = await this.usersRepo.findById(userId);
await this.billingService.changePlan(user.customerId, plan);
await this.usersRepo.update(userId, { plan });
}
}Comunicação entre módulos via eventos
Para desacoplar módulos, use eventos em vez de chamadas diretas onde possível:
// users/users.service.ts
@Injectable()
export class UsersService {
constructor(
private readonly usersRepo: UsersRepository,
private readonly eventEmitter: EventEmitter2
) {}
async createUser(dto: CreateUserDto) {
const user = await this.usersRepo.create(dto);
this.eventEmitter.emit("user.created", { userId: user.id, tenantId: user.tenantId });
return user;
}
}
// billing/billing.service.ts
@Injectable()
export class BillingService {
@OnEvent("user.created")
async handleUserCreated(event: UserCreatedEvent) {
await this.createFreeTrialSubscription(event.userId, event.tenantId);
}
}billing reage ao evento sem que users precise saber que billing existe.
Configuração por módulo
Evite um único arquivo .env com 50 variáveis. Cada módulo configura o que precisa:
// billing/billing.module.ts
@Module({
imports: [
ConfigModule.forFeature(
registerAs("stripe", () => ({
secretKey: process.env.STRIPE_SECRET_KEY,
webhookSecret: process.env.STRIPE_WEBHOOK_SECRET,
defaultCurrency: process.env.STRIPE_CURRENCY ?? "brl",
}))
),
],
// ...
})
export class BillingModule {}
// billing/stripe.service.ts
@Injectable()
export class StripeService {
constructor(
@Inject("stripe") private readonly config: StripeConfig
) {}
}Conclusão
A estrutura modular do NestJS reflete a estrutura do negócio. Mantenha módulos coesos, use AsyncLocalStorage para propagar o tenantId sem poluir assinaturas de método, e comunique módulos via eventos para reduzir acoplamento.
O resultado é um codebase onde cada parte do negócio tem um lugar claro: e adicionar um novo tenant ou um novo módulo de funcionalidade não exige entender o sistema inteiro.