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

aciona o domínioacionado pelo domíniointernal/domainmodelo · casos de uso · portsdemand · deliveryexecution · resourceidentity · …dop-apiBFF Pythondop-cmdCLIbordagRPCO BFF e a CLI chegam aodomínio só por gRPC —o único adapter primário.infraestrutura · no boot, um ativoprovedores · por requisição, vários ativosSecretStorememoryk8sgcpEventBusmemorynatsIdentityProviderfirebaseoidcSandboxLauncherdockerk8sGitProvidergithubgitlabAgentProvideranthropicopenaiMailersmtpsendgridinternal/appa raiz de composição — conhece as duas pontas; a configuração escolhe cada adapter
As três regiões do dop-core. O domínio declara os ports (as tomadas nas suas bordas); cada pacote de adapter se liga a uma; a raiz de composição faz a ligação, por configuração. As duas famílias à direita têm ciclos de vida opostos.

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 infraestruturaPorts de provedor de domínio
ExemplosSecretStore, IdentityProvider, ObjectStore, EventBus, repositóriosGitProvider, AgentProvider, Mailer
Quem escolheO ambiente de implantaçãoA configuração da conta
QuandoUma vez, no bootA cada requisição
Quantos ativosUmVá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

ports_secretstore.go raw
// 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:

resource_set_credential.go raw
// 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.

secretstore_memory.go raw
// 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:

wire.go raw
// 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:

contract_secretstore.go raw
// 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:

contract_secretstore_test.go raw
// 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
	})
}
SecretStoreSuiteseis garantias numeradastest/contract/secretstore.gomemorysemprek8squando um cluster respondegcpatrás de uma build tag, quando há credencialuma função, três alvosa fronteira, guardada por dois testes em todo go test ./...internal/domaindeclara os portsinternal/adapteros implementaSDKspgx · nats · gcp · k8spermitidoimplementanuncaTestTheDomainDoesNotImportInfrastructure — nada sob internal/domain importa um adapter ou um SDK.TestOnlyAppKnowsTheAdapters — fora de internal/app, nada importa internal/adapter.
O que mantém o padrão verdadeiro ao longo do tempo. Em cima: uma suíte de contrato roda contra todo adapter. Embaixo: a fronteira de imports — adapters podem conhecer o domínio e os SDKs; o domínio não conhece nenhum dos dois — imposta por testes que quebram o build.

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.

SecretStore.Puta promessa: read-after-writeadapter em memóriamap[key] = copyreturn nilaqui a promessa não custa nadaadapter Secret ManagerAddSecretVersionversão n criadaconfirm v = npor número — forteawait latest ≥ nalias — eventualdestroyOldermaterial antigo destruídoretry · 25 ms → 1 s · ≤ 30 sKindUnavailablepassado o tetoescrita aceita, read-after-write não confirmado:um erro que alguém lê, não um Get dizendo "não existe".
O mesmo Put por dois adapters. Em memória a promessa é de graça. No Secret Manager o adapter confirma por número de versão, espera o alias alcançar — o passo destacado — e, passado o teto, recusa em vez de mentir.

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:

secretstore_gcp_await.go raw
// 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:

delivery_gitprovider.go raw
// 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:

app_gitproviders.go raw
// 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)
}
domain/deliveryabre o PRFor(account, repo)internal/app · gitProviders.Forrepositório→ integraçãointegração→ provedorcofre→ tokenresolvido a cada requisiçãoprovedor desconhecido: recusa,nunca um defaultGitHubGraphQL · merge queueGitLabREST · merge trainso token chega prontoGet(ref)ports.SecretStorelido aqui, no coreO domínio de entrega nunca fica sabendo que o GitHub existe.O adapter git nunca fica sabendo que existe um cofre.O domínio de recursos nunca fica sabendo que PRs existem.
Um port por requisição. O domínio de entrega pede um provedor por repositório; a raiz de composição resolve integração e credencial e devolve um adapter pronto — GitHub para um repositório, GitLab para o seguinte.

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:

architecture_test.go raw
// 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.