Arquitetura hexagonal e Ports and Adapters em Python

TL;DR

“Além de e-mail, precisamos notificar por Slack também” chega como um requisito de meia linha num board de produto — e o quanto ele dói de implementar é o teste mais honesto de quanto uma arquitetura está isolada de verdade. Numa aplicação sem fronteiras explícitas, “notificar” está espalhado: uma chamada SMTP dentro do handler que cria a tarefa, outra dentro do job que fecha tarefas vencidas, talvez uma terceira copiada num worker. Adicionar Slack significa caçar cada um desses lugares e duplicar a lógica de novo. Nesta nota, a API de Tarefas construída ao longo do Galho 13 já não tem esse problema: um AbstractNotificador — o Port de saída que esta nota introduz — é a única coisa que a Service Layer conhece sobre “avisar alguém”; um SlackAdapter novo, satisfazendo esse contrato, é a única linha de código que muda. O resto desta nota nomeia formalmente o vocabulário que as notas 02-06 deste galho já construíram informalmente — domínio no centro do hexágono, AbstractRepository/AbstractUnitOfWork como Ports de saída, SqlAlchemyTarefaRepository/SqlAlchemyUnitOfWork como Adapters de saída, FastAPI como Adapter de entrada — e fecha com o diagrama que reorganiza a API de Tarefas inteira (a mesma da capstone do Galho 10) em camadas hexagonais. Teoria do estilo, sem repetir: Arquitetura. Fonte primária do estilo: Cockburn (2005); fonte primária da aplicação Python: Percival & Gregory.

O requisito que chega como uma frase e cobra o preço da arquitetura

A API de Tarefas construída pelas notas 02-06 deste galho tem, hoje, um jeito de avisar um usuário quando algo acontece com uma tarefa dele — a nota 04 introduziu isso ao lado da Unit of Work, no cenário de mover uma tarefa para outro dono: uma Notificacao de domínio, gravada na mesma transação da tarefa, através de um AbstractNotificacaoRepository. É persistência — a notificação vira uma linha na tabela notificacoes, e o usuário a vê ao abrir a lista de notificações não lidas. Funciona bem para esse caso de uso.

Só que o produto pede algo diferente agora: “quando uma tarefa é movida para outro usuário, além de registrar a notificação no banco, mande um e-mail avisando — e, se possível, mande também no Slack do time, porque metade do pessoal não abre a lista de notificações da aplicação.” Isso não é mais “gravar uma linha” — é disparar uma ação externa, uma chamada de rede para um provedor de e-mail (SMTP, SES, SendGrid) e, em seguida, potencialmente outra para um provedor diferente (a API de webhooks do Slack). É um tipo de dependência de saída que este galho ainda não formalizou: não é o banco, é um serviço de terceiros que a aplicação aciona, não consulta.

Um desenvolvedor sob pressão de prazo, sem pensar duas vezes, adiciona a chamada de e-mail direto onde a decisão de negócio acontece:

"""services/mover_tarefa.py — a versão que parece razoável, e não é."""
 
import smtplib
from email.message import EmailMessage
 
from domain.notificacao import Notificacao
from domain.unit_of_work import AbstractUnitOfWork
 
 
def mover_tarefa_para_outro_usuario(
    uow: AbstractUnitOfWork, tarefa_id: int, novo_usuario_id: int, email_novo_dono: str,
) -> None:
    with uow:
        tarefa = uow.tarefas.get(tarefa_id)
        if tarefa is None:
            raise TarefaNaoEncontrada(tarefa_id)
 
        tarefa.usuario_id = novo_usuario_id
        uow.tarefas.add(tarefa)
 
        notificacao = Notificacao(
            id=None, usuario_id=novo_usuario_id,
            mensagem=f"Você recebeu a tarefa '{tarefa.titulo}'",
        )
        uow.notificacoes.add(notificacao)
        uow.commit()
 
    # 💥 fora da Unit of Work, porque enviar e-mail não é uma escrita no banco —
    # mas agora smtplib mora dentro da Service Layer, que a nota 06 deste galho
    # já garantiu ser Python puro, sem import de framework nenhum
    msg = EmailMessage()
    msg["Subject"] = "Você recebeu uma nova tarefa"
    msg["From"] = "no-reply@empresa.com"
    msg["To"] = email_novo_dono
    msg.set_content(f"Você recebeu a tarefa '{tarefa.titulo}'")
    with smtplib.SMTP("smtp.empresa.com", 587) as smtp:
        smtp.starttls()
        smtp.login("no-reply", "senha-no-codigo")
        smtp.send_message(msg)

