Trilha de aprendizado · Nível 15 · Tutorial 2

Separar domínio, persistência e interface

Organizar a aplicação em componentes com contratos claros, mantendo as regras do catálogo independentes do terminal e do formato de armazenamento.

  • Nível: Avançado
  • Duração: 25 min
  • 8 passos
Separar domínio, persistência e interface

O que você vai percorrer

  1. Reconhecer as responsabilidades do catálogo Use o cadastro de um item para distinguir regras do catálogo, execução do caso de uso, armazenamento e interação com o terminal. 2 min
  2. Separar dependências de chamadas Veja como manter as importações voltadas para modelos e contratos internos, mesmo quando a execução percorre interface, caso de uso e adaptador. 3 min
  3. Manter regras e modelos livres de tecnologia Delimite o modelo interno do catálogo para que suas regras não dependam de terminal, arquivos ou formatos externos. 3 min
  4. Definir o contrato que o caso de uso precisa Especifique a fronteira de persistência a partir do que o cadastro realmente precisa observar. 3 min
  5. Implementar o cadastro com dependência injetada Implemente um caso de uso de cadastro que depende apenas de um contrato de repositório e valide seu comportamento com um adaptador em memória. 4 min
  6. Confinar o formato JSON ao adaptador Implemente um adaptador JSON que traduza registros externos para modelos internos, preserve o contrato da aplicação e sinalize dados inválidos como falhas de persistência. 4 min
  7. Conectar interface e persistência na composição Monte os componentes concretos em um único ponto e mantenha a escolha do armazenamento fora das regras do catálogo. 3 min
  8. Validar as fronteiras e a substituição do adaptador Conclua o recorte do catálogo executando os mesmos cenários com persistência em memória e em JSON, e verifique onde cada dependência pode existir. 4 min

O que você vai aprender

  • Distribuir responsabilidades entre domínio, casos de uso e adaptadores externos.
  • Definir o contrato de persistência a partir das necessidades dos casos de uso.
  • Implementar um caso de uso sem importar detalhes da interface ou do armazenamento.
  • Montar os componentes em um ponto de composição e verificar a substituição do adaptador de persistência.

Antes de começar

  • Definir requisitos verificáveis para uma aplicação de linha de comando
  • Organizar módulos em pacotes locais
  • Definir interfaces estruturais com Protocol
  • Isolar dependências com substitutos de teste
  • Salvar e carregar dados em JSON

Passo 1 de 8

Reconhecer as responsabilidades do catálogo

Use o cadastro de um item para distinguir regras do catálogo, execução do caso de uso, armazenamento e interação com o terminal.

Um recorte observável

Cadastro mínimo do catálogo

Retomando os critérios de aceitação, considere um recorte autocontido: ao cadastrar um item, o código deve ser único, o título não pode ser vazio e, quando o cadastro for aceito, o item deve poder ser encontrado depois.

Esses resultados não dizem que a regra precisa conhecer terminal, arquivos ou JSON. Eles descrevem o que o catálogo deve garantir.

Exemplo

Critério não é detalhe técnico

Regra do catálogo: “Não aceitar dois itens com o mesmo código.”

Decisão externa: guardar os itens em um arquivo JSON e mostrar “Item cadastrado” no terminal.

A primeira continua válida se a interface ou o armazenamento mudarem; as demais podem mudar sem alterar o significado de código único.

Quatro responsabilidades, quatro fronteiras

Onde cada decisão pertence

Domínio representa Item e protege regras próprias, como título não vazio.

Aplicação executa o caso de uso “cadastrar item”: coordena a validação, a verificação de duplicidade e a inclusão.

Persistência é um adaptador externo: lê e grava dados no formato escolhido.

Interface é outro adaptador externo: recebe valores do terminal e apresenta o resultado. Ela não decide as regras do catálogo.

Da função misturada às responsabilidades separadas

Compare uma implementação concentrada com uma distribuição que preserva as regras internas quando JSON ou terminal mudam.

Diagrama comparando uma função única que valida título, consulta JSON, grava arquivo e imprime mensagem com quatro componentes separados: domínio, aplicação, persistência e interface.

À esquerda, vários motivos de mudança se acumulam em uma função. À direita, cada fronteira concentra uma responsabilidade.

Dica

Separe por motivo de mudança

Não crie uma classe para cada função. A separação é útil quando há uma responsabilidade distinta: uma regra do catálogo, a coordenação de uma ação, um formato de armazenamento ou uma forma de interação. Para este recorte, poucos módulos e funções bem nomeados já podem bastar.

Classifique as decisões

Associe a ação à responsabilidade principal

Relacione cada ação à fronteira que deve ser sua responsável principal.

Toque em um item e depois no par correspondente.

Passo 2 de 8

Separar dependências de chamadas

Veja como manter as importações voltadas para modelos e contratos internos, mesmo quando a execução percorre interface, caso de uso e adaptador.

Dois sentidos diferentes

Importar não é o mesmo que chamar

No cadastro, a execução pode seguir interface → aplicação → adaptador. Já as importações devem proteger o núcleo: o domínio não importa interface, persistência nem aplicação; a aplicação importa modelos e contratos internos, nunca um adaptador concreto.

