Organização de projetos Python Orientado a Objetos: módulos, pacotes e arquitetura simples

Capítulo 11

Tempo estimado de leitura: 11 minutos

+ Exercício

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.py ou cli.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.toml

Observaçã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.

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

CamadaResponsabilidadePode importarEvitar importar
domainRegras de negócio e modelosSomente Python padrão e módulos do próprio domínioInfraestrutura, frameworks, IO
applicationOrquestrar casos de usodomaininterfaces (para não acoplar a UI)
infrastructurePersistência, integrações, IOdomain (para implementar contratos), libs externasinterfaces (em geral)
interfacesCLI/web, apresentaçãoapplication (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__.py e 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 models pode 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 CheckoutUseCase

Boas 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: Product e Cart (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.py

Passo 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 cart

A 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 app com subpacotes: domain, application, infrastructure, interfaces.
  • domain: conter Invoice e 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 com if __name__ == '__main__'.
  • Expor no app/application/__init__.py o caso de uso principal via import e __all__.

Checklist de verificação

  • O módulo de domínio não importa csv nem faz open().
  • 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 executar main.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

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

Em uma arquitetura simples em camadas (domain, application, infrastructure, interfaces), qual opção descreve melhor uma forma de reduzir acoplamento e melhorar a testabilidade ao implementar um caso de uso?

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

Você errou! Tente novamente.

Ao injetar dependências, a aplicação depende de um contrato/objeto passado de fora, e o domínio fica livre de IO. Isso facilita testar com fakes e mantém as dependências apontando para dentro (em direção ao domínio).

Próximo capitúlo

Boas práticas de POO em Python: legibilidade, reutilização, acoplamento e coesão

Arrow Right Icon
Capa do Ebook gratuito Python Orientado a Objetos para Iniciantes: Classes, Herança e Boas Práticas
79%

Python Orientado a Objetos para Iniciantes: Classes, Herança e Boas Práticas

Novo curso

14 páginas

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