Node.js Essencial: Módulos, ESM vs CommonJS e organização de dependências

Capítulo 3

Tempo estimado de leitura: 9 minutos

+ Exercício

O que é um módulo no Node.js (e por que isso importa)

Um módulo é uma unidade de código com fronteiras explícitas: ele expõe uma API (o que outros podem usar) e esconde detalhes internos (o que não deve vazar). No Node.js, módulos são a base para organizar o projeto, controlar dependências e evitar acoplamentos indevidos.

No ecossistema Node, você vai lidar principalmente com dois sistemas de módulos:

  • CommonJS (CJS): usa require() e module.exports. É o formato tradicional do Node.
  • ECMAScript Modules (ESM): usa import/export. É o padrão moderno do JavaScript.

ESM vs CommonJS: diferenças práticas

AspectoESMCommonJS
Sintaxeimport / exportrequire() / module.exports
CarregamentoEstático (analisável em build)Dinâmico (em runtime)
Top-level awaitSuportado (em Node moderno)Não (precisa de async IIFE)
ResoluçãoMais estrita (extensões/exports)Mais permissiva (historicamente)
InteropImporta CJS com regras específicasRequer ESM via import() (dinâmico)

Quando escolher ESM

  • Projetos novos com TypeScript e tooling moderno.
  • Bibliotecas que querem alinhar com o ecossistema atual.
  • Quando você quer imports estáticos e melhor suporte a tree-shaking (mais relevante no front, mas também em bundlers).

Quando CommonJS ainda aparece

  • Dependências antigas.
  • Projetos legados.
  • Scripts simples que dependem de comportamento permissivo de resolução.

Configuração do tipo de módulo: type no package.json

O Node decide se arquivos .js são ESM ou CJS com base no campo type do package.json:

  • "type": "module".js é ESM por padrão.
  • "type": "commonjs" (ou ausência do campo) → .js é CJS por padrão.

Extensões explícitas também definem o tipo:

  • .mjs → sempre ESM
  • .cjs → sempre CommonJS

Exemplo de package.json para ESM

{  "name": "api",  "version": "1.0.0",  "type": "module",  "scripts": {    "dev": "node --watch dist/server.js",    "build": "tsc -p tsconfig.json",    "start": "node dist/server.js"  }}

Se você usa TypeScript, normalmente seu código fonte está em src (TS) e o output em dist (JS). O type afeta o output JavaScript executado pelo Node.

Continue em nosso aplicativo e ...
  • Ouça o áudio com a tela desligada
  • Ganhe Certificado após a conclusão
  • + de 5000 cursos para você explorar!
ou continue lendo abaixo...
Download App

Baixar o aplicativo

Import/export na prática (ESM)

Export nomeado

// src/math/sum.ts
export function sum(a: number, b: number) {
  return a + b;
}
// src/index.ts
import { sum } from "./math/sum.js";
console.log(sum(2, 3));

Atenção: em ESM no Node, ao importar arquivos locais no JavaScript gerado, você geralmente precisa da extensão (.js). Em TypeScript, isso influencia como você escreve imports no .ts para que o output funcione.

Export default

// src/config/env.ts
export default function getEnv(name: string) {
  return process.env[name];
}
// src/index.ts
import getEnv from "./config/env.js";

Reexport (barrel) com cuidado

// src/math/index.ts
export { sum } from "./sum.js";
export { multiply } from "./multiply.js";

Barrels podem simplificar imports, mas também podem criar acoplamento e dependências circulares se usados indiscriminadamente. Use-os para módulos estáveis e coesos.

CommonJS na prática (para reconhecer e interoperar)

// cjs/logger.cjs
function log(msg) {
  console.log(msg);
}
module.exports = { log };
// cjs/index.cjs
const { log } = require("./logger.cjs");
log("ok");

Resolução de módulos: como o Node encontra o que você importou

Três tipos comuns de import

  • Built-in: node:fs, node:path, node:crypto.
  • Pacotes: express, zod (resolvidos via node_modules e metadados do pacote).
  • Arquivos locais: começam com ./, ../ ou / (caminho absoluto).

Importando built-ins (boa prática)

import { readFile } from "node:fs/promises";
import path from "node:path";

O prefixo node: deixa explícito que é um módulo nativo e evita ambiguidades com pacotes de mesmo nome.

Resolução de pacotes e o campo exports

Muitos pacotes modernos definem exports no package.json para controlar quais caminhos podem ser importados. Isso evita imports “profundos” do tipo some-lib/dist/internal.js.