O código funciona — o e-mail sai, o time comemora, o ticket fecha. Duas semanas depois, o produto pede o Slack. O mesmo desenvolvedor abre o mesmo arquivo e adiciona um segundo bloco, quase idêntico ao primeiro, chamando requests.post() contra o webhook do Slack. Três meses depois, um segundo caso de uso — “avisar quando uma tarefa vence sem ser concluída” — precisa do mesmo comportamento (e-mail + Slack), e como não existe nenhum lugar único que já sabe “como notificar”, alguém copia os dois blocos de smtplib/requests de novo, para dentro de outra função de Service Layer. É o mesmo bug estrutural que abriu a nota 02 deste galho — uma capacidade que deveria existir em um lugar só passa a existir em N lugares, cada um podendo divergir silenciosamente do resto.

O que está quebrado, em uma frase

“Como notificar alguém” é uma decisão de infraestrutura (qual provedor, qual protocolo, qual credencial) que se infiltrou dentro da Service Layer — a mesma camada que a nota 06 deste galho já blindou contra import fastapi/import sqlalchemy continua livre para importar smtplib e requests, porque nada, na estrutura do código, nomeou “enviar uma notificação” como algo que merece o mesmo tratamento que “persistir uma tarefa” já recebe.

O Port que faltava: AbstractNotificador

A correção segue exatamente o molde que as notas 03 e 04 já cravaram duas vezes neste galho: uma interface abstrata, livre de qualquer import de infraestrutura, que declara o que a Service Layer precisa poder fazer — não como isso acontece por baixo.

"""domain/notificador.py — o Port de saída, sem NENHUM import de infraestrutura."""
 
from abc import ABC, abstractmethod
 
 
class AbstractNotificador(ABC):
    """Contrato: qualquer forma de avisar um destinatário sobre algo."""
 
    @abstractmethod
    def enviar(self, destinatario: str, mensagem: str) -> None:
        """Envia `mensagem` para `destinatario`. Não garante entrega —
        só que a tentativa de envio foi disparada."""
        raise NotImplementedError

Repare que domain/notificador.py não sabe se destinatario é um e-mail, um @usuario do Slack, ou um número de telefone — essa decisão pertence a cada Adapter concreto, não ao Port. A Service Layer, por sua vez, só conhece essa interface — nunca smtplib, nunca requests, nunca o SDK do Slack:

"""services/mover_tarefa.py — a versão corrigida, com o Port injetado."""
 
from domain.notificacao import Notificacao
from domain.notificador import AbstractNotificador
from domain.unit_of_work import AbstractUnitOfWork
 
 
def mover_tarefa_para_outro_usuario(
    uow: AbstractUnitOfWork,
    notificador: AbstractNotificador,
    tarefa_id: int,
    novo_usuario_id: int,
    email_novo_dono: str,
) -> None:
    with uow:
        tarefa = uow.tarefas.get(tarefa_id)
        if tarefa is None:
            raise TarefaNaoEncontrada(tarefa_id)
 
        tarefa.usuario_id = novo_usuario_id
        uow.tarefas.add(tarefa)
 
        notificacao = Notificacao(
            id=None, usuario_id=novo_usuario_id,
            mensagem=f"Você recebeu a tarefa '{tarefa.titulo}'",
        )
        uow.notificacoes.add(notificacao)
        uow.commit()
 
    # fora do `with uow:` de propósito — a nota 04 já estabeleceu que a UoW
    # cobre só a transação de banco; o envio em si não participa dela
    notificador.enviar(
        destinatario=email_novo_dono,
        mensagem=f"Você recebeu a tarefa '{tarefa.titulo}'",
    )

mover_tarefa_para_outro_usuario agora recebe notificador: AbstractNotificador do mesmo jeito que já recebia uow: AbstractUnitOfWork — como parâmetro, decidido por fora. Quem decide qual implementação concreta chega até aqui não é a Service Layer; é o composition root, exatamente o papel que a nota 05 deste galho já formalizou.

