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()emodule.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
| Aspecto | ESM | CommonJS |
|---|---|---|
| Sintaxe | import / export | require() / module.exports |
| Carregamento | Estático (analisável em build) | Dinâmico (em runtime) |
| Top-level await | Suportado (em Node moderno) | Não (precisa de async IIFE) |
| Resolução | Mais estrita (extensões/exports) | Mais permissiva (historicamente) |
| Interop | Importa CJS com regras específicas | Requer 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.
- Ouça o áudio com a tela desligada
- Ganhe Certificado após a conclusão
- + de 5000 cursos para você explorar!
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 vianode_modulese 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/.mjsexplicitamente.
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 -yEdite 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:
devroda TypeScript diretamente (sem build) com watcher.buildgera JS emdist.startexecuta 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 expressDesenvolvimento: ferramentas de build, lint, testes, tipos.
npm i -D typescript tsx @types/node @types/express eslint vitestRegra 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:
pathsnotsconfig.jsonpara 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.testelint: 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 typequando aplicável? - Há módulos que exportam “demais” (API pública grande e instável)?
ESM/CJS e interoperabilidade
- O
package.jsontemtypecoerente 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
defaultvs* 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.:
@infrasendo importado pordomain)? - 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)?