Portanto, uma seta de chamada em execução não determina uma seta de importação entre módulos.

Dependências e chamadas

Compare os dois diagramas: acima estão as importações entre módulos; abaixo, as chamadas feitas quando um cadastro é executado.

Diagrama em duas partes. Na parte superior, setas de importação apontam de interface, adaptadores e aplicação para domínio e contrato da aplicação. Na parte inferior, setas de execução seguem da interface para aplicação e depois para um objeto adaptador.

O adaptador pode receber chamadas da aplicação sem que a aplicação importe sua implementação.

Um mapa enxuto de módulos

Fronteiras proporcionais

Uma organização local já basta para explicitar as fronteiras. Não é preciso criar uma classe ou diretório para cada função:

  • dominio/item.py: modelo e regras próprias do item.
  • aplicacao/cadastrar_item.py: caso de uso.
  • aplicacao/portas.py: contrato consumido pela aplicação.
  • adaptadores/repositorio_json.py: detalhe de armazenamento.
  • interface/terminal.py: coleta e apresentação de valores.
  • composicao.py: módulo que conhecerá as implementações concretas.

Isso é uma organização de código, não uma decisão de empacotamento ou distribuição.

A aplicação depende do contrato, não do JSON

O caso de uso recebe um objeto compatível com o contrato. Ele não cria nem importa um repositório JSON.

python
# aplicacao/portas.py
from typing import Protocol


class Catalogo(Protocol):
    # As operações necessárias serão definidas no próximo passo.
    ...


# aplicacao/cadastrar_item.py
from aplicacao.portas import Catalogo
from dominio.item import Item


def cadastrar_item(item: Item, catalogo: Catalogo) -> Item:
    # Consulta e inclusão pertencem a este fluxo de aplicação.
    # Não importe JsonCatalogo nem crie adaptadores aqui.
    return item


# adaptadores/repositorio_json.py
from aplicacao.portas import Catalogo


class CatalogoJson:
    # Implementará o comportamento esperado por Catalogo.
    ...

Encontre o vazamento

Qual trecho viola a fronteira?

Qual alternativa revela um vazamento arquitetural dentro de aplicacao/cadastrar_item.py?

Confira a direção correta

Fluxo versus dependência

Como o caso de uso chama o objeto adaptador durante o cadastro, ele precisa importar a implementação concreta desse adaptador.

Passo 3 de 8

Manter regras e modelos livres de tecnologia

Delimite o modelo interno do catálogo para que suas regras não dependam de terminal, arquivos ou formatos externos.

O que pertence ao item do catálogo

Um modelo que conhece apenas o catálogo

O modelo de domínio representa um item válido do catálogo: código e título. Ele pode verificar uma regra que depende somente desses valores, como “o título não pode estar vazio”.

Ele não deve saber se os valores vieram do terminal, de uma API ou de um teste. Também não deve saber onde o item será salvo. Essas são decisões externas ao modelo.

A fronteira do modelo

O domínio recebe valores já separados e produz um modelo válido — ou sinaliza uma falha de negócio. Formatação para pessoas e detalhes técnicos ficam fora dessa fronteira.

Diagrama mostrando valores de entrada à esquerda, um item de catálogo válido no centro dentro de uma fronteira e terminal, arquivo e registro externo à direita, todos fora da fronteira do domínio.

O item conhece seus valores e suas regras locais; não conhece a origem nem o destino dos dados.

Dica

Critério prático

Pergunte: “Consigo decidir isso olhando apenas para um item?” Se sim, a regra provavelmente pode ficar no modelo. Se precisa observar outros itens ou um recurso externo, ela não é uma regra local do item.

Validade local e resultados do domínio

Modelo mínimo e independente

Arquivo sugerido: dominio.py

python
from dataclasses import dataclass


class TituloInvalidoError(ValueError):
    """O título não atende a uma regra do catálogo."""


@dataclass(frozen=True)
class ItemCatalogo:
    codigo: str
    titulo: str

    def __post_init__(self) -> None:
        if not self.titulo.strip():
            raise TituloInvalidoError(
                "Um item do catálogo precisa de título."
            )

Exceção e retorno não são interface

Ao construir ItemCatalogo, um título em branco gera TituloInvalidoError: uma falha compreensível para a aplicação. Quando a construção funciona, o resultado é o próprio ItemCatalogo.

Não coloque aqui print, encerramento do processo, caminho de arquivo, leitura de teclado nem uma mensagem preparada para a tela. Mais adiante, a interface decidirá como apresentar uma falha; o armazenamento decidirá como preservar o item.

Exemplo

Duas regras, dois lugares

ItemCatalogo("A-10", " ") pode ser rejeitado imediatamente: a regra usa somente o título.

Já decidir se o código A-10 é único exige consultar o catálogo existente. Portanto, essa verificação não cabe dentro de ItemCatalogo; ela será responsabilidade da aplicação ao coordenar a operação de cadastro.

Separar o que o domínio não pode decidir

Delimite as responsabilidades

