O núcleo do DOP precisa rodar em dois lugares que não concordam em nada. No Google Cloud Run, um segredo mora no Secret Manager e a identidade vem do Firebase. Num cluster k3s, um segredo é um Secret do Kubernetes e a identidade vem do provedor OIDC que o cluster tiver. Some a lista que cresce com o produto — armazenamento de objetos, um broker de mensagens, o executor que roda o sandbox de um agente, o host git que recebe o pull request — e acoplar o domínio a qualquer um deles significaria uma reescrita por ambiente.
Arquitetura hexagonal — ports e adapters — é a resposta de manual. É também o
padrão que mais frequentemente termina como o nome de uma pasta: um pacote
interfaces/, uma implementação por interface, e um domínio que continua
sabendo o que é um bucket. Este artigo é sobre a versão que o
DOP roda: onde a hexagonal valeu a pena, o que ela
custa, e as três disciplinas que a impedem de apodrecer. O código é real — os
trechos vêm do dop-core, o núcleo em Go da plataforma, aparados para
leitura.
A forma
Três regiões. internal/domain é dono do modelo e declara os ports — o
cofre, o provedor de identidade, o barramento de eventos, o executor, os
repositórios — no seu próprio vocabulário. internal/adapter tem um pacote
por tecnologia: Secret Manager e Secret do Kubernetes atrás da mesma
interface, NATS JetStream e um barramento em memória atrás de outra, GitHub e
GitLab atrás de uma terceira. internal/app é a raiz de composição: o único
lugar que conhece as duas pontas, onde um valor de configuração escolhe qual
adapter preenche qual port. Do outro lado, o servidor gRPC é o adapter
primário; o BFF em Python e a CLI chegam ao domínio por ele e por nada mais.
Nada disso é novo. O que importa é uma distinção que o diagrama faz e a maioria das bases de código hexagonais não faz.
Duas famílias de ports
| Ports de infraestrutura | Ports de provedor de domínio | |
|---|---|---|
| Exemplos | SecretStore, IdentityProvider, ObjectStore, EventBus, repositórios | GitProvider, AgentProvider, Mailer |
| Quem escolhe | O ambiente de implantação | A configuração da conta |
| Quando | Uma vez, no boot | A cada requisição |
| Quantos ativos | Um | Vários ao mesmo tempo |
Elas se parecem — uma interface Go, N implementações — e têm ciclos de vida opostos. Um port de infraestrutura é escolhido pela implantação: uma vez, no boot, um adapter ativo pela vida do processo. Um port de provedor de domínio é escolhido pelos dados da conta: a cada requisição, vários adapters ativos ao mesmo tempo, porque um workspace tem um repositório no GitHub e outro no GitLab e os dois têm que funcionar. Confundir as duas é o erro típico deste desenho — um provedor git escolhido por variável de ambiente funciona na demo e falha, em silêncio, no primeiro cliente com dois hosts. A seção sobre o provedor git, mais abaixo, mostra como a família por requisição fica em código.
Um port na linguagem do domínio
// dop-core/internal/domain/ports/ports.go (excerpt)
//
// Package ports declares the infrastructure PORTS, in the domain's language.
// The domain defines the port with the narrowest surface it needs; vendor
// adapters live in internal/adapter and are chosen by configuration at the
// composition root. No SDK crosses this boundary, and any capability that
// does not map across adapters stays OUT of the port.
package ports
// SecretRef is a LOGICAL, opaque reference: only the adapter knows how to
// resolve it (a path in Secret Manager, a Secret name in k8s). The domain never
// knows a path, a namespace or a secret name.
type SecretRef struct {
AccountID string
Kind string // integration_credential
OwnerID string
}
// SecretValue is opaque by construction: with no useful String(), it does not
// serialize into a log.
type SecretValue []byte
func (SecretValue) String() string { return "***" }
// SecretStore — four operations and nothing more.
//
// Guarantees verified by the contract suite, in EVERY adapter:
// 1. read-after-write: Put followed by Get returns the same value, immediately;
// 2. Get of a missing reference returns (nil, nil) — not an error;
// 3. Delete is idempotent;
// 4. Put over an existing reference replaces it;
// 5. isolation: a reference of account A never resolves a secret of account B;
// 6. the value never appears in a log, an error or a stack trace.
//
// Versioning stays OUT of the port: Secret Manager has versions, a k8s Secret
// is flat. A capability that does not map does not get in.
type SecretStore interface {
Put(ctx context.Context, ref SecretRef, v SecretValue) error
Get(ctx context.Context, ref SecretRef) (SecretValue, error)
Delete(ctx context.Context, ref SecretRef) error
Exists(ctx context.Context, ref SecretRef) (bool, error)
}Quatro verbos. SecretRef é opaco de propósito: o domínio nunca vê um
caminho no Secret Manager nem um namespace no Kubernetes. SecretValue não
consegue se imprimir, e é assim que a garantia 6 sobrevive a um %v
descuidado. E as garantias estão escritas no port, numeradas, porque são elas
que a suíte de contrato testa — a interface diz quais são os métodos; o
comentário diz o que eles prometem.
Repare no que não está lá: versões. O Secret Manager tem, um Secret do Kubernetes não tem, e uma capacidade que não mapeia entre adapters fica fora do port. Se um dia for necessária, entra como capacidade opcional que o domínio nunca presume.
O caso de uso do outro lado do port sabe exatamente isto:
// dop-core/internal/domain/resource/service.go (excerpt)
//
// The use case that puts a third party's key into the vault. Note what the
// domain knows: a SecretRef built from its own identifiers, and four verbs.
// It does not know whether the vault is Secret Manager, a k8s Secret or a map.
//
// The order is vault first, row second: if the database fails, an orphan
// secret with no pointer is left behind (inert, and overwritten on the next
// attempt); the reverse order would leave the row claiming a credential that
// does not exist, and execution would fail far from here, with no explanation.
func (s *Service) SetCredential(ctx context.Context, resourceID string, secret []byte) (string, error) {
a, err := s.who(ctx)
if err != nil {
return "", err
}
if len(secret) == 0 {
return "", errs.Invalid("empty credential").WithCode(KeyCredentialEmpty, nil)
}
// The second factor's gate: it comes AFTER the cheap validation and BEFORE
// the authorization, so that a caller with no session does not learn which
// resources exist.
if err := s.requireStepUp(ctx); err != nil {
return "", err
}
r, err := s.authorize(ctx, a, resourceID, LevelManage)
if err != nil {
return "", err
}
if !r.HasCredential() {
return "", errs.Precondition("a resource of kind %q has no credential", r.Kind).
WithCode(KeyKindHasNoCredential, map[string]any{"kind": string(r.Kind)})
}
if err := s.secrets.Put(ctx, SecretRefFor(a.accountID, r.ID), ports.SecretValue(secret)); err != nil {
return "", errs.Wrap(errs.KindUnavailable, err,
"failed to store the credential of resource %s", r.ID)
}
saved, err := s.repo.SetCredentialRef(ctx, a.accountID, r.ID, CredentialRef(a.accountID, r.ID))
if err != nil {
return "", err
}
return saved.CredentialRef, nil
}
// SecretRefFor is the domain's side of the reference: its own identifiers,
// nothing about where the secret physically lives.
func SecretRefFor(accountID, resourceID string) ports.SecretRef {
return ports.SecretRef{
AccountID: accountID,
Kind: CredentialKind,
OwnerID: resourceID,
}
}O domínio monta uma referência a partir dos próprios identificadores e chama
Put. Ordena o cofre antes da linha por uma razão que ele mesmo escreve, e
não sabe nada sobre para onde os bytes vão.
Dois adapters desde o primeiro dia
A primeira disciplina: um port com um único adapter é um chute, e sai com o formato do fornecedor que o inspirou. Por isso o adapter local não é “para depois” — é escrito junto com o port, e é a prova de que o port está certo.
// dop-core/internal/adapter/secretstore/memory.go
package secretstore
import (
"context"
"sync"
"github.com/barrosef/dop-core/internal/domain/ports"
)
// Memory is the in-memory adapter — used in the domain tests and as a living
// reference for the contract: it passes exactly the same set of tests as the k8s
// adapter and the GCP one.
type Memory struct {
mu sync.RWMutex
data map[string]ports.SecretValue
}
func NewMemory() *Memory { return &Memory{data: map[string]ports.SecretValue{}} }
func key(r ports.SecretRef) string { return r.AccountID + "/" + r.Kind + "/" + r.OwnerID }
func (m *Memory) Put(_ context.Context, ref ports.SecretRef, v ports.SecretValue) error {
m.mu.Lock()
defer m.mu.Unlock()
cp := make(ports.SecretValue, len(v))
copy(cp, v)
m.data[key(ref)] = cp
return nil
}
func (m *Memory) Get(_ context.Context, ref ports.SecretRef) (ports.SecretValue, error) {
m.mu.RLock()
defer m.mu.RUnlock()
v, ok := m.data[key(ref)]
if !ok {
return nil, nil
}
cp := make(ports.SecretValue, len(v))
copy(cp, v)
return cp, nil
}
func (m *Memory) Delete(_ context.Context, ref ports.SecretRef) error {
m.mu.Lock()
defer m.mu.Unlock()
delete(m.data, key(ref))
return nil
}
func (m *Memory) Exists(ctx context.Context, ref ports.SecretRef) (bool, error) {
v, err := m.Get(ctx, ref)
return v != nil, err
}
var _ ports.SecretStore = (*Memory)(nil)Cinquenta linhas, e não é um mock: copia os bytes na entrada e na saída para que quem chama não altere o cofre por acidente, e passa exatamente os mesmos testes que o adapter do GCP. O barramento de eventos em memória é a mesma história com mais em jogo — entrega numa goroutine, com backoff, teto de tentativas e a mesma política de mensagem envenenada do JetStream, porque um dublê que entregasse de forma síncrona e perfeita esconderia os bugs que só aparecem com entrega assíncrona.
A raiz de composição escolhe entre eles por configuração:
// dop-core/internal/app/wire.go (excerpt)
//
// Package app is the COMPOSITION ROOT: where the ports receive their adapters.
// It is the only place in the system that knows both ends. The domain knows only
// the ports; the adapters know only their technology. The choice happens here,
// by configuration — never through a conditional scattered across the code.
package app
// Deps gathers everything the use cases need — always as a PORT, never as an
// adapter's concrete type.
type Deps struct {
Pool *pgxpool.Pool
Bus ports.EventBus
Secrets ports.SecretStore
Objects ports.ObjectStore
Identity ports.IdentityProvider
Launcher ports.SandboxLauncher
Runner ports.VerificationRunner
Mailer ports.Mailer
SMS ports.SMSer
Repos ports.ProjectRepository
Cfg *config.Config
}
func Build(ctx context.Context, cfg *config.Config) (*Deps, func(), error) {
// ...pool and bus...
// ── choosing the adapters by configuration ──
var secrets ports.SecretStore
switch cfg.SecretBackend {
case "memory":
secrets = secretstore.NewMemory()
case "gcp":
// The port's guarantee 1 (read-after-write) is NOT deliverable on real
// GCP as-is. The adapter confirms by version number, which is strong,
// and then waits for the `latest` alias to catch up; if it does not
// converge, it refuses with KindUnavailable instead of returning "it
// does not exist" for a credential that was just written.
gcp, err := secretstore.NewGCP(ctx, secretstore.GCPConfig{
ProjectID: cfg.SecretProject,
Endpoint: cfg.SecretEndpoint,
Propagation: cfg.SecretPropagation,
})
if err != nil {
return nil, nil, err
}
closers = append(closers, gcp.Close)
secrets = gcp
default: // k8s — used locally and in self-hosted; there is no Secret Manager emulator
secrets = secretstore.NewK8s(secretstore.K8sConfig{
APIServer: cfg.K8sAPIServer,
Token: cfg.K8sToken,
Namespace: cfg.K8sNamespace,
})
}
// The executor is also a port with two REAL adapters: Docker for local
// development with no cluster, k8s for the execution cluster. Both pass
// the same contract suite.
var launcher ports.SandboxLauncher
switch cfg.SandboxBackend {
case "docker":
launcher = sandbox.NewDocker(sandbox.DockerConfig{Socket: cfg.DockerSocket})
default:
launcher = sandbox.NewK8s(/* ... */)
}
// ...identity (firebase | oidc), mailer (smtp | sendgrid), sms (twilio | zenvia)...
return &Deps{Secrets: secrets, Launcher: launcher /* ... */}, cleanup, nil
}Deps guarda ports, nunca tipos concretos, e esse switch é o único
condicional sobre backend em toda a base de código. É assim que “escolhido no
boot, um ativo” fica na prática.
A suíte de contrato é o que torna isso verdade
A segunda disciplina. Dois adapters que passam cada um nos próprios testes
são dois adapters; dois adapters que passam nos mesmos testes são
substituíveis. O DOP mantém uma suíte por port em test/contract, escrita
contra as garantias numeradas do port:
// dop-core/test/contract/secretstore.go (excerpt)
//
// Package contract carries the ports' CONTRACT tests. A port with a single
// adapter is guesswork. The same set runs against EVERY adapter — in-memory,
// k8s, GCP Secret Manager — and it is what guarantees substitutability in
// fact, not in intention.
package contract
// SecretStoreSuite verifies the six guarantees documented on the port.
func SecretStoreSuite(t *testing.T, name string, newStore func(t *testing.T) ports.SecretStore) {
t.Run(name, func(t *testing.T) {
// UNIQUE accounts per run, and not fixed literals.
//
// The first version used fixed "acct-a"/"acct-b", and the suite passed —
// against the in-memory double, where `newStore` returns a fresh vault
// on every subtest. Against a REAL backend, `newStore` returns a new
// client for the SAME vault, and the secret written in subtest 1 made
// the `Exists` subtest fail by finding what it had left behind itself.
//
// It was the suite written on top of the double: it verified the port,
// but carried along an assumption only the double satisfied.
id := fmt.Sprintf("%d-%d", time.Now().UnixNano(), refSeq.Add(1))
refA := ports.SecretRef{AccountID: "acct-a-" + id, Kind: "integration_credential", OwnerID: "res-1"}
refB := ports.SecretRef{AccountID: "acct-b-" + id, Kind: "integration_credential", OwnerID: "res-1"}
val := ports.SecretValue("super-secret-token")
cleanup := newStore(t)
t.Cleanup(func() {
_ = cleanup.Delete(context.Background(), refA)
_ = cleanup.Delete(context.Background(), refB)
})
t.Run("1_immediate_read_after_write", func(t *testing.T) {
s := newStore(t)
ctx := context.Background()
if err := s.Put(ctx, refA, val); err != nil {
t.Fatalf("Put: %v", err)
}
got, err := s.Get(ctx, refA)
if err != nil {
t.Fatalf("Get: %v", err)
}
if !bytes.Equal(got, val) {
t.Fatalf("value differs: %q != %q", got, val)
}
})
t.Run("2_absent_returns_nil_with_no_error", func(t *testing.T) {
s := newStore(t)
got, err := s.Get(context.Background(),
ports.SecretRef{AccountID: "acct-x", Kind: "integration_credential", OwnerID: "does-not-exist"})
if err != nil {
t.Fatalf("expected nil with no error, got error: %v", err)
}
if got != nil {
t.Fatalf("expected nil, got %q", got)
}
})
t.Run("3_idempotent_delete", func(t *testing.T) {
s := newStore(t)
ctx := context.Background()
_ = s.Put(ctx, refA, val)
if err := s.Delete(ctx, refA); err != nil {
t.Fatalf("1st Delete: %v", err)
}
if err := s.Delete(ctx, refA); err != nil {
t.Fatalf("the 2nd Delete should be harmless: %v", err)
}
})
// 4_put_replaces, 5_isolation_between_accounts, 6_value_never_logged ...
})
}O comentário do topo registra a armadilha. A primeira versão desta suíte usava nomes de conta fixos e passava — contra o dublê em memória, onde cada subteste ganha um cofre novo. Contra um backend real o mesmo cofre persiste entre subtestes, e o segredo deixado pelo subteste 1 quebrou o subteste 5. A suíte tinha sido escrita em cima do dublê e carregava uma premissa que só o dublê satisfazia; nenhum adapter real passaria, e nenhum estava sendo executado. Identificadores únicos por execução consertaram a suíte. Rodá-la contra a coisa real foi o que achou o bug — e é a razão do arquivo seguinte:
// dop-core/test/contract/secretstore_test.go — the suite against the in-memory
// adapter. It ALWAYS runs.
package contract_test
func TestSecretStoreContract(t *testing.T) {
contract.SecretStoreSuite(t, "memory", func(t *testing.T) ports.SecretStore {
return secretstore.NewMemory()
})
}
// dop-core/test/contract/secretstore_gcp_test.go — the SAME suite, now against
// Secret Manager. Locally the target is the community emulator; pointing
// SECRET_MANAGER_EMULATOR_HOST at nothing and supplying a credential, the SAME
// function runs against real GCP — which is the only way to discover the
// divergences listed in the adapter's header.
//
// go test ./test/contract/ -tags=integration -v -run SecretStore
//
// BEWARE when reading a PASS here: the emulator is more permissive than real
// GCP in nine documented points, and one of them is the port's guarantee 1 —
// read-after-write, which Google does NOT promise through the `latest` alias.
// Green here is no proof of green there.
//go:build integration
func TestSecretStoreContractGCP(t *testing.T) {
endpoint := os.Getenv("SECRET_MANAGER_EMULATOR_HOST")
if endpoint == "" {
endpoint = "127.0.0.1:8085"
}
contract.SecretStoreSuite(t, "gcp", func(t *testing.T) ports.SecretStore {
// One PROJECT per newStore call: the suite counts on clean state.
s, err := secretstore.NewGCP(context.Background(), secretstore.GCPConfig{
ProjectID: fmt.Sprintf("contract-%d", time.Now().UnixNano()),
Endpoint: endpoint,
})
if err != nil {
t.Skipf("Secret Manager unreachable at %s: %v", endpoint, err)
}
t.Cleanup(func() { _ = s.Close() })
return s
})
}A mesma função, três alvos: o adapter em memória sempre, o Kubernetes quando um cluster responde, o Secret Manager atrás de uma build tag quando existe credencial. O aviso acima do teste do GCP é merecido: o emulador é mais permissivo que o Google em nove pontos documentados, e um deles é a primeira garantia do port.
Quando o adapter não consegue cumprir a promessa, o adapter paga
O SecretStore promete read-after-write. A promessa nasceu do adapter
Kubernetes, onde ela vale — desde que o adapter leia pela API e não por um
volume montado, que o kubelet sincroniza mais ou menos uma vez por minuto. O
adapter do GCP não conseguiu cumpri-la como estava escrita. O Google é
explícito: só a leitura por número de versão é fortemente consistente,
enquanto o alias latest converge “tipicamente em minutos, mas pode levar
algumas horas”. E SecretRef é plano; não há onde guardar uma versão.
O modo de falha é o pior que um cofre pode ter. No GCP real, um Get logo
depois de um Put podia devolver (nil, nil) — que, pelo port, significa
“não existe”. Uma credencial recém-escrita pareceria ausente, em silêncio, e
quem chamou concluiria que a integração nunca foi configurada. No emulador o
mesmo caso passa em dez milissegundos.
Três saídas estavam na mesa. Afrouxar a garantia para “eventualmente
consistente” empurra um laço de releitura para cada chamador, e o chamador
não consegue distinguir “ainda não” de “nunca”. Guardar o valor em cache no
processo depois do Put cria um segundo lugar onde a credencial existe, com
a própria invalidação — troca um problema de consistência por um de
segurança. Ler por número de versão é o caminho fortemente consistente, mas
exige que o Put devolva um identificador que o chamador guarda; isso muda o
port, não um adapter, e ficou registrado como evolução possível em vez de
rejeitado.
A decisão foi que a garantia se mantém e o adapter paga:
// dop-core/internal/adapter/secretstore/gcp.go (excerpt)
//
// Put → AddSecretVersion, confirmed by VERSION NUMBER (strongly consistent),
// then waiting for the `latest` alias (eventual), then destroying the
// older versions' material.
// Get → AccessSecretVersion on ".../versions/latest".
//
// Why Get reads `latest` and not a named version: the port has nowhere to keep
// a version number — SecretRef is flat and the domain knows no version.
// awaitLatest waits for the `latest` alias to reach the written version.
//
// Without this, "write and return" would be read-after-write only on the
// emulator: on real GCP the alias is eventually consistent, and a Get right
// after the Put would return (nil, nil) — which through the port means "it does
// not exist". A just-written credential would show up as absent, in silence.
func (g *GCP) awaitLatest(ctx context.Context, id string, want int64) error {
deadline := time.Now().Add(g.propagation) // SECRET_PROPAGATION_SECONDS, 30 s by default
wait := 25 * time.Millisecond
for {
resp, err := g.client.AccessSecretVersion(ctx, &secretmanagerpb.AccessSecretVersionRequest{
Name: g.secretName(id) + "/versions/latest",
})
switch {
case err == nil:
got, verr := versionNumber(resp.GetName())
if verr != nil {
return verr
}
// >= and not ==: another concurrent Put may already have gone
// ahead, and in that case propagation has more than caught up.
if got >= want {
return nil
}
case status.Code(err) == codes.NotFound, status.Code(err) == codes.FailedPrecondition:
// not propagated yet — exactly the case this wait covers
default:
return wrapGCP(err, "failed to confirm the secret's visibility")
}
if !time.Now().Before(deadline) {
// Refusing is the part that matters: a Put that returns success
// while the following Get says "it does not exist" is worse than
// a Put that fails.
return errs.New(errs.KindUnavailable,
"Secret Manager did not make version %d visible through `latest` within %s: "+
"the write was accepted, but read-after-write was not confirmed",
want, g.propagation)
}
select {
case <-ctx.Done():
return errs.Wrap(errs.KindUnavailable, ctx.Err(),
"context ended before confirming the secret's visibility")
case <-time.After(wait):
}
if wait < time.Second {
wait *= 2
}
}
}Confirmar a escrita por número de versão, depois esperar o latest
alcançar, com um teto, e se não convergir, recusar com um erro explícito.
Recusar é a parte que importa: um Put que devolve sucesso enquanto o Get
seguinte diz “não encontrado” produz uma integração silenciosamente quebrada;
um Put que falha produz um erro que alguém lê. O resíduo também está
escrito — um Put no GCP é mais lento e pode falhar por não-convergência,
comportamento que o emulador nunca reproduz, então o teste local não cobre
esse caminho.
É aqui que a hexagonal paga o próprio aluguel. O domínio nunca soube que o Secret Manager tem um alias, o caso de uso não mudou uma linha, e o lugar onde a semântica do fornecedor foi absorvida é uma função num adapter.
A família por requisição: o provedor git
Um pull request vai para o host onde o repositório mora, e o token que o abre pertence à integração da conta. O domínio de entrega declara o que precisa e quem resolve:
// dop-core/internal/domain/delivery/repository.go (excerpt)
//
// A DOMAIN PROVIDER port: it lives in the delivery domain, not in the shared
// ports package, because it is the delivery domain's vocabulary — a pull
// request, a rebase, a merge — and nothing else needs it.
package delivery
type GitProvider interface {
OpenPullRequest(ctx context.Context, spec OpenPRSpec) (ProviderPR, error)
Rebase(ctx context.Context, spec RebaseSpec) (RebaseResult, error)
Merge(ctx context.Context, spec MergeSpec) (MergeResult, error)
// HasNativeQueue says whether the provider has a merge queue of its own
// (GitHub's, GitLab's merge trains). DOP's queue orchestrates on top and
// covers who does not.
HasNativeQueue(ctx context.Context, repoExternalID string) (bool, error)
}
// GitProviders resolves WHICH provider serves a repository.
//
// It is not a boot-time choice, like SecretStore or EventBus: the provider
// belongs to the REPOSITORY, and that is precisely why `ProjectRepo` carries an
// `IntegrationID` — a project with one repo on GitHub and another on GitLab has
// to be representable. A single provider chosen by configuration would make
// that impossible, silently.
//
// Whoever implements it also resolves the CREDENTIAL, in the vault — which is
// why the port returns a ready `GitProvider`, and no git adapter knows the vault.
type GitProviders interface {
For(ctx context.Context, accountID, repoID string) (GitProvider, error)
}Duas interfaces. GitProvider é o vocabulário do domínio de entrega — abrir,
rebase, merge, e se o host tem uma fila de merge própria em cima da qual a
fila do DOP pode orquestrar. GitProviders resolve qual delas serve um dado
repositório. O resolvedor é implementado na raiz de composição, porque é o
único lugar autorizado a conhecer as três pontas:
// dop-core/internal/app/gitproviders.go (excerpt)
//
// gitProviders resolves WHICH provider serves each repository, and with which
// credential. It is the only place in the system that knows all three ends —
// repository, integration and vault — and that is why it lives here, in the
// composition root. The git adapter does not know the vault; the delivery
// domain does not know GitHub or GitLab; the resource domain does not know
// PRs exist.
package app
type gitProviders struct {
pool *pgxpool.Pool
resources *resource.Service
secrets ports.SecretStore
cfg *config.Config
}
func (g gitProviders) For(ctx context.Context, accountID, repoID string) (delivery.GitProvider, error) {
// 1. The repository gives the integration.
var integrationID, externalID string
err := g.pool.QueryRow(ctx, `
SELECT r.integration_id::text, r.external_id
FROM project_repos r
JOIN projects p ON p.id = r.project_id
WHERE r.id = $1 AND p.account_id = $2`, repoID, accountID).
Scan(&integrationID, &externalID)
if err != nil {
return nil, errs.NotFound("repository %s not found in this account", repoID)
}
// 2. The integration gives the provider and the API's base.
res, err := g.resources.Get(ctx, integrationID)
if err != nil {
return nil, err
}
spec, err := resource.ParseIntegration(res.Config)
if err != nil {
return nil, err
}
if spec.Category != resource.CategoryGit {
return nil, errs.Precondition(
"the repository's integration is of category %q, not git", spec.Category)
}
// 3. The vault gives the credential — and it is HERE that it is read, in
// the core, which is the one that has the vault. The adapter receives the
// token ready-made and never knew a vault exists.
value, err := g.secrets.Get(ctx, resource.SecretRefFor(accountID, res.ID))
if err != nil {
return nil, err
}
if len(value) == 0 {
return nil, errs.Precondition(
"integration %q has no credential configured", res.Name)
}
switch spec.Provider {
case "github":
return gitprovider.NewGitHub(gitprovider.GitHubConfig{
APIBase: firstNonEmpty(spec.BaseURL, g.cfg.GitHubAPI),
GraphQLURL: g.cfg.GitHubGraphQL,
Token: string(value),
ActorID: externalID,
MergeMethod: g.cfg.GitMergeMethod,
}), nil
case "gitlab":
return gitprovider.NewGitLab(gitprovider.GitLabConfig{
APIBase: firstNonEmpty(spec.BaseURL, g.cfg.GitLabAPI),
Token: string(value),
ActorID: externalID,
MergeMethod: g.cfg.GitMergeMethod,
}), nil
}
// An unknown provider is an explicit refusal, never a default: opening a PR
// in the wrong place is worse than not opening one.
return nil, errs.Invalid("unsupported git provider: %q", spec.Provider)
}Repositório → integração → credencial → adapter, a cada requisição. O adapter git recebe um token pronto e nunca fica sabendo que existe um cofre; o domínio de entrega nunca fica sabendo que o GitHub existe; o domínio de recursos nunca fica sabendo que pull requests existem. Um provedor desconhecido é uma recusa, não um default, porque abrir um PR no lugar errado é pior do que não abrir. O port do provedor de agente — Anthropic ou OpenAI, escolhido por conta — tem exatamente a mesma forma.
A fronteira é um teste
A terceira disciplina é a mais barata e a mais pulada. Uma regra que mora no README dura até o primeiro prazo. A do DOP mora na suíte de testes:
// dop-core/test/contract/architecture_test.go (excerpt)
package contract_test
// The frontier that holds the architecture up: internal/domain must not
// import internal/adapter, nor any vendor SDK.
//
// This is a TEST, not a convention in the README — it is what makes the
// frontier survive time. Whoever tries to violate it breaks the build.
func TestTheDomainDoesNotImportInfrastructure(t *testing.T) {
root := repoRoot(t)
domainDir := filepath.Join(root, "internal", "domain")
forbidden := []string{
"/internal/adapter", // the central rule
"google.golang.org/grpc", // the protocol belongs to the edge
"github.com/jackc/pgx", // the database is an adapter
"github.com/nats-io", // the broker is an adapter
"cloud.google.com/go", // a vendor SDK
"k8s.io/client-go", // likewise
}
var violations []string
err := filepath.Walk(domainDir, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() || !strings.HasSuffix(path, ".go") {
return err
}
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, path, nil, parser.ImportsOnly)
if err != nil {
return err
}
rel, _ := filepath.Rel(root, path)
for _, imp := range f.Imports {
p := strings.Trim(imp.Path.Value, `"`)
for _, banned := range forbidden {
if strings.Contains(p, banned) {
violations = append(violations,
rel+" imports "+p+" (forbidden: "+banned+")")
}
}
}
return nil
})
if err != nil {
t.Fatalf("the sweep failed: %v", err)
}
if len(violations) > 0 {
t.Errorf("the domain↔infrastructure frontier was violated in %d place(s):", len(violations))
for _, v := range violations {
t.Errorf(" • %s", v)
}
t.Error("\ninternal/domain declares PORTS; adapters live in internal/adapter " +
"and are chosen in the composition root (internal/app).")
}
}
// The composition root is the ONLY place allowed to know both ends: anything
// under internal/ that is not internal/app or internal/adapter must not
// import an adapter package.
func TestOnlyAppKnowsTheAdapters(t *testing.T) { /* same sweep, other rule */ }Faça o parse dos imports de todo arquivo sob internal/domain; falhe em
qualquer pacote de adapter ou SDK de fornecedor. Um segundo teste percorre
todo o resto de internal/ e falha se qualquer coisa além da raiz de
composição importar um adapter. Os dois rodam em todo go test ./..., então
a fronteira quebra o build antes de quebrar o desenho.
O que custa, e onde não vale o esforço
Dois adapters por port, escritos e mantidos, desde o primeiro commit. Uma suíte de contrato por port, e a infraestrutura para rodá-la contra os backends reais, não só contra os emuladores. Uma indireção a mais em toda chamada de infraestrutura. E o recurso mais forte de um fornecedor — versões de segredo, IAM por segredo, custom claims do Firebase — inacessível ao domínio por construção. Esse último é o preço, e é deliberado.
As mesmas regras dizem onde o padrão não compensa. Duas alternativas foram consideradas para a plataforma como um todo e rejeitadas: acoplar ao GCP agora e portar depois — “depois” é quando o acoplamento já se espalhou, e rodar num cluster local era requisito de desenvolvimento, não ambição — e uma biblioteca multi-cloud genérica, que entrega o denominador comum dos fornecedores da biblioteca, não do domínio, e troca um acoplamento por outro. Mas dentro da fronteira, o DOP não põe um port na frente de consultas Postgres que só vão rodar no Postgres, não abstrai a borda gRPC e não embrulha o logger. Um port vale a pena onde o segundo adapter é real: um segundo ambiente, um segundo host que o cliente pode escolher, um teste que precisa rodar sem infraestrutura. Onde o segundo adapter é imaginário, a interface é o nome de uma pasta.
A hexagonal, no fim, é uma regra contábil: o domínio não paga nada ao fornecedor, e o adapter paga o que o fornecedor cobrar. As três disciplinas são o que mantém os livros honestos.