DOP’s core has to run in two places that agree on nothing. On Google Cloud Run, a secret lives in Secret Manager and identity comes from Firebase. On a k3s cluster, a secret is a Kubernetes Secret and identity comes from whatever OIDC provider the cluster has. Add the list that grows with the product — object storage, a message broker, the executor that runs an agent’s sandbox, the git host that receives the pull request — and coupling the domain to any one of them would mean a rewrite per environment.

Hexagonal architecture — ports and adapters — is the textbook answer. It is also the pattern that most often ends as the name of a folder: an interfaces/ package, one implementation per interface, and a domain that still knows what a bucket is. This article is about the version of it that DOP runs: where hexagonal was worth it, what it costs, and the three disciplines that keep it from decaying. The code is real — the snippets come from dop-core, the platform’s Go core, trimmed for reading.

The shape

drives the domaindriven by the domaininternal/domainmodel · use cases · portsdemand · deliveryexecution · resourceidentity · …dop-apiPython BFFdop-cmdCLIedgegRPCThe BFF and the CLI reach thedomain only through gRPC —the one driving adapter.infrastructure · chosen at boot, one activeproviders · per request, several activeSecretStorememoryk8sgcpEventBusmemorynatsIdentityProviderfirebaseoidcSandboxLauncherdockerk8sGitProvidergithubgitlabAgentProvideranthropicopenaiMailersmtpsendgridinternal/appthe composition root — knows both ends; configuration picks each adapter
The three regions of dop-core. The domain declares the ports (the sockets on its edges); each adapter package plugs into one; the composition root does the plugging, by configuration. The two families on the right have opposite life cycles.

Three regions. internal/domain owns the model and declares the ports — the vault, the identity provider, the event bus, the executor, the repositories — in its own vocabulary. internal/adapter holds one package per technology: Secret Manager and a Kubernetes Secret behind the same interface, NATS JetStream and an in-memory bus behind another, GitHub and GitLab behind a third. internal/app is the composition root: the only place that knows both ends, where a configuration value picks which adapter fills which port. On the other side, the gRPC server is the driving adapter; the Python BFF and the CLI reach the domain through it and through nothing else.

None of that is new. What matters is a distinction the diagram makes and most hexagonal codebases do not.

Two families of ports

Infrastructure portsDomain provider ports
ExamplesSecretStore, IdentityProvider, ObjectStore, EventBus, repositoriesGitProvider, AgentProvider, Mailer
Who choosesThe deployment environmentThe account’s configuration
WhenOnce, at bootOn every request
How many activeOneSeveral at the same time

They look alike — a Go interface, N implementations — and have opposite life cycles. An infrastructure port is chosen by the deployment: once, at boot, one adapter active for the life of the process. A domain provider port is chosen by the account’s data: on every request, several adapters active at once, because one workspace has a repository on GitHub and another on GitLab and both have to work. Confusing the two is this design’s typical mistake — a git provider picked by an environment variable works on the demo and fails, silently, on the first customer with two hosts. The git provider section below shows what the per-request family looks like in code.

A port in the domain’s language

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)
}

Four verbs. SecretRef is opaque on purpose: the domain never sees a path in Secret Manager or a namespace in Kubernetes. SecretValue cannot print itself, which is how guarantee 6 survives a careless %v. And the guarantees are written on the port, numbered, because they are what the contract suite tests — the interface says what the methods are; the comment says what they promise.

Notice what is not there: versions. Secret Manager has them, a Kubernetes Secret does not, and a capability that does not map across adapters stays out of the port. If it is ever needed it comes in as an optional capability the domain never assumes.

The use case on the other side of the port knows exactly this much:

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,
	}
}

The domain builds a reference from its own identifiers and calls Put. It orders the vault before the row for a reason it writes down, and it knows nothing about where the bytes go.

Two adapters from day one