Considere a necessidade: cadastrar um item com título não vazio e código único. Justifique onde devem ficar: (1) a validação do título, (2) a consulta necessária para detectar duplicidade e (3) a apresentação de sucesso ou erro à pessoa usuária. Diga também o que o domínio deve devolver ou sinalizar.

Escreva pelo menos 120 caracteres (0/120).

Resumo

Modelo proporcional

  • Um modelo de domínio expressa dados e regras próprias do catálogo, sem conhecer terminal, arquivos ou formatos externos.
  • Título não vazio é uma regra local do item; unicidade do código exige consultar o catálogo e não pertence ao modelo isolado.
  • O domínio retorna valores internos e sinaliza falhas de negócio; a interface decide como exibi-los.
  • Uma dataclass e uma exceção específica já formam uma fronteira útil. Não é necessário criar uma hierarquia de classes para cada pequena operação.

Passo 4 de 8

Definir o contrato que o caso de uso precisa

Especifique a fronteira de persistência a partir do que o cadastro realmente precisa observar.

O contrato pertence ao consumidor

Persistência vista pelo caso de uso

O caso de uso de cadastro não precisa saber como os dados são armazenados. Ele só precisa de duas capacidades: procurar um código e incluir um item.

Por isso, o contrato fica próximo da aplicação consumidora. Ele importa apenas o modelo interno Item e define o menor conjunto de operações necessário. Um adaptador externo poderá cumprir esse contrato com memória, arquivo ou outra tecnologia, sem alterar o caso de uso.

Fronteira mínima do repositório

A aplicação conhece o contrato; detalhes externos ficam do outro lado da fronteira.

Diagrama mostrando o caso de uso Cadastro apontando para um contrato de repositório com as operações buscar_por_codigo e adicionar; adaptadores externos aparecem atrás do contrato, sem ligação direta com o domínio.

O caso de uso depende da abstração que consome, e não de uma implementação de armazenamento.

Contrato orientado ao cadastro

Arquivo sugerido: aplicacao/contratos.py. O Protocol descreve o que a aplicação exige, não como alguém deve implementar.

python
from typing import Protocol

from dominio.item import Item


class FalhaDePersistencia(Exception):
    """Não foi possível executar uma operação exigida pelo contrato."""


class RepositorioDeItens(Protocol):
    def buscar_por_codigo(self, codigo: str) -> Item | None:
        """Retorna o item cadastrado ou None se o código estiver ausente.

        Não altera o catálogo.
        Pode levantar FalhaDePersistencia.
        """

    def adicionar(self, item: Item) -> None:
        """Inclui um item cujo código ainda não está cadastrado.

        Após retornar, uma busca pelo código do item deve encontrá-lo.
        Pode levantar FalhaDePersistencia.
        """

Assinatura não basta: defina o comportamento

Exemplo

Três resultados diferentes

Considere buscar_por_codigo("A-10"):

  • Retornar None: a consulta funcionou e não existe item com esse código.
  • O caso de uso recusar o cadastro: encontrou um item e aplica a regra de código único.
  • Levantar FalhaDePersistencia: a consulta não pôde ser concluída de modo confiável.

Não transforme uma falha de leitura em None. Isso faria a aplicação interpretar “não consegui consultar” como “o código está livre” e poderia permitir uma duplicidade.

Efeitos e limites explícitos

A semântica do contrato completa as assinaturas:

  • buscar_por_codigo é uma consulta: não altera o estado.
  • adicionar tem efeito observável: depois de retornar, a busca pelo mesmo código encontra o item.
  • A pré-condição de adicionar é que o código ainda não esteja cadastrado; o caso de uso verifica isso antes de pedir a inclusão.

Esse é um recorte sequencial. A sequência “buscar, depois adicionar” não promete atomicidade nem segurança entre escritores concorrentes. Essas garantias exigiriam requisitos e mecanismos adicionais.

Dica

Mínimo útil, não CRUD automático

Não acrescente listar, remover, atualizar ou métodos genéricos como salvar apenas por hábito. O contrato deve expressar o comportamento que este consumidor precisa agora. Compatibilidade é cumprir esses comportamentos, e não somente ter métodos com nomes parecidos.

Verifique as distinções do contrato

Relacione situação e significado

Associe cada situação ao significado correto no contrato de persistência.

Toque em um item e depois no par correspondente.

Passo 5 de 8

Implementar o cadastro com dependência injetada

Implemente um caso de uso de cadastro que depende apenas de um contrato de repositório e valide seu comportamento com um adaptador em memória.

Orquestrar sem conhecer a tecnologia

A sequência do caso de uso

O caso de uso coordena o cadastro nesta ordem:

  1. Cria um Item válido.
  2. Consulta se o código já existe.
  3. Recusa o cadastro se encontrar um item.
  4. Solicita a inclusão ao repositório recebido.
  5. Retorna o item cadastrado.

Ele recebe o repositório como parâmetro. Portanto, sabe o que precisa fazer, mas não sabe se os dados estão em memória, em JSON ou em outra tecnologia.

Fluxo e fronteira do caso de uso