AbstractNotificador não substitui AbstractNotificacaoRepository — os dois convivem

AbstractNotificacaoRepository (nota 04) continua existindo, com o mesmo papel: persistir a Notificacao de domínio na mesma transação da tarefa, para que o usuário a veja dentro da própria aplicação. AbstractNotificador (esta nota) é uma peça nova, ao lado da primeira — dispara o envio de fato, por um canal externo (e-mail, Slack), depois que a transação de banco já fechou com sucesso. Um caso de uso pode usar as duas: grava a notificação (via uow.notificacoes) e dispara o aviso externo (via notificador.enviar) — cada Port cobrindo a metade do problema que lhe cabe.

Os Adapters: EmailAdapter, ConsoleAdapter, e o SlackAdapter que o requisito pediu

Com o Port definido, cada forma concreta de notificar vira uma classe pequena que implementa enviar() — e nenhuma delas precisa saber que as outras existem.

"""infra/notificador_email.py — Adapter de saída: e-mail via SMTP."""
 
import smtplib
from email.message import EmailMessage
 
from domain.notificador import AbstractNotificador
 
 
class EmailAdapter(AbstractNotificador):
    def __init__(self, host: str, porta: int, usuario: str, senha: str) -> None:
        self._host = host
        self._porta = porta
        self._usuario = usuario
        self._senha = senha
 
    def enviar(self, destinatario: str, mensagem: str) -> None:
        msg = EmailMessage()
        msg["Subject"] = "Notificação da API de Tarefas"
        msg["From"] = self._usuario
        msg["To"] = destinatario
        msg.set_content(mensagem)
 
        with smtplib.SMTP(self._host, self._porta) as smtp:
            smtp.starttls()
            smtp.login(self._usuario, self._senha)
            smtp.send_message(msg)
"""infra/notificador_console.py — Adapter de saída: pra testes/dev, sem rede nenhuma."""
 
from domain.notificador import AbstractNotificador
 
 
class ConsoleAdapter(AbstractNotificador):
    """Não envia nada de verdade — imprime, útil em desenvolvimento local
    e como base para um Fake mais estrito em teste automatizado."""
 
    def enviar(self, destinatario: str, mensagem: str) -> None:
        print(f"[NOTIFICAÇÃO] para {destinatario}: {mensagem}")

E o Adapter que o requisito de abertura desta nota pediu — Slack, via webhook HTTP — entra sem tocar em uma linha sequer da Service Layer, do domínio, ou de qualquer outro Adapter já existente:

"""infra/notificador_slack.py — Adapter de saída NOVO: Slack, via webhook HTTP."""
 
import requests
 
from domain.notificador import AbstractNotificador
 
 
class SlackAdapter(AbstractNotificador):
    def __init__(self, webhook_url: str) -> None:
        self._webhook_url = webhook_url
 
    def enviar(self, destinatario: str, mensagem: str) -> None:
        # `destinatario` aqui é o nome do canal ou @usuário Slack —
        # o Adapter decide o que esse campo significa para O SEU protocolo
        resposta = requests.post(
            self._webhook_url,
            json={"channel": destinatario, "text": mensagem},
            timeout=5,
        )
        resposta.raise_for_status()

SlackAdapter.enviar() tem a mesma assinatura de EmailAdapter.enviar() e ConsoleAdapter.enviar()destinatario: str, mensagem: str -> None — porque é exatamente essa forma, e só essa forma, que AbstractNotificador exige. O composition root troca uma implementação pela outra (ou usa as duas, se o requisito pedir “e-mail e Slack”) sem que mover_tarefa_para_outro_usuario precise de uma linha nova:

"""main.py — composition root decidindo QUAIS notificadores existem."""
 
from infra.notificador_email import EmailAdapter
from infra.notificador_slack import SlackAdapter
 
 
def criar_notificador_email() -> EmailAdapter:
    return EmailAdapter(host="smtp.empresa.com", porta=587, usuario="no-reply", senha=SENHA_SMTP)
 
 
def criar_notificador_slack() -> SlackAdapter:
    return SlackAdapter(webhook_url=SLACK_WEBHOOK_URL)