The first discipline: a port with a single adapter is a guess, and it comes out shaped like the vendor that inspired it. So the local adapter is not “for later” — it is written with the port, and it is the proof that the port is right.

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)

Fifty lines, and it is not a mock: it copies bytes in and out so a caller cannot mutate the vault by accident, and it passes exactly the same tests as the GCP one. The in-memory event bus is the same story with more at stake — it delivers in a goroutine, with backoff, an attempt cap and the same poison-message policy as JetStream, because a double that delivered synchronously and perfectly would hide the bugs that only show up with asynchronous delivery.

The composition root picks between them by configuration:

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 holds ports, never concrete types, and that switch is the only conditional on a backend in the whole codebase. This is what “chosen at boot, one active” looks like.

The contract suite is what makes it true

The second discipline. Two adapters that each pass their own tests are two adapters; two adapters that pass the same tests are substitutable. DOP keeps one suite per port under test/contract, written against the port’s numbered guarantees:

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 ...
	})
}

The comment at the top records the trap. The first version of this suite used fixed account names and passed — against the in-memory double, where every subtest gets a fresh vault. Against a real backend the same vault persists between subtests, and the secret left behind by subtest 1 broke subtest 5. The suite had been written on top of the double and carried an assumption only the double satisfied; no real adapter would pass, and none was being run. Unique identifiers per run fixed the suite. Running it against the real thing is what found the bug — which is the point of the next file:

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
	})
}
SecretStoreSuitesix numbered guaranteestest/contract/secretstore.gomemoryalwaysk8swhen a cluster answersgcpbehind a build tag, when a credential existsone function, three targetsthe frontier, guarded by two tests on every go test ./...internal/domaindeclares the portsinternal/adapterimplements themSDKspgx · nats · gcp · k8sallowedimplementsneverTestTheDomainDoesNotImportInfrastructure — nothing under internal/domain imports an adapter or an SDK.TestOnlyAppKnowsTheAdapters — outside internal/app, nothing imports internal/adapter.
What keeps the pattern true over time. Above: one contract suite runs against every adapter. Below: the import frontier — adapters may know the domain and the SDKs; the domain may know neither — enforced by tests that break the build.

Same function, three targets: the in-memory adapter always, Kubernetes when a cluster answers, Secret Manager behind a build tag when a credential exists. The warning above the GCP test is earned: the emulator is more permissive than Google in nine documented points, and one of them is the port’s first guarantee.

When the adapter cannot keep the promise, the adapter pays

SecretStore promises read-after-write. The promise was born from the Kubernetes adapter, where it holds — provided the adapter reads through the API and not through a mounted volume, which the kubelet syncs about once a minute. The GCP adapter could not keep it as written. Google is explicit that only a read by version number is strongly consistent, while the latest alias converges “typically within minutes, but may take a few hours”. And SecretRef is flat; there is nowhere to keep a version.

The failure mode is the worst one a vault can have. On real GCP a Get right after a Put could return (nil, nil) — which through the port means “it does not exist”. A credential just written would look absent, silently, and the caller would conclude the integration was never configured. In the emulator the same case passes in ten milliseconds.

SecretStore.Putthe promise: read-after-writein-memory adaptermap[key] = copyreturn nilthe promise costs nothing hereSecret Manager adapterAddSecretVersionversion n createdconfirm v = nby number — strongawait latest ≥ nalias — eventualdestroyOlderold material goneretry · 25 ms → 1 s · ≤ 30 sKindUnavailablepast the ceilingwrite accepted, read-after-write not confirmed:an error somebody reads, not a Get saying "absent".
The same Put through two adapters. In memory the promise is free. On Secret Manager the adapter confirms by version number, waits for the alias to catch up — the highlighted step — and past the ceiling refuses rather than lie.