A consulta e a inclusão são chamadas por meio do contrato; a implementação concreta permanece fora do caso de uso.

Diagrama mostrando valores de código e título entrando no caso de uso de cadastro, que cria um item, consulta um contrato de repositório e solicita inclusão em um repositório em memória externo.

O caso de uso chama o contrato. O adaptador concreto realiza o armazenamento.

Dica

Injeção explícita

Criar RepositorioEmMemoria() dentro de cadastrar() acoplaria o caso de uso à implementação. Receber repositorio por parâmetro torna a dependência visível e substituível.

Criar domínio, contrato e caso de uso

Estrutura local

No seu computador, crie a pasta catalogo com um arquivo vazio __init__.py e os três arquivos abaixo. Depois, crie uma pasta tests. O exemplo é autocontido e não depende de arquivos de aulas anteriores.

catalogo/dominio.py

python
from dataclasses import dataclass


class TituloInvalido(ValueError):
    pass


class CodigoDuplicado(ValueError):
    pass


@dataclass(frozen=True)
class Item:
    codigo: str
    titulo: str

    def __post_init__(self) -> None:
        if not self.titulo.strip():
            raise TituloInvalido("O título não pode ser vazio.")

catalogo/contratos.py

python
from typing import Protocol

from catalogo.dominio import Item


class RepositorioDeItens(Protocol):
    def buscar_por_codigo(self, codigo: str) -> Item | None:
        """Retorna o item encontrado ou None quando o código está ausente."""

    def adicionar(self, item: Item) -> None:
        """Inclui um item cujo código ainda não está cadastrado."""

catalogo/cadastro.py

python
from catalogo.contratos import RepositorioDeItens
from catalogo.dominio import CodigoDuplicado, Item


def cadastrar(
    codigo: str,
    titulo: str,
    repositorio: RepositorioDeItens,
) -> Item:
    item = Item(codigo=codigo, titulo=titulo)

    if repositorio.buscar_por_codigo(codigo) is not None:
        raise CodigoDuplicado(f"O código {codigo!r} já está cadastrado.")

    repositorio.adicionar(item)
    return item

Substituir armazenamento por memória e testar

Adaptador mínimo para testes

Este adaptador satisfaz o comportamento necessário sem herdar do Protocol. Ele guarda itens em um dicionário e expõe quantidade_de_gravacoes para tornar observável que uma rejeição não provocou inclusão.

catalogo/memoria.py

python
from catalogo.dominio import Item


class RepositorioEmMemoria:
    def __init__(self) -> None:
        self._itens: dict[str, Item] = {}
        self.quantidade_de_gravacoes = 0

    def buscar_por_codigo(self, codigo: str) -> Item | None:
        return self._itens.get(codigo)

    def adicionar(self, item: Item) -> None:
        self._itens[item.codigo] = item
        self.quantidade_de_gravacoes += 1

tests/test_cadastro.py

python
import pytest

from catalogo.cadastro import cadastrar
from catalogo.dominio import CodigoDuplicado, TituloInvalido
from catalogo.memoria import RepositorioEmMemoria


def test_cadastra_item_e_o_torna_consultavel() -> None:
    repositorio = RepositorioEmMemoria()

    resultado = cadastrar("A1", "Python avançado", repositorio)

    assert resultado.codigo == "A1"
    assert repositorio.buscar_por_codigo("A1") == resultado
    assert repositorio.quantidade_de_gravacoes == 1


def test_titulo_invalido_nao_grava() -> None:
    repositorio = RepositorioEmMemoria()

    with pytest.raises(TituloInvalido):
        cadastrar("A1", "   ", repositorio)

    assert repositorio.buscar_por_codigo("A1") is None
    assert repositorio.quantidade_de_gravacoes == 0


def test_codigo_duplicado_nao_grava_novamente() -> None:
    repositorio = RepositorioEmMemoria()
    cadastrar("A1", "Primeiro título", repositorio)

    with pytest.raises(CodigoDuplicado):
        cadastrar("A1", "Outro título", repositorio)

    assert repositorio.buscar_por_codigo("A1").titulo == "Primeiro título"
    assert repositorio.quantidade_de_gravacoes == 1

Exemplo

Executar localmente

Na raiz que contém as pastas catalogo e tests, execute:

python -m pytest -q

O resultado esperado é 3 passed. Nos dois cenários de rejeição, a contagem de gravações permanece em 0 ou não aumenta além da gravação inicial.

Verificar a evidência

Relate sua execução

Execute os testes no seu computador. Quais foram os resultados? Cite a evidência de que o título inválido e a tentativa de código duplicado não causaram uma nova gravação.

Escreva pelo menos 80 caracteres (0/80).

Passo 6 de 8

Confinar o formato JSON ao adaptador

Implemente um adaptador JSON que traduza registros externos para modelos internos, preserve o contrato da aplicação e sinalize dados inválidos como falhas de persistência.

Uma fronteira para o formato externo

O JSON para no adaptador

O repositório JSON é dono do caminho do arquivo, da leitura, da gravação e dos nomes presentes no registro armazenado. O caso de uso continua trabalhando apenas com Item e com o contrato de repositório.

