Voltar para o blog
NestJSBackendArquiteturaSaaS

Arquitetura modular no NestJS para sistemas SaaS escaláveis

21 de maio de 202611 min de leiturapor Sávio Araújo

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.

Quer conversar sobre esse tema?

Se você tem dúvidas, quer aplicar isso num projeto ou só quer trocar uma ideia, pode me chamar.

Falar com a SA Tech