Se “e-mail e Slack juntos” for o comportamento desejado, um terceiro Adapter — um NotificadorComposto que recebe uma lista de AbstractNotificador e chama enviar() em cada um — resolve isso sem que AbstractNotificador precise crescer nenhum método novo:

"""infra/notificador_composto.py — compõe vários Adapters atrás do MESMO Port."""
 
from domain.notificador import AbstractNotificador
 
 
class NotificadorComposto(AbstractNotificador):
    def __init__(self, notificadores: list[AbstractNotificador]) -> None:
        self._notificadores = notificadores
 
    def enviar(self, destinatario: str, mensagem: str) -> None:
        for notificador in self._notificadores:
            notificador.enviar(destinatario, mensagem)

Nomeando o que já existia: o vocabulário Ports and Adapters

O que as seções anteriores fizeram — separar “o que a Service Layer precisa” de “como isso é feito de verdade” — é o mesmo movimento que as notas 03 e 04 deste galho já fizeram para persistência, sem usar esse nome. Esta seção nomeia formalmente o vocabulário, seguindo Cockburn (2005) e a aplicação que Percival & Gregory fazem dele em Python, sem reexplicar o estilo arquitetural em si — isso já está em Arquitetura.

Vocabulário hexagonalO que é na API de TarefasOnde este galho já construiu
Core / domínioTarefa, Notificacao, regras de negócionota 02
Driven Port (saída)AbstractRepository, AbstractUnitOfWork, AbstractNotificadornota 03, nota 04, esta nota
Driven Adapter (saída)SqlAlchemyTarefaRepository, SqlAlchemyUnitOfWork, EmailAdapter/SlackAdapter/ConsoleAdapternotas 03, 04, esta nota
Driving Port (entrada)A própria função de caso de uso — criar_tarefa(comando, uow) — é o “ponto de entrada” que qualquer chamador acionanota 06
Driving Adapter (entrada)O handler FastAPI, que traduz POST /tarefas num CriarTarefaComando e chama a função de caso de usonota 06, capstone Galho 10

O ponto que vale destacar, porque é o que mais confunde quem chega de fora: FastAPI é um Adapter, não o centro da aplicação. A capstone do Galho 10 construiu a API “de dentro para fora” — começou pelo handler, foi acrescentando camadas (roteamento, validação, injeção, erro, middleware) até chegar num sistema funcional. Isso é uma sequência pedagógica legítima e comum — mas o resultado, visto de fora, tem o handler HTTP como se fosse o ponto de partida conceitual da aplicação. A arquitetura hexagonal inverte essa leitura: o domínio (Tarefa, Notificacao, suas regras) é o centro; FastAPI é só uma das formas possíveis de acionar esse centro — trocável, em princípio, por uma CLI, um worker de fila, ou outro framework web inteiro, sem que uma linha do domínio ou da Service Layer precise mudar.

"Hexagonal" não significa seis lados, nem um diagrama geométrico obrigatório

Cockburn escolheu o hexágono só para ter espaço visual suficiente para desenhar vários Ports de entrada e saída ao redor de um núcleo — não existe significado no número seis, e a forma exata do diagrama (hexágono, círculo, retângulo) não importa para a arquitetura em si. O que importa, e o que o próprio Cockburn enfatiza no artigo original (“Hexagonal Architecture”, 2005), é a simetria entre entrada e saída: tanto o lado que aciona a aplicação (HTTP, CLI, fila) quanto o lado que a aplicação aciona (banco, e-mail, outro serviço) atravessam Ports — nenhum dos dois lados tem acesso direto ao núcleo. É comum ver diagramas que só desenham Adapters de saída (banco, e-mail) esquecendo que a entrada (FastAPI, neste galho) também é, estruturalmente, um Adapter — a mesma simetria que faz o nome “Ports and Adapters” ser, para Cockburn, um nome melhor que “Hexagonal” para descrever o padrão.

A API de Tarefas inteira, em camadas hexagonais

Juntando tudo que este galho construiu — domínio (nota 02), Repository/UoW (notas 03-04), DI (nota 05), Service Layer (nota 06), e o AbstractNotificador desta nota — a API de Tarefas da capstone do Galho 10 se reorganiza assim:


flowchart TB
    subgraph DrivingAdapters["Driving Adapters — entrada (acionam a aplicação)"]
        HTTP["FastAPI\nrouters/tarefas.py\nCapstone Galho 10"]
        WORKER["Worker de fila\nimportar_tarefas_csv()"]
        CLI["CLI administrativo"]
    end

    subgraph DrivingPorts["Driving Ports — o que pode ser acionado"]
        UC["criar_tarefa(comando, uow)\nconcluir_tarefa(comando, uow)\nmover_tarefa(uow, notificador, ...)"]
    end

    subgraph Core["CORE — domínio, não sabe que nada fora existe"]
        ENT["Tarefa.concluir()\nNotificacao\nregras de negócio\n(nota 02)"]
    end

    subgraph DrivenPorts["Driven Ports — o que o core pode acionar"]
        REPO_PORT["AbstractRepository\n(nota 03)"]
        UOW_PORT["AbstractUnitOfWork\n(nota 04)"]
        NOTIF_PORT["AbstractNotificador\n(esta nota)"]
    end

    subgraph DrivenAdapters["Driven Adapters — saída (a aplicação aciona)"]
        SQL["SqlAlchemyTarefaRepository\nSqlAlchemyUnitOfWork\n(notas 03-04)"]
        EMAIL["EmailAdapter"]
        SLACK["SlackAdapter (NOVO)"]
        CONSOLE["ConsoleAdapter (testes)"]
    end

    subgraph Storage["Sistemas externos"]
        DB[("Banco relacional\nGalho 9")]
        SMTP[("Servidor SMTP")]
        SLACKAPI[("Slack Webhook API")]
    end

    HTTP --> UC
    WORKER --> UC
    CLI --> UC
    UC --> ENT
    UC --> REPO_PORT
    UC --> UOW_PORT
    UC --> NOTIF_PORT

    REPO_PORT -.->|implementado por| SQL
    UOW_PORT -.->|implementado por| SQL
    NOTIF_PORT -.->|implementado por| EMAIL
    NOTIF_PORT -.->|implementado por| SLACK
    NOTIF_PORT -.->|implementado por| CONSOLE

    SQL --> DB
    EMAIL --> SMTP
    SLACK --> SLACKAPI

    style Core fill:#2d7a4a,color:#fff
    style ENT fill:#2d7a4a,color:#fff
    style DrivingPorts fill:#4A90D9,color:#fff
    style DrivenPorts fill:#4A90D9,color:#fff
    style DrivingAdapters fill:#8b6914,color:#fff
    style DrivenAdapters fill:#8b6914,color:#fff

O núcleo verde (Tarefa, Notificacao, suas regras) não tem nenhuma seta saindo dele em direção às camadas externas — só recebe chamadas de dentro da Service Layer, e é isso que a nota 02 já garantiu ao proibir qualquer import fastapi/import sqlalchemy do domínio. As duas faixas azuis (Ports) são só interfaces — abc.ABC com @abstractmethod, sem lógica nenhuma por trás — e é o fato de a Service Layer só conhecer essas faixas azuis, nunca as faixas marrons (Adapters), que torna o diagrama verdadeiro: trocar SlackAdapter por outro provedor de mensageria, ou trocar FastAPI por outro framework web inteiro, é uma mudança confinada a uma faixa marrom — nenhuma seta cruza de uma faixa marrom para outra sem passar pela faixa azul do meio.

Vale nomear a assimetria de nomenclatura entre “Driving” e “Driven” — comum causar confusão: Driving (“quem dirige/aciona”) são os Ports/Adapters de entrada — eles “dirigem” a aplicação, provocam algo a acontecer. Driven (“o que é dirigido/acionado”) são os de saída — a aplicação os “dirige”, os aciona para cumprir o que a entrada pediu. AbstractRepository/AbstractUnitOfWork/AbstractNotificador são todos Driven Ports porque, em todos os três casos, é o core que decide chamar .add(), .commit() ou .enviar() — nunca o inverso.

Uma requisição atravessando o hexágono

O diagrama anterior é estático — mostra a estrutura, não o fluxo. Uma única requisição HTTP torna as camadas concretas: POST /tarefas/{id}/mover chega pelo Adapter de entrada (FastAPI), atravessa o Driving Port (a função de caso de uso), toca o núcleo (a entidade Tarefa), e sai por dois Driven Ports diferentes — persistência e notificação — cada um resolvido por um Adapter de saída distinto.