Assim, {"codigo": "PY-01", "titulo": "Python"} é um detalhe do adaptador. O modelo interno não recebe dicionários, caminhos nem decide como um arquivo será escrito.

Tradução em cada direção

O adaptador converte o formato externo ao entrar e ao sair da aplicação.

Diagrama com um registro JSON à esquerda, uma camada adaptadora no centro e um objeto de item de catálogo à direita; setas mostram leitura convertendo JSON em item e gravação convertendo item em JSON.

Na leitura: registro JSON → adaptador → Item válido. Na gravação: Item → adaptador → registro JSON. A aplicação não conhece os nomes dos campos nem o arquivo.

Atenção

Ausência não é corrupção

Neste exemplo, somente um arquivo inicialmente ausente significa catálogo vazio. JSON malformado, uma estrutura que não seja lista, registros incompletos ou valores que não formem um Item válido são falhas de persistência. Não transforme esses casos em uma lista vazia: isso esconderia dados danificados.

Implementar o adaptador JSON

Falhas preservam a causa

O código abaixo pressupõe os tipos já criados nos passos anteriores: Item valida código e título; RepositorioCatalogo é o Protocol consumido pela aplicação; e FalhaPersistencia representa uma falha prevista nessa fronteira. O adaptador encadeia a exceção técnica com from exc, preservando sua causa para diagnóstico.

catalogo/adaptadores/repositorio_json.py

python
from __future__ import annotations

import json
from pathlib import Path

from catalogo.aplicacao.portas import FalhaPersistencia
from catalogo.dominio.item import Item


class RepositorioJson:
    def __init__(self, caminho: Path) -> None:
        self._caminho = caminho

    def buscar_por_codigo(self, codigo: str) -> Item | None:
        for item in self._carregar_itens():
            if item.codigo == codigo:
                return item
        return None

    def adicionar(self, item: Item) -> None:
        itens = self._carregar_itens()
        registros = [
            {"codigo": existente.codigo, "titulo": existente.titulo}
            for existente in itens
        ]
        registros.append({"codigo": item.codigo, "titulo": item.titulo})

        try:
            conteudo = json.dumps(registros, ensure_ascii=False, indent=2)
            self._caminho.write_text(conteudo, encoding="utf-8")
        except OSError as exc:
            raise FalhaPersistencia("não foi possível gravar o catálogo") from exc

    def _carregar_itens(self) -> list[Item]:
        try:
            conteudo = self._caminho.read_text(encoding="utf-8")
        except FileNotFoundError:
            return []
        except OSError as exc:
            raise FalhaPersistencia("não foi possível ler o catálogo") from exc

        try:
            registros = json.loads(conteudo)
            if not isinstance(registros, list):
                raise TypeError("a raiz do JSON deve ser uma lista")

            return [
                Item(codigo=registro["codigo"], titulo=registro["titulo"])
                for registro in registros
                if isinstance(registro, dict)
            ]
        except (json.JSONDecodeError, KeyError, TypeError, ValueError) as exc:
            raise FalhaPersistencia("dados do catálogo são inválidos") from exc

Verificar a integração e a durabilidade

Reabra para observar o efeito

Use o mesmo caso de uso de cadastro do passo anterior, sem alterá-lo. A primeira instância grava usando o contrato; uma segunda instância, criada com o mesmo caminho, deve recuperar o item. Isso diferencia o JSON do adaptador em memória, que é útil em testes isolados, mas perde seus dados quando a instância deixa de existir.

tests/test_repositorio_json.py

python
import pytest

from catalogo.adaptadores.repositorio_json import RepositorioJson
from catalogo.aplicacao.portas import FalhaPersistencia
from catalogo.dominio.item import Item


def test_arquivo_ausente_representa_catalogo_vazio(tmp_path) -> None:
    repositorio = RepositorioJson(tmp_path / "catalogo.json")

    assert repositorio.buscar_por_codigo("PY-01") is None


def test_item_gravado_e_recuperado_por_nova_instancia(tmp_path) -> None:
    caminho = tmp_path / "catalogo.json"
    primeira_instancia = RepositorioJson(caminho)
    primeira_instancia.adicionar(Item(codigo="PY-01", titulo="Python avançado"))

    segunda_instancia = RepositorioJson(caminho)

    assert segunda_instancia.buscar_por_codigo("PY-01") == Item(
        codigo="PY-01",
        titulo="Python avançado",
    )


def test_json_invalido_e_falha_de_persistencia(tmp_path) -> None:
    caminho = tmp_path / "catalogo.json"
    caminho.write_text("{ nao e json }", encoding="utf-8")
    repositorio = RepositorioJson(caminho)

    with pytest.raises(FalhaPersistencia) as erro:
        repositorio.buscar_por_codigo("PY-01")

    assert isinstance(erro.value.__cause__, Exception)

Dica

Use o caso de uso já existente

No teste de cadastro completo, injete RepositorioJson(tmp_path / "catalogo.json") no mesmo caso de uso usado com o repositório em memória. Sucesso, título inválido e código duplicado devem manter o mesmo resultado de negócio. Apenas a composição escolhe qual adaptador entregar ao caso de uso.