Regra prática: importe apenas o que o pacote documenta como público. Se você precisa acessar um arquivo interno, isso é um sinal de acoplamento indevido.

Interoperabilidade ESM/CommonJS (sem dor)

ESM importando CommonJS

Quando você importa um módulo CJS a partir de ESM, o Node cria um “namespace” onde o default costuma apontar para module.exports.

// src/interop/use-cjs.ts (ESM)
import cjsPkg from "some-cjs-package";
// ou, em alguns casos:
import * as cjsNs from "some-cjs-package";

Se o pacote CJS exporta um objeto, default geralmente funciona. Se exporta funções de forma específica, pode variar. Na dúvida, teste e padronize no projeto.

CommonJS carregando ESM

Um arquivo CJS não pode usar require() diretamente em um módulo ESM. A alternativa é import() dinâmico:

// cjs/load-esm.cjs
async function main() {
  const esmModule = await import("../dist/esm-entry.js");
  esmModule.run();
}
main();

Isso é útil em migrações: você mantém uma entrada CJS e carrega partes ESM aos poucos.

Estratégia recomendada em projetos novos

  • Escolha ESM como padrão do app.
  • Interop com CJS apenas quando necessário (dependências legadas).
  • Evite misturar ESM e CJS dentro do mesmo diretório sem motivo; se precisar, use .cjs/.mjs explicitamente.

Passo a passo: criando um projeto padrão (Node + TypeScript) com dependências bem separadas

1) Inicialize o projeto e defina scripts

mkdir api && cd api
npm init -y

Edite o package.json para um padrão ESM:

{  "name": "api",  "version": "1.0.0",  "private": true,  "type": "module",  "scripts": {    "dev": "tsx watch src/server.ts",    "build": "tsc -p tsconfig.json",    "start": "node dist/server.js",    "lint": "eslint .",    "test": "vitest"  }}

Neste exemplo:

  • dev roda TypeScript diretamente (sem build) com watcher.
  • build gera JS em dist.
  • start executa o output compilado.

2) Instale dependências de produção vs desenvolvimento

Produção: tudo que o app precisa para rodar em runtime.

npm i express

Desenvolvimento: ferramentas de build, lint, testes, tipos.

npm i -D typescript tsx @types/node @types/express eslint vitest

Regra prática: se você remover node_modules e instalar apenas dependências de produção, o app deve iniciar com npm run start.

3) Configure o TypeScript para ESM no Node

// tsconfig.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "outDir": "dist",
    "rootDir": "src",
    "strict": true,
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "skipLibCheck": true
  },
  "include": ["src"]
}

module e moduleResolution como NodeNext ajudam o TypeScript a seguir as regras reais do Node para ESM/CJS e extensões.

4) Crie uma estrutura de pastas com limites claros

Uma convenção simples e escalável:

src/
  server.ts
  app/
    http/
      routes/
      controllers/
      middlewares/
    services/
    repositories/
    domain/
      entities/
      value-objects/
  shared/
    config/
    errors/
    logger/
  infra/
    db/
    cache/
    http/
  • app/: regras e casos de uso da aplicação (o “coração”).
  • domain/: modelos e invariantes (sem depender de Express, DB etc.).
  • infra/: integrações (banco, filas, providers externos).
  • shared/: utilitários transversais (erros, config, logger), com cuidado para não virar “pasta lixeira”.

5) Evite acoplamentos indevidos (regras de dependência)

Defina regras simples:

  • domain não importa nada de infra nem de http.
  • services dependem de interfaces (contratos), não de implementações concretas.
  • controllers orquestram entrada/saída HTTP e chamam services; não fazem regra de negócio complexa.

Exemplo de contrato para reduzir acoplamento:

// src/app/repositories/UserRepository.ts
import type { User } from "../domain/entities/User.js";

export interface UserRepository {
  findByEmail(email: string): Promise<User | null>;
  save(user: User): Promise<void>;
}
// src/infra/db/PostgresUserRepository.ts
import type { UserRepository } from "../../app/repositories/UserRepository.js";
import type { User } from "../../app/domain/entities/User.js";

export class PostgresUserRepository implements UserRepository {
  async findByEmail(email: string): Promise<User | null> {
    return null;
  }
  async save(user: User): Promise<void> {}
}

Note o uso de import type para evitar carregar dependências em runtime quando o import é apenas de tipos.