sequenceDiagram
    participant Cliente
    participant FastAPI as FastAPI (Driving Adapter)
    participant UC as mover_tarefa_para_outro_usuario<br/>(Driving Port / caso de uso)
    participant ENT as Tarefa (Core)
    participant UOW as AbstractUnitOfWork<br/>(Driven Port)
    participant REPO as AbstractRepository<br/>(Driven Port)
    participant NOTIF as AbstractNotificador<br/>(Driven Port)
    participant SQL as SqlAlchemyUnitOfWork<br/>(Driven Adapter)
    participant SLACK as SlackAdapter<br/>(Driven Adapter)
    participant DB as Banco
    participant API as Slack Webhook API

    Cliente->>FastAPI: PATCH /tarefas/1/mover {novo_usuario_id: 99}
    FastAPI->>FastAPI: monta MoverTarefaComando (nota 06)
    FastAPI->>UC: mover_tarefa_para_outro_usuario(uow, notificador, comando)

    UC->>UOW: with uow:
    UOW->>SQL: __enter__() cria Session + Repositories
    UC->>REPO: uow.tarefas.get(tarefa_id)
    REPO-->>UC: Tarefa (domínio)

    UC->>ENT: tarefa.usuario_id = novo_usuario_id
    UC->>REPO: uow.tarefas.add(tarefa)
    UC->>UOW: uow.commit()
    UOW->>SQL: session.commit()
    SQL->>DB: UPDATE tarefas SET usuario_id = 99

    UC->>NOTIF: notificador.enviar(destinatario, mensagem)
    NOTIF->>SLACK: (resolvido em runtime pelo composition root)
    SLACK->>API: POST webhook {"channel": ..., "text": ...}
    API-->>SLACK: 200 OK

    UC-->>FastAPI: retorna Tarefa atualizada
    FastAPI-->>Cliente: 200 {"id": 1, "usuario_id": 99, ...}

Repare no que mover_tarefa_para_outro_usuario (o Driving Port, no meio do diagrama) nunca vê: nunca vê Session, Engine, smtplib ou o SDK do Slack — só vê AbstractUnitOfWork, AbstractRepository, AbstractNotificador. Os três Adapters concretos (SqlAlchemyUnitOfWork, e por trás dele SqlAlchemyTarefaRepository, e SlackAdapter) só aparecem no diagrama como quem de fato conversa com o mundo externo (DB, API) — e nenhum deles é mencionado por nome dentro do código do caso de uso, só resolvido em runtime pelo composition root, exatamente como a nota 05 deste galho já formalizou.

Por que isso importa na prática: o custo de trocar um Adapter

Voltando ao requisito de abertura desta nota — Slack além de e-mail — a resposta completa, com a arquitetura hexagonal em vigor, é: escrever infra/notificador_slack.py (uma classe, um método), e mudar uma linha no composition root. Nada em domain/, nada em services/, nada no handler FastAPI muda. Compare com o cenário sem Ports/Adapters, do início da nota — smtplib e requests espalhados dentro de funções de Service Layer, cada caso de uso que precisa notificar reimplementando a decisão de “qual provedor” à sua maneira: adicionar Slack ali significa caçar cada lugar que faz isso, não escrever um arquivo novo.

O mesmo raciocínio vale, com o mesmo peso, para o outro lado do hexágono — trocar FastAPI por outro framework web (ou por um Adapter de linha de comando inteiro, ou por um novo Adapter que consome de uma fila de eventos) é, em teoria, uma mudança confinada à faixa marrom de entrada: um Adapter novo que traduz sua forma específica de chegada (HTTP, linha de comando, mensagem de fila) num Comando e chama a mesma função de caso de uso que já existe. A nota 06 deste galho já demonstrou essa simetria concretamente com o worker de importação em lote — ele nunca precisou saber que FastAPI existe, porque ele já chama o mesmo Driving Port que o handler chama.

"Trocável em teoria" não é "trocável de graça"