Conferir a fronteira

Evidência de substituição

No seu computador, execute os testes. Relate duas evidências: o que aconteceu ao reabrir o repositório JSON e o que aconteceu diante de conteúdo inválido. Inclua por que o caso de uso não precisou ser modificado.

Escreva pelo menos 80 caracteres (0/80).

Passo 7 de 8

Conectar interface e persistência na composição

Monte os componentes concretos em um único ponto e mantenha a escolha do armazenamento fora das regras do catálogo.

A interface faz pouco — e faz no limite externo

Receber, chamar e apresentar

Depois que código e título já foram separados, a interface só encaminha esses valores ao caso de uso e apresenta o item retornado. Ela não abre arquivos, não conhece JSON e não decide se um código é único.

Para manter essa fronteira explícita, receba tanto o caso de uso quanto o repositório por parâmetro. Assim, a interface não precisa importar nenhum adaptador concreto.

interface_terminal.py

Uma interface local e fina, sem detalhes de armazenamento.

python
from collections.abc import Callable
from catalogo.dominio.item import Item


def cadastrar_e_mostrar(
    cadastrar: Callable[[object, str, str], Item],
    repositorio: object,
    codigo: str,
    titulo: str,
) -> None:
    item = cadastrar(repositorio, codigo, titulo)
    print(f"Item cadastrado: {item.codigo} — {item.titulo}")

Dica

Uma fronteira prática

print pertence à interface, pois é apresentação. A validação do título e a verificação de duplicidade continuam no modelo e no caso de uso, respectivamente.

A raiz de composição conecta as peças

Um único lugar conhece as implementações

A raiz de composição é o módulo que importa classes concretas, constrói objetos e os conecta. Ela pode conhecer RepositorioJson; o caso de uso não pode.

Neste exemplo local, os valores e o caminho são explícitos. Ainda não há parser de argumentos, configuração externa nem instalação do pacote.

Montagem versus execução

Observe que as importações concretas ficam concentradas na composição, enquanto a execução percorre os componentes já conectados.

Diagrama com uma raiz de composição conectando uma interface de terminal, um caso de uso central e dois adaptadores alternativos, memória e arquivo JSON; setas distinguem montagem e execução.

A composição constrói e injeta. Depois, a interface chama o caso de uso, que usa o repositório recebido.

composicao.py

A composição escolhe JSON e injeta o objeto construído na interface.

python
from pathlib import Path

from catalogo.adaptadores.repositorio_json import RepositorioJson
from catalogo.aplicacao.cadastrar_item import cadastrar_item
from catalogo.interface_terminal import cadastrar_e_mostrar


def executar_demonstracao() -> None:
    arquivo = Path("catalogo.json")
    repositorio = RepositorioJson(arquivo)

    cadastrar_e_mostrar(
        cadastrar_item,
        repositorio,
        codigo="LIV-001",
        titulo="Arquitetura Python",
    )


if __name__ == "__main__":
    executar_demonstracao()

Trocar armazenamento sem espalhar escolhas

A mudança é local

Para uma demonstração ou teste, a composição pode construir RepositorioMemoria no lugar de RepositorioJson. A interface, o caso de uso e o domínio permanecem iguais porque dependem do comportamento contratado, não da tecnologia escolhida.

Alternativa em memória

Troque somente a criação e a importação na raiz de composição.

python
from catalogo.adaptadores.repositorio_memoria import RepositorioMemoria


def executar_demonstracao_em_memoria() -> None:
    repositorio = RepositorioMemoria()

    cadastrar_e_mostrar(
        cadastrar_item,
        repositorio,
        codigo="LIV-001",
        titulo="Arquitetura Python",
    )

Atenção

Não leve a decisão para dentro

Evite condicionais como if usar_json no caso de uso ou no domínio. Também evite criar o repositório dentro de cadastrar_item. Esses locais passariam a conhecer uma decisão externa que a composição já resolve.

Verifique a montagem e o fluxo

Da composição ao efeito

Coloque as ações na ordem correta, desde a montagem até a persistência do item.

  1. A raiz de composição constrói um RepositorioJson com o caminho explícito.
  2. A interface chama cadastrar_item com código, título e o repositório recebido.
  3. A raiz fornece o repositório e a função cadastrar_item para a interface.
  4. O caso de uso valida, consulta e solicita a inclusão ao adaptador pelo contrato.

Onde trocar o adaptador?

Para mudar de persistência JSON para memória, qual componente deve ser alterado?

Passo 8 de 8

Validar as fronteiras e a substituição do adaptador

Conclua o recorte do catálogo executando os mesmos cenários com persistência em memória e em JSON, e verifique onde cada dependência pode existir.

O que a validação deve demonstrar

Mesmo comportamento, tecnologia trocável

A validação final não procura provar que memória e JSON funcionam internamente do mesmo jeito. Ela verifica o que o caso de uso observa: cadastro válido retorna o item, título vazio é rejeitado e código repetido é rejeitado.

