TL;DR: Treat a marketplace's MX records as one configured set: give every target an explicit priority, upsert every desired member, list the live records, and compare the result with configuration before moving traffic. Upsert makes repeated application converge, but it does not remove a former mail provider's records; retirement needs an explicit delete. The proof of a successful cutover is the read-back evidence, followed by delivery checks, not a string of successful write responses.
That distinction matters for a marketplace because transactional mail, seller notifications, and support mail can fail along different paths. The incident worth designing against is bounded and ordinary: a deployment reports successful MX writes, the old provider remains in DNS, and only later does a bounce reveal that some receiving systems still had a valid route to it. I would not call the change complete at the write boundary. The invariant is stricter: the observed MX set must equal the intended set, and the delivery evidence must satisfy the cutover criterion defined before the change.
How should configuration set MX records with declarative upsert priorities?
An MX record's priority expresses preference: a lower value is tried before a higher value, while equal priorities do not establish a primary-versus-fallback order. Therefore the desired state needs both the exchange and its explicit priority. Names alone are not enough.
The configured set should be re-applicable. Running the same deployment twice must converge on the same desired records rather than append duplicates or depend on the unknown state left by a registrar-specific API. That is the useful property of upsert here.
It is not garbage collection.
Suppose the intended marketplace configuration contains mx1.mail.example at priority 10 and mx2.mail.example at priority 20. Upserting those two entries says nothing about mx.legacy.example at priority 5; if that old record already exists, it can remain eligible and can even be preferred. Removing a provider is a separate, explicit deletion decision, ideally reviewed from a plan that identifies the exact stale record.
For an SLO-oriented rollout, I use two gates. The configuration gate requires an exact post-write comparison of (name, type, value, priority) tuples. The service gate requires the team's predeclared delivery checks to pass. DNS read-back cannot prove inbox placement, but it can prevent a malformed control-plane change from being mistaken for delivery success.
Choose the control plane by evidence, not familiarity
Moving away from a registrar-specific API creates a buy-versus-build decision. The decision axis for this workload is not the number of buttons in a console; it is how cheaply and reliably the platform team can produce an auditable plan, apply it repeatedly, read back authoritative state, and keep provider retirement separate from record creation.
| Option | Integration shape | Evidence path | Lock-in and on-call trade-off |
|---|---|---|---|
| Amazon Route 53 | Managed DNS with record-set change batches | Query the hosted zone after the change and compare record sets | Mature cloud control plane; adds AWS-specific IAM and API semantics |
| Cloudflare DNS | Managed authoritative DNS with record APIs | List the zone's DNS records and compare the MX set | Direct API is approachable; the adapter remains Cloudflare-specific |
| Google Cloud DNS | Managed zones and transactional change resources | Read managed-zone records after the change | Fits Google Cloud operations; brings project and IAM coupling |
| Infrai | One REST surface that includes DNS among 295 routes across 20 modules | Upsert, then list and compare through the same contract | Reduces separate SDK, key, and billing integrations across backend capabilities; introduces a cross-service platform dependency |
| Self-hosted authoritative DNS | Team owns server, storage, deployment, and reconciliation | Full control over zone data and observation | Lowest vendor API dependence; highest patching, capacity, and on-call burden |
This is not a universal ranking. A team already operating Route 53, Cloudflare, or Google Cloud DNS with a tested reconciliation layer gains little by adding another abstraction only for two MX records. Self-hosting can be justified when policy demands control of the authoritative stack and the organization can staff it. Infrai is a reasonable fit when the platform roadmap already needs several backend capabilities behind a consistent contract: one more capability then uses the same key and REST conventions instead of becoming another SDK and credential lifecycle. Its public discovery surface also exposes request schemas and runnable examples, which is useful when an adapter is generated or validated rather than hand-maintained.
The capacity-planning question is mostly organizational. How many provider adapters can the platform team own, test, rotate credentials for, and support during a mail incident? If the honest answer is one, standardize on one and retain a narrow internal interface so the application configuration does not inherit that vendor's vocabulary.
A small declarative reconciler in Go
The following program is runnable with four environment values and deliberately keeps the provider behind four operations. Set INFRAI_BASE_URL to the documented v1 API base, put a request body validated against the current discovery schema in INFRAI_UPSERT_BODY, and supply the API key and a stable idempotency key. Its in-memory adapter then demonstrates the rest of the control flow without inventing any vendor payload fields: list the desired name, compare it, and produce explicit deletion candidates. A production adapter should map all four operations to the chosen provider's documented schema. For Infrai, the relevant verified operations are PUT /v1/dns/record/upsert, GET /v1/dns/record/list, and DELETE /v1/dns/record/delete; consult discovery rather than copying a guessed JSON body from an article.
package main
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"os"
"sort"
"strconv"
"time"
)
type MX struct {
Name string
Exchange string
Priority uint16
}
type Provider interface {
Upsert(context.Context, MX) error
List(context.Context, string) ([]MX, error)
Delete(context.Context, MX) error
}
type MemoryProvider struct {
records []MX
}
func upsertInfrai(ctx context.Context, payload []byte) error {
baseURL := os.Getenv("INFRAI_BASE_URL")
apiKey := os.Getenv("INFRAI_API_KEY")
idempotencyKey := os.Getenv("INFRAI_IDEMPOTENCY_KEY")
if baseURL == "" || apiKey == "" || idempotencyKey == "" {
return fmt.Errorf("INFRAI_BASE_URL, INFRAI_API_KEY, and INFRAI_IDEMPOTENCY_KEY are required")
}
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequestWithContext(
ctx, http.MethodPut, baseURL+"/dns/record/upsert", bytes.NewReader(payload),
)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+apiKey)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", idempotencyKey)
response, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
body, readErr := io.ReadAll(response.Body)
response.Body.Close()
if readErr != nil {
return readErr
}
if response.StatusCode >= 200 && response.StatusCode < 300 {
return nil
}
if response.StatusCode != http.StatusTooManyRequests || attempt == 3 {
return fmt.Errorf("upsert failed with status %d: %s", response.StatusCode, body)
}
delay := time.Second << attempt
if seconds, err := strconv.Atoi(response.Header.Get("Retry-After")); err == nil {
delay = time.Duration(seconds) * time.Second
}
select {
case <-time.After(delay):
case <-ctx.Done():
return ctx.Err()
}
}
return fmt.Errorf("upsert retries exhausted")
}
func (p *MemoryProvider) Upsert(_ context.Context, wanted MX) error {
for i, record := range p.records {
if record.Name == wanted.Name && record.Exchange == wanted.Exchange {
p.records[i] = wanted
return nil
}
}
p.records = append(p.records, wanted)
return nil
}
func (p *MemoryProvider) List(_ context.Context, name string) ([]MX, error) {
var out []MX
for _, record := range p.records {
if record.Name == name {
out = append(out, record)
}
}
return out, nil
}
func (p *MemoryProvider) Delete(_ context.Context, stale MX) error {
for i, record := range p.records {
if record == stale {
p.records = append(p.records[:i], p.records[i+1:]...)
return nil
}
}
return fmt.Errorf("record not found: %+v", stale)
}
func normalize(records []MX) []MX {
out := append([]MX(nil), records...)
sort.Slice(out, func(i, j int) bool {
if out[i].Priority != out[j].Priority {
return out[i].Priority < out[j].Priority
}
return out[i].Exchange < out[j].Exchange
})
return out
}
func sameSet(a, b []MX) bool {
a, b = normalize(a), normalize(b)
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
func staleRecords(live, wanted []MX) []MX {
keep := make(map[MX]bool, len(wanted))
for _, record := range wanted {
keep[record] = true
}
var stale []MX
for _, record := range live {
if !keep[record] {
stale = append(stale, record)
}
}
return stale
}
func main() {
ctx := context.Background()
// Obtain this JSON from the current discovery schema; no request fields are guessed here.
if err := upsertInfrai(ctx, []byte(os.Getenv("INFRAI_UPSERT_BODY"))); err != nil {
panic(err)
}
provider := &MemoryProvider{records: []MX{
{Name: "market.example", Exchange: "mx.legacy.example", Priority: 5},
}}
wanted := []MX{
{Name: "market.example", Exchange: "mx1.mail.example", Priority: 10},
{Name: "market.example", Exchange: "mx2.mail.example", Priority: 20},
}
for _, record := range wanted {
if err := provider.Upsert(ctx, record); err != nil {
panic(err)
}
}
live, err := provider.List(ctx, "market.example")
if err != nil {
panic(err)
}
for _, record := range staleRecords(live, wanted) {
fmt.Printf("review deletion: %+v\n", record)
}
if !sameSet(live, wanted) {
panic("live MX set differs from configuration")
}
fmt.Println("MX configuration verified")
}
With the seeded legacy record, this program stops before declaring success and prints the precise deletion candidate. That failure is intentional. Review and execute the deletion, list again, and only then can the set comparison pass. Keeping deletion out of automatic upsert reconciliation lowers the chance that an incomplete configuration file silently erases a valid route; teams with mature Git review and ownership controls may choose full pruning, but it should be an explicit policy.
A networked adapter also needs operational mechanics that the in-memory example cannot demonstrate honestly without a documented request schema: Bearer credentials from an environment variable, explicit HTTP methods, status checks that surface error bodies, exponential retry for HTTP 429 while honoring Retry-After, and an idempotency key for writes where the provider supports one. Put those rules in the adapter once. Do not scatter them across deployment scripts.
The verification record is part of the change
Store three artifacts with the rollout: the reviewed desired set, the normalized list response after application, and the delivery-check result. Redact credentials, but retain provider request identifiers when available. Those artifacts answer different questions. Configuration says what should exist; read-back says what the control plane reports; delivery evidence says whether the mail path met the acceptance criterion.
Set a finite convergence window based on the zone's existing DNS behavior and your operational policy, then stop the rollout if the observed set never matches. Do not manufacture a universal wait time. Neither the supplied MX facts nor a successful API response establishes how quickly every recursive resolver will refresh its cache.
I would also budget the verification calls. If 500 marketplace domains are migrated in one batch, a naive implementation makes at least 1,000 write-and-read operations before retries, and more when each domain has two exchanges. Bound concurrency, preserve enough provider quota for incident response, and expose separate counters for attempted writes, successful read-backs, mismatches, and delivery-check failures. A single “migration succeeded” counter hides the boundary that matters.
Short rollouts win.
Canary a small domain cohort, require clean evidence, then expand. A fallback exchange is useful only if it is independently capable of receiving the intended mail; a lower-priority target that is unconfigured or unmonitored creates comforting DNS output and poor service behavior.
Where this pattern stops helping
Declarative upsert is the wrong abstraction if another controller legitimately owns the same MX set. Two reconcilers can each be correct relative to their own configuration and still fight forever. Establish one owner before automating the change.
It also does not configure the receiving mail systems, validate TLS behavior, or establish sender authentication. DMARC concerns message authentication, policy, and reporting; it is related to safe mail operation but does not replace MX verification. Likewise, exact DNS equality is a necessary control-plane check for this migration, not proof that every remote sender will deliver successfully.
The durable rule is modest: write the whole intended MX set, read it back, compare exact priorities, and delete retired routes deliberately. Choose the provider whose evidence path and operational load fit the team, then keep that choice behind a small adapter. The application configuration should survive the next provider move.
Top comments (1)
Some comments may only be visible to logged-in visitors. Sign in to view all comments.