Three ways out were on the table. Loosening the guarantee to “eventually consistent” pushes a reread loop onto every caller, and the caller cannot tell “not yet” from “never”. Caching the value in the process after the Put creates a second place where a credential exists, with its own invalidation — it trades a consistency problem for a security one. Reading by version number is the strongly consistent path, but it needs Put to return an identifier the caller keeps; that changes the port, not an adapter, and it is recorded as a possible evolution rather than rejected.

The decision was that the guarantee holds and the adapter pays:

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
		}
	}
}

Confirm the write by version number, then wait for latest to catch up under a ceiling, and if it does not converge, refuse with an explicit error. Refusing is the part that matters: a Put that returns success while the following Get says “not found” produces a silently broken integration; a Put that fails produces an error somebody reads. The residue is written down too — a Put on GCP is slower and can fail on non-convergence, behaviour the emulator never reproduces, so the local test does not cover that path.

This is the moment hexagonal earns its keep. The domain never learned that Secret Manager has an alias, the use case did not change a line, and the place where the vendor’s semantics were absorbed is one function in one adapter.

The per-request family: the git provider

A pull request goes to whichever host the repository lives on, and the token that opens it belongs to the account’s integration. The delivery domain declares what it needs and who resolves it:

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)
}

Two interfaces. GitProvider is the delivery domain’s vocabulary — open, rebase, merge, and whether the host has a merge queue of its own that DOP’s queue can orchestrate on top of. GitProviders resolves which one serves a given repository. The resolver is implemented in the composition root, because that is the only place allowed to know all three ends:

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/deliveryopens the PRFor(account, repo)internal/app · gitProviders.Forrepository→ integrationintegration→ providervault→ tokenresolved on every requestunknown provider: a refusal,never a defaultGitHubGraphQL · merge queueGitLabREST · merge trainsthe token arrives ready-madeGet(ref)ports.SecretStoreread here, in the coreThe delivery domain never learns GitHub exists.The git adapter never learns a vault exists.The resource domain never learns PRs exist.
A per-request port. The delivery domain asks for a provider by repository; the composition root resolves integration and credential and returns a ready adapter — GitHub for one repository, GitLab for the next.

Repository → integration → credential → adapter, on every request. The git adapter receives a ready token and never learns a vault exists; the delivery domain never learns GitHub exists; the resource domain never learns pull requests exist. An unknown provider is a refusal, not a default, because opening a PR in the wrong place is worse than not opening one. The agent provider port — Anthropic or OpenAI, chosen per account — has exactly the same shape.

The frontier is a test

The third discipline is the cheapest and the one most often skipped. A rule that lives in a README lasts until the first deadline. DOP’s lives in the test suite:

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 */ }

Parse every file under internal/domain for its imports; fail on any adapter package or vendor SDK. A second test walks everything else under internal/ and fails if anything but the composition root imports an adapter. Both run on every go test ./..., so the frontier breaks the build before it breaks the design.

What it costs, and where not to bother

Two adapters per port, written and maintained, from the first commit. A contract suite per port, and the infrastructure to run it against the real backends, not only the emulators. One more indirection on every infrastructure call. And a vendor’s strongest feature — secret versions, per-secret IAM, Firebase’s custom claims — inaccessible to the domain by construction. That last one is the price, and it is deliberate.

The same rules say where the pattern does not pay. Two alternatives were considered for the platform as a whole and rejected: couple to GCP now and port later — “later” is when the coupling is already spread out, and running on a local cluster was a development requirement, not an ambition — and a generic multi-cloud library, which delivers the common denominator of the library’s vendors rather than the domain’s, and trades one coupling for another. But inside the boundary, DOP does not put a port in front of Postgres queries that will only ever run on Postgres, does not abstract the gRPC edge, and does not wrap the logger. A port is worth having where the second adapter is real: a second environment, a second host the customer can pick, a test that has to run with no infrastructure. Where the second adapter is imaginary, the interface is a folder name.

Hexagonal, in the end, is an accounting rule: the domain pays nothing to a vendor, and the adapter pays whatever the vendor charges. The three disciplines are how the books stay honest.