Aliases de import (paths) e limites de módulo

Aliases tornam imports mais legíveis e reduzem ../../.., mas precisam ser configurados de forma consistente entre TypeScript e runtime.

Opção A: manter imports relativos (mais simples no Node puro)

  • Menos configuração.
  • Menos risco de “funciona no TS, quebra no Node”.

Boa prática: padronize profundidade e evite atravessar camadas (ex.: infra importando http).

Opção B: usar aliases com suporte no runtime

Se você quer @app/*, @shared/*, etc., configure:

  • paths no tsconfig.json para o TypeScript entender.
  • Uma estratégia de runtime: bundler, loader, ou resolver compatível com Node (varia conforme stack).

Exemplo de paths (TypeScript):

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@app/*": ["src/app/*"],
      "@shared/*": ["src/shared/*"],
      "@infra/*": ["src/infra/*"]
    }
  }
}

Se você não configurar o runtime, o Node não vai resolver @app/... sozinho. Em projetos sem bundler, prefira imports relativos ou adote uma solução de execução que suporte aliases.

Organização de dependências: padrões que evitam problemas

Dependências diretas vs transitivas

  • Diretas: você instalou e importa no código.
  • Transitivas: vêm como dependência de outra dependência.

Regra prática: se você importa algo no seu código, ele deve estar em dependencies (ou devDependencies se só usado em dev/test/build). Não confie em dependência transitiva.

Produção vs desenvolvimento

  • dependencies: express, drivers de banco, libs de validação usadas em runtime.
  • devDependencies: types, linters, test runners, ferramentas de build.

Anti-padrão comum: colocar typescript em dependencies sem necessidade (se você não compila em produção).

Scripts úteis e previsíveis

  • dev: roda local com watch.
  • build: gera artefatos.
  • start: roda artefatos gerados.
  • test e lint: garantem qualidade.

Evite scripts que mudam comportamento conforme ambiente sem deixar explícito (ex.: start que às vezes compila e às vezes não).

Checklist de revisão: imports, aliases e limites de módulos

Imports e exports

  • Os imports locais em ESM incluem extensão correta no output (.js)?
  • Há imports “profundos” em pacotes (ex.: lib/dist/internal) que deveriam ser evitados?
  • Existe uso excessivo de barrels (index.ts) criando dependências circulares?
  • Tipos estão sendo importados com import type quando aplicável?
  • Há módulos que exportam “demais” (API pública grande e instável)?

ESM/CJS e interoperabilidade

  • O package.json tem type coerente com o output executado?
  • Arquivos CJS/ESM misturados estão com extensões explícitas (.cjs/.mjs) quando necessário?
  • Ao importar CJS em ESM, o projeto padronizou default vs * as?
  • Se há CJS carregando ESM, está usando import() e tratando async corretamente?

Aliases e resolução

  • Se existem aliases (@app, etc.), TypeScript e runtime resolvem da mesma forma?
  • Os aliases não permitem “furar” camadas (ex.: @infra sendo importado por domain)?
  • Há um padrão de import (relativo vs alias) documentado e seguido?

Limites de módulos e acoplamento

  • domain está livre de dependências de framework (Express, drivers, etc.)?
  • infra depende de contratos definidos em app, e não o contrário?
  • Controllers estão finos (sem regra de negócio) e chamam services/use-cases?
  • Configurações e singletons estão centralizados (evitando imports que inicializam coisas “sem querer”)?
  • Há sinais de dependência circular (módulos que se importam mutuamente)?

Agora responda o exercício sobre o conteúdo:

Em um projeto Node.js com TypeScript configurado para ESM, qual prática ajuda a evitar acoplamento indevido ao importar dependências de pacotes?

Você acertou! Parabéns, agora siga para a próxima página

Você errou! Tente novamente.

Importar apenas o que o pacote expõe como público reduz o risco de quebrar com mudanças internas e evita acoplamento indevido. Imports “profundos” contornam as fronteiras do módulo e podem falhar quando o pacote restringe caminhos via exports.

Próximo capitúlo

Node.js Essencial: TypeScript aplicado ao back-end (configuração e ergonomia)

Arrow Right Icon
Capa do Ebook gratuito Node.js Essencial: Construindo um Back-end com Express e TypeScript
19%

Node.js Essencial: Construindo um Back-end com Express e TypeScript

Novo curso

16 páginas

Baixe o app para ganhar Certificação grátis e ouvir os cursos em background, mesmo com a tela desligada.