Por que organizar em módulos e pacotes
Em projetos orientados a objetos, a organização do código influencia diretamente a legibilidade, a testabilidade e a evolução do sistema. A ideia central é separar responsabilidades: cada arquivo (módulo) deve ter um foco claro, e cada pasta (pacote) deve agrupar módulos relacionados. Isso reduz acoplamento, evita dependências confusas e facilita reutilização.
Módulo é um arquivo .py. Pacote é uma pasta com arquivos Python (normalmente com __init__.py) que pode ser importada como um namespace. Uma arquitetura simples costuma dividir o projeto por camadas (domínio, aplicação, infraestrutura, interface) ou por funcionalidades (feature-based). Para iniciantes, uma divisão por camadas costuma deixar as dependências mais explícitas.
Convenções de nomes e estrutura recomendada
Convenções de nomes
- Pacotes e módulos:
snake_case(ex.:order_service.py,repositories). - Classes:
PascalCase(ex.:OrderService). - Funções e métodos:
snake_case. - Constantes:
UPPER_SNAKE_CASE. - Arquivos executáveis/entrada: frequentemente
main.pyoucli.py.
Estrutura mínima (arquitetura simples em camadas)
Exemplo de estrutura para um projeto pequeno, mas já organizado:
meu_projeto/ # raiz do repositório (pasta do projeto) src/ app/ # pacote principal da aplicação __init__.py domain/ # regras de negócio (entidades/serviços puros) __init__.py models.py services.py application/ # casos de uso / orquestração __init__.py use_cases.py infrastructure/ # acesso a dados, APIs externas, IO __init__.py repositories.py interfaces/ # entrada/saída: CLI, web, etc. __init__.py cli.py main.py # ponto de entrada (executável) tests/ test_use_cases.py pyproject.tomlObservação: a pasta src/ é uma prática comum para evitar importações acidentais a partir da raiz. Se preferir, você pode colocar o pacote app/ diretamente na raiz, mas mantenha a separação interna.
Controlando dependências entre camadas
Uma regra simples para evitar acoplamento: dependências devem apontar “para dentro” (para o domínio), e não o contrário.
- Ouça o áudio com a tela desligada
- Ganhe Certificado após a conclusão
- + de 5000 cursos para você explorar!
Baixar o aplicativo
| Camada | Responsabilidade | Pode importar | Evitar importar |
|---|---|---|---|
| domain | Regras de negócio e modelos | Somente Python padrão e módulos do próprio domínio | Infraestrutura, frameworks, IO |
| application | Orquestrar casos de uso | domain | interfaces (para não acoplar a UI) |
| infrastructure | Persistência, integrações, IO | domain (para implementar contratos), libs externas | interfaces (em geral) |
| interfaces | CLI/web, apresentação | application (e às vezes domain para DTOs simples) | infra diretamente (prefira passar via aplicação) |
Quando você respeita essas direções, fica mais fácil testar: o domínio e a aplicação podem ser testados sem banco de dados, sem rede e sem CLI.
Exemplo de dependência saudável (injeção simples)
Em vez de um caso de uso criar um repositório concreto (infra), ele recebe um objeto que cumpre um contrato. Assim, a aplicação depende de uma abstração, e a infraestrutura “pluga” a implementação.
# src/app/application/use_cases.py from app.domain.services import TotalCalculator class CheckoutUseCase: def __init__(self, order_repository): self._order_repository = order_repository self._calculator = TotalCalculator() def execute(self, order_id: str) -> float: order = self._order_repository.get(order_id) total = self._calculator.calculate(order) self._order_repository.save(order) return total# src/app/infrastructure/repositories.py class InMemoryOrderRepository: def __init__(self): self._db = {} def get(self, order_id: str): return self._db[order_id] def save(self, order): self._db[order.id] = order# src/app/interfaces/cli.py from app.application.use_cases import CheckoutUseCase def run_cli(order_repository): use_case = CheckoutUseCase(order_repository) order_id = input("Order id: ") total = use_case.execute(order_id) print(f"Total: {total}")Note que use_cases.py não importa repositories.py. Quem conecta tudo é o ponto de entrada.
Como evitar importações circulares
Importação circular acontece quando A importa B e B importa A (direta ou indiretamente). Em projetos OO isso costuma surgir quando:
- Dois módulos têm responsabilidades misturadas (ex.: modelo importando repositório e repositório importando modelo).
- Você coloca “tudo” em
__init__.pye cria dependências implícitas. - Há código executável no topo do módulo que depende de imports ainda não resolvidos.
Sinais e sintomas
- Erros como
ImportError: cannot import name ... from partially initialized module. - Um módulo funciona quando executado isoladamente, mas falha quando importado.
Estratégias práticas
- Separe responsabilidades: se dois módulos se importam mutuamente, provavelmente deveriam ser reorganizados (ex.: mover um contrato para um terceiro módulo).
- Importe módulos, não símbolos, quando fizer sentido:
import app.domain.models as modelspode reduzir colisões e tornar dependências mais claras. - Use import local (dentro de função) como último recurso: útil para quebrar ciclos pontuais, mas pode esconder problemas de arquitetura.
- Crie um módulo de “contratos”: interfaces/Protocol/ABC em um arquivo neutro (ex.:
domain/ports.py) para que aplicação e infraestrutura dependam dele. - Evite efeitos colaterais no import: não execute lógica no topo do módulo (conexão com banco, leitura de arquivo, etc.).
Exemplo: quebrando um ciclo com um módulo de contratos
# src/app/domain/ports.py from typing import Protocol class OrderRepository(Protocol): def get(self, order_id: str): ... def save(self, order) -> None: ...# src/app/application/use_cases.py from app.domain.ports import OrderRepository class CheckoutUseCase: def __init__(self, repo: OrderRepository): self._repo = repo# src/app/infrastructure/repositories.py from app.domain.ports import OrderRepository class InMemoryOrderRepository(OrderRepository): ...Agora aplicação e infraestrutura “se encontram” em ports.py, e o domínio não precisa importar infraestrutura.
Expondo uma API de pacote com __init__.py
O arquivo __init__.py define o que um pacote expõe. Você pode usá-lo para oferecer uma API mais simples, evitando que o usuário do pacote precise conhecer a estrutura interna.
Exemplo: API pública do pacote
# src/app/application/__init__.py from .use_cases import CheckoutUseCase __all__ = ["CheckoutUseCase"]Com isso, quem usa pode fazer:
from app.application import CheckoutUseCaseBoas práticas:
- Exponha pouco: apenas o que é “público” e estável.
- Evite imports pesados: não importe coisas que disparam conexões/IO.
- Use __all__: ajuda a documentar a API pública e controlar
from pacote import *.
Isolando código executável com if __name__ == '__main__'
Um módulo pode ser importado (para reutilização/testes) ou executado como script. O bloco if __name__ == '__main__': garante que um trecho rode apenas quando o arquivo for executado diretamente, e não quando for importado.
Exemplo no ponto de entrada
# src/app/main.py from app.infrastructure.repositories import InMemoryOrderRepository from app.interfaces.cli import run_cli def build_container(): repo = InMemoryOrderRepository() return repo if __name__ == "__main__": repo = build_container() run_cli(repo)Esse padrão ajuda a manter o restante do código “importável” e testável, porque a montagem de dependências e a execução ficam concentradas no entrypoint.
Passo a passo: reorganizando um código monolítico
Você vai pegar um arquivo único com regras de negócio, persistência e interface misturadas e transformá-lo em uma estrutura clara e testável.
Ponto de partida (monolítico)
# monolito.py import json class Product: def __init__(self, id, name, price): self.id = id self.name = name self.price = price class Cart: def __init__(self): self.items = [] def add(self, product, qty): self.items.append((product, qty)) def total(self): return sum(p.price * q for p, q in self.items) def save_cart(cart, path="cart.json"): data = [{"id": p.id, "name": p.name, "price": p.price, "qty": q} for p, q in cart.items] with open(path, "w", encoding="utf-8") as f: json.dump(data, f) def load_cart(path="cart.json"): cart = Cart() with open(path, "r", encoding="utf-8") as f: data = json.load(f) for item in data: cart.add(Product(item["id"], item["name"], item["price"]), item["qty"]) return cart def main(): cart = Cart() cart.add(Product("p1", "Mouse", 50.0), 2) cart.add(Product("p2", "Teclado", 120.0), 1) print("Total:", cart.total()) save_cart(cart) if __name__ == "__main__": main()Objetivo da reorganização
- domain:
ProducteCart(sem JSON, sem arquivo). - infrastructure: persistência em JSON (salvar/carregar).
- application: caso de uso “montar carrinho e calcular total” (ou “carregar e calcular”).
- interfaces/main: ponto de entrada que monta dependências e executa.
Passo 1: criar a estrutura de pastas
meu_projeto/ src/ app/ __init__.py domain/ __init__.py models.py application/ __init__.py use_cases.py infrastructure/ __init__.py json_storage.py interfaces/ __init__.py cli.py main.py tests/ test_cart_total.pyPasso 2: mover o domínio para domain/models.py
# src/app/domain/models.py class Product: def __init__(self, id: str, name: str, price: float): self.id = id self.name = name self.price = price class Cart: def __init__(self): self.items = [] # lista de (Product, qty) def add(self, product: Product, qty: int) -> None: self.items.append((product, qty)) def total(self) -> float: return sum(p.price * q for p, q in self.items)Repare que não há json nem leitura/escrita de arquivo aqui.
Passo 3: criar a persistência em infrastructure/json_storage.py
# src/app/infrastructure/json_storage.py import json from app.domain.models import Cart, Product class JsonCartStorage: def __init__(self, path: str = "cart.json"): self.path = path def save(self, cart: Cart) -> None: data = [ {"id": p.id, "name": p.name, "price": p.price, "qty": q} for p, q in cart.items ] with open(self.path, "w", encoding="utf-8") as f: json.dump(data, f) def load(self) -> Cart: cart = Cart() with open(self.path, "r", encoding="utf-8") as f: data = json.load(f) for item in data: cart.add(Product(item["id"], item["name"], item["price"]), item["qty"]) return cartA infraestrutura importa o domínio, mas o domínio não importa a infraestrutura.
Passo 4: criar um caso de uso em application/use_cases.py
# src/app/application/use_cases.py from app.domain.models import Cart, Product class BuildSampleCartUseCase: def execute(self) -> Cart: cart = Cart() cart.add(Product("p1", "Mouse", 50.0), 2) cart.add(Product("p2", "Teclado", 120.0), 1) return cart class SaveCartUseCase: def __init__(self, storage): self._storage = storage def execute(self, cart: Cart) -> None: self._storage.save(cart)O caso de uso recebe storage como dependência. Assim, você pode trocar por um “fake” em testes.
Passo 5: criar a interface (CLI) em interfaces/cli.py
# src/app/interfaces/cli.py def print_total(cart): print("Total:", cart.total())Passo 6: montar tudo no entrypoint main.py
# src/app/main.py from app.application.use_cases import BuildSampleCartUseCase, SaveCartUseCase from app.infrastructure.json_storage import JsonCartStorage from app.interfaces.cli import print_total def main(): cart = BuildSampleCartUseCase().execute() print_total(cart) storage = JsonCartStorage("cart.json") SaveCartUseCase(storage).execute(cart) if __name__ == "__main__": main()Atividade prática (mão na massa)
Enunciado
Você recebeu um arquivo billing.py que mistura: cálculo de fatura, desconto, leitura de arquivo e impressão. Sua tarefa é reorganizar em um projeto com pacotes e camadas, garantindo que o domínio não dependa de IO e que o ponto de entrada concentre a execução.
Código monolítico para reorganizar
# billing.py import csv class Invoice: def __init__(self): self.lines = [] # (desc, amount) def add_line(self, desc, amount): self.lines.append((desc, float(amount))) def total(self): return sum(a for _, a in self.lines) def load_invoice(path): inv = Invoice() with open(path, newline="", encoding="utf-8") as f: reader = csv.reader(f) for desc, amount in reader: inv.add_line(desc, amount) return inv def apply_discount(inv, percent): total = inv.total() return total * (1 - percent/100) def main(): inv = load_invoice("invoice.csv") final = apply_discount(inv, 10) print("Final:", final) if __name__ == "__main__": main()Requisitos da reorganização
- Crie um pacote
appcom subpacotes:domain,application,infrastructure,interfaces. domain: conterInvoicee regras de cálculo (ex.: total).infrastructure: conter o carregamento CSV (ex.:CsvInvoiceLoader).application: conter um caso de uso (ex.:CalculateDiscountedTotalUseCase) que recebe um loader e retorna o valor final.interfaces: conter uma função simples para exibir o resultado.main.py: montar dependências e executar comif __name__ == '__main__'.- Expor no
app/application/__init__.pyo caso de uso principal via import e__all__.
Checklist de verificação
- O módulo de domínio não importa
csvnem fazopen(). - O caso de uso não importa diretamente a interface (CLI/print).
- Não há importações circulares ao rodar
python -m app.main(ou executarmain.py). - Você consegue testar o caso de uso passando um loader falso (sem ler arquivo).
Desafio extra (testabilidade)
Crie um teste em tests/ que valida o desconto sem ler CSV, usando um loader fake:
# tests/test_discount.py from app.application import CalculateDiscountedTotalUseCase from app.domain.models import Invoice class FakeLoader: def load(self, path: str) -> Invoice: inv = Invoice() inv.add_line("A", 100) inv.add_line("B", 50) return inv def test_discounted_total(): use_case = CalculateDiscountedTotalUseCase(loader=FakeLoader()) result = use_case.execute(path="ignored.csv", percent=10) assert result == 135.0