Execute esses cenários contra os dois adaptadores. Depois, acrescente uma evidência exclusiva do JSON: crie outro adaptador apontando para o mesmo arquivo e consulte o item cadastrado. A memória é um substituto de teste volátil; ela não precisa sobreviver à recriação.

Fronteiras que serão inspecionadas

A composição conhece as implementações concretas; as camadas internas conhecem apenas modelos e contratos.

Diagrama com domínio no centro, caso de uso acima, interface à esquerda e dois adaptadores externos à direita: memória e documento JSON. Setas de dependência apontam das bordas para o núcleo; setas proibidas do núcleo para o JSON e para a interface aparecem interrompidas.

Trocar Memória por JSON muda a composição, não o domínio nem o caso de uso.

Dica

Limite do contrato

A sequência consultar e depois incluir é adequada para este exemplo sequencial. Ela não cria, por si só, uma operação atômica nem coordena dois escritores concorrentes.

Aplicação local completa

Crie estes arquivos

Em uma pasta local, crie o pacote catalogo e copie os módulos abaixo com estes nomes. O código contém domínio, contrato, adaptadores, interface fina e composição; não depende de parser de argumentos, configuração externa nem projeto pré-criado.

Módulos do pacote catalogo

Crie cada seção em seu respectivo arquivo.

python
# catalogo/dominio.py
from dataclasses import dataclass


class ItemInvalido(ValueError):
    pass


@dataclass(frozen=True, slots=True)
class Item:
    codigo: str
    titulo: str

    def __post_init__(self) -> None:
        if not self.titulo.strip():
            raise ItemInvalido("o título não pode ser vazio")


# catalogo/aplicacao.py
from typing import Protocol

from catalogo.dominio import Item


class FalhaDePersistencia(RuntimeError):
    pass


class CodigoDuplicado(ValueError):
    pass


class RepositorioDeItens(Protocol):
    def buscar_por_codigo(self, codigo: str) -> Item | None: ...

    def adicionar(self, item: Item) -> None: ...


class CadastrarItem:
    def __init__(self, repositorio: RepositorioDeItens) -> None:
        self._repositorio = repositorio

    def executar(self, codigo: str, titulo: str) -> Item:
        item = Item(codigo=codigo, titulo=titulo)
        if self._repositorio.buscar_por_codigo(item.codigo) is not None:
            raise CodigoDuplicado(f"código já cadastrado: {item.codigo}")
        self._repositorio.adicionar(item)
        return item


# catalogo/adaptadores.py
import json
from pathlib import Path

from catalogo.aplicacao import FalhaDePersistencia
from catalogo.dominio import Item


class RepositorioEmMemoria:
    def __init__(self) -> None:
        self._itens: dict[str, Item] = {}
        self.gravacoes = 0

    def buscar_por_codigo(self, codigo: str) -> Item | None:
        return self._itens.get(codigo)

    def adicionar(self, item: Item) -> None:
        self._itens[item.codigo] = item
        self.gravacoes += 1


class RepositorioJson:
    def __init__(self, caminho: Path) -> None:
        self._caminho = caminho

    def _carregar(self) -> list[Item]:
        if not self._caminho.exists():
            return []
        try:
            bruto = json.loads(self._caminho.read_text(encoding="utf-8"))
            if not isinstance(bruto, list):
                raise ValueError("a raiz JSON deve ser uma lista")
            return [Item(registro["codigo"], registro["titulo"]) for registro in bruto]
        except (OSError, json.JSONDecodeError, KeyError, TypeError, ValueError) as erro:
            raise FalhaDePersistencia("não foi possível carregar o catálogo") from erro

    def _salvar(self, itens: list[Item]) -> None:
        registros = [{"codigo": item.codigo, "titulo": item.titulo} for item in itens]
        try:
            self._caminho.write_text(
                json.dumps(registros, ensure_ascii=False, indent=2),
                encoding="utf-8",
            )
        except OSError as erro:
            raise FalhaDePersistencia("não foi possível gravar o catálogo") from erro

    def buscar_por_codigo(self, codigo: str) -> Item | None:
        return next((item for item in self._carregar() if item.codigo == codigo), None)

    def adicionar(self, item: Item) -> None:
        itens = self._carregar()
        itens.append(item)
        self._salvar(itens)


# catalogo/interface.py
from catalogo.aplicacao import CadastrarItem


def cadastrar_valores(cadastro: CadastrarItem, codigo: str, titulo: str) -> str:
    item = cadastro.executar(codigo, titulo)
    return f"Item cadastrado: {item.codigo} — {item.titulo}"


# catalogo/composicao.py
from pathlib import Path

from catalogo.adaptadores import RepositorioEmMemoria, RepositorioJson
from catalogo.aplicacao import CadastrarItem


def montar_com_memoria() -> CadastrarItem:
    return CadastrarItem(RepositorioEmMemoria())


def montar_com_json(caminho: Path) -> CadastrarItem:
    return CadastrarItem(RepositorioJson(caminho))

Execute os mesmos cenários nos dois adaptadores

Teste de fronteiras e compatibilidade

Salve como test_catalogo.py ao lado da pasta catalogo e execute pytest -q nessa pasta.