A promessa central da arquitetura hexagonal — trocar um Adapter sem tocar no core — é real, mas vale nomear o que ela não cobre. Trocar SlackAdapter por outro provedor de mensageria não exige tocar no domínio nem na Service Layer, desde que o novo provedor caiba na mesma assinatura (enviar(destinatario, mensagem) -> None). Se o Slack precisar de algo que o Port não previu — anexar um botão interativo, por exemplo — o Port precisa crescer (um método novo, ou um parâmetro novo), e essa mudança sim se propaga para toda implementação existente de AbstractNotificador, inclusive EmailAdapter e ConsoleAdapter, que agora precisam decidir o que fazer com uma capacidade que não faz sentido para elas. A arquitetura hexagonal isola a implementação de um Port; não torna o desenho do Port em si imune a mudança quando o requisito de negócio evolui de um jeito que a abstração original não previu.

A ressalva honesta: hexágono é estrutura, não seguro contra over-engineering

Cada Port novo — AbstractNotificador, como cada AbstractRepository/AbstractUnitOfWork antes dele — é mais uma interface, mais um vocabulário, mais uma decisão de composição no main.py. A régua que as notas 03 e 05 deste galho já cravaram continua valendo aqui, sem exceção: se a aplicação só vai enviar e-mail, sempre, por um único provedor, para sempre, um EmailAdapter chamado direto (sem AbstractNotificador no meio) é simples, funciona, e não é “arquitetura errada” — é arquitetura calibrada para um problema que não tem, hoje, mais de uma forma de ser resolvido. O Port compensa quando existe (ou é razoavelmente previsível que vá existir) mais de uma implementação real — o cenário de abertura desta nota, “e-mail e depois Slack”, é exatamente esse sinal. Extrair o Port antes desse sinal aparecer é especular sobre um futuro que pode nunca chegar; a arquitetura hexagonal não isenta ninguém de fazer essa avaliação de custo-benefício caso a caso — ela só barateia o custo de estar errado, quando a especulação acerta.

Em resumo

O hexágono desta nota não introduziu nenhum mecanismo novo — nomeou, formalmente, o que as notas 02 a 06 deste galho já tinham construído: um núcleo de domínio Python puro, cercado por Ports (interfaces abc.ABC, sem lógica) e Adapters (implementações concretas, uma por tecnologia). O único artefato genuinamente novo foi AbstractNotificador — o Port de saída que faltava para “avisar alguém por um canal externo” ter o mesmo tratamento estrutural que “persistir uma entidade” já tinha. Com ele no lugar, o requisito que abriu a nota — Slack, além de e-mail — vira a prova concreta da promessa do estilo: um SlackAdapter novo, uma linha alterada no composition root, e nenhuma mudança no domínio, na Service Layer, ou no Adapter de entrada FastAPI. É esse o teste que separa arquitetura hexagonal genuína de um diagrama bonito — não “o desenho tem um hexágono”, é “o próximo requisito de troca de fornecedor custa um arquivo novo, não uma caçada pelo código inteiro”.

Em entrevista

Arquitetura hexagonal é um dos assuntos mais citados em entrevistas de arquitetura backend sênior — e a pergunta que separa quem entende de quem decorou o diagrama é sempre sobre o custo de troca:

“Hexagonal architecture — Ports and Adapters, from Cockburn — puts the domain at the center, with zero knowledge of any technology outside it. Everything the domain needs from the outside world, and everything the outside world uses to trigger the domain, goes through an interface — a Port — defined by the domain itself, not by whatever’s on the other side. Concrete implementations, Adapters, live in the infrastructure layer and satisfy those interfaces: a SQLAlchemy repository for persistence, a FastAPI handler for HTTP, an SMTP or Slack client for notifications. The concrete test of whether this is actually in place, not just a diagram, is what it costs to add a new adapter — say, a new notification channel. If that’s a new class implementing an existing interface, plus one line in the composition root, the architecture is real. If it means hunting through service functions for scattered smtplib calls, it’s not hexagonal, no matter what the box-and-arrow diagram in the design doc says.”

Como explicar em inglês

PTEN
arquitetura hexagonalhexagonal architecture
Ports and AdaptersPorts and Adapters
porta de entrada / saídadriving port / driven port
adaptador de entrada / saídadriving adapter / driven adapter
núcleo / core do domíniodomain core
isolamento do domíniodomain isolation
composition rootcomposition root
trocar de provedorswap providers
custo de trocacost of swapping

Fontes

Veja também

Consultado em 2026-07-12.