python
import pytest

from catalogo.adaptadores import RepositorioEmMemoria, RepositorioJson
from catalogo.aplicacao import CadastrarItem, CodigoDuplicado
from catalogo.dominio import ItemInvalido


@pytest.fixture(params=["memoria", "json"])
def repositorio(request, tmp_path):
    if request.param == "memoria":
        return RepositorioEmMemoria()
    return RepositorioJson(tmp_path / "catalogo.json")


def test_cadastra_item_em_qualquer_adaptador(repositorio):
    cadastro = CadastrarItem(repositorio)

    resultado = cadastro.executar("A1", "Python avançado")

    assert resultado.codigo == "A1"
    assert repositorio.buscar_por_codigo("A1") == resultado


def test_titulo_vazio_e_rejeitado_em_qualquer_adaptador(repositorio):
    cadastro = CadastrarItem(repositorio)

    with pytest.raises(ItemInvalido):
        cadastro.executar("A1", "   ")

    assert repositorio.buscar_por_codigo("A1") is None


def test_codigo_duplicado_e_rejeitado_em_qualquer_adaptador(repositorio):
    cadastro = CadastrarItem(repositorio)
    cadastro.executar("A1", "Python avançado")

    with pytest.raises(CodigoDuplicado):
        cadastro.executar("A1", "Outro título")

    assert repositorio.buscar_por_codigo("A1").titulo == "Python avançado"


def test_rejeicoes_de_negocio_nao_gravam_na_memoria():
    repositorio = RepositorioEmMemoria()
    cadastro = CadastrarItem(repositorio)

    with pytest.raises(ItemInvalido):
        cadastro.executar("A1", "")
    assert repositorio.gravacoes == 0

    cadastro.executar("A1", "Python avançado")
    with pytest.raises(CodigoDuplicado):
        cadastro.executar("A1", "Outro título")
    assert repositorio.gravacoes == 1


def test_json_persiste_apos_recriar_o_adaptador(tmp_path):
    caminho = tmp_path / "catalogo.json"
    CadastrarItem(RepositorioJson(caminho)).executar("A1", "Python avançado")

    repositorio_recriado = RepositorioJson(caminho)

    assert repositorio_recriado.buscar_por_codigo("A1") == ItemInesperadoNaoUsar


# Substitua apenas a última asserção acima por esta versão correta:
# assert repositorio_recriado.buscar_por_codigo("A1").titulo == "Python avançado"

Atenção

Faça a correção intencional do teste

O último teste contém deliberadamente uma referência inválida (ItemInesperadoNaoUsar). Antes de executar, substitua as duas últimas linhas indicadas pelo comentário. Isso torna explícita a evidência de durabilidade: um novo RepositorioJson lê o mesmo arquivo e recupera o título salvo.

Relato de execução

Após corrigir e executar o teste, relate: quais resultados de negócio foram iguais nos dois adaptadores, qual evidência ocorreu apenas no JSON e em que módulo está a escolha entre memória e JSON.

Escreva pelo menos 120 caracteres (0/120).

Revisão final das fronteiras

Resumo

Checklist arquitetural

Use esta revisão ao examinar seu código local.

  • dominio.py não importa interface, caminhos, JSON ou adaptadores.
  • aplicacao.py declara o Protocol consumido e orquestra o cadastro sem criar repositórios concretos.
  • adaptadores.py contém o caminho e o mapeamento entre registros JSON e Item; uma falha de leitura ou formato vira FalhaDePersistencia com a causa preservada.
  • interface.py recebe valores e apresenta o resultado, mas não carrega arquivos nem aplica a regra de unicidade.
  • composicao.py é o único módulo que importa os adaptadores concretos e decide qual será injetado.
  • O contrato permite substituição para este fluxo sequencial; não garante transações, atomicidade ou concorrência.

Justificativa das fronteiras

Justifique por que as regras do catálogo não dependem do terminal nem do JSON. Cite uma evidência nas importações, uma nos testes e um limite que essa separação não resolve.

Escreva pelo menos 160 caracteres (0/160).

Tutorial concluído

Parabéns! Você concluiu: Separar domínio, persistência e interface

Você concluiu a separação entre domínio, aplicação, adaptadores e composição. Agora o catálogo pode usar memória nos testes ou JSON localmente sem alterar suas regras. Nos próximos passos, a interface de linha de comando será estruturada com argumentos e opções.

Baixe o Aplicativo agora para ter acesso a + de 5000 cursos gratuitos, exercícios, certificado e muito conteúdo sem pagar nada!

  • Cursos online 100% gratuitos do início ao fim

    Milhares de cursos online em vídeo, ebooks e áudiobooks.

  • Mais de 60 mil exercícios gratuitos

    Para testar seus conhecimentos no decorrer dos cursos online

  • Certificado Digital gratuito válido em todo o Brasil

    Gerado diretamente na galeria de fotos do seu celular e enviado ao seu e-mail

Aplicativo Cursa na tela de ebook, na tela de curso em vídeo e na tela de exercícios do curso, mais o certificado de conclusão de curso