Short answer: store the provider's DNS zone ID with the application's zone inventory, but treat it as a replaceable external reference, never as the primary key for the domain or its records. Resolve every record operation through a stable application-owned zone key, verify that the returned zone still represents the expected domain, and reconcile desired MX records against published DNS before declaring a logistics mail cutover complete.
That separation is the difference between remembering where a zone lives and letting a provider define what the zone is. The first is useful state. The second turns an account move, a delete-and-recreate event, or stale inventory into an identity failure.
Should the application store the DNS zone ID for record operations?
Yes, as a scoped locator. No, as the application's primary key.
The useful model has three identities: an application-owned zone key, the normalized domain name that humans recognize, and the provider-issued zone ID used for remote record operations. The application key stays stable through infrastructure changes. The domain is the assertion checked at the provider boundary. The provider ID is cached coordination data that can be replaced after discovery or reconciliation.
This matters in a logistics company because mail is operational traffic. Dispatch notices, carrier exceptions, and warehouse escalations may all depend on the same domain while DNS control moves between accounts or providers. A write that reaches the wrong zone can be syntactically successful and still violate intent. A write that reaches no zone is easier to notice.
I bring an idempotency reflex to this problem because I've been paged by missed scheduled jobs and duplicate queue deliveries. Those incidents don't prove anything special about DNS, but they expose the same bad assumption: a job executing once is not a reliability property. For an MX cutover, the durable question is whether the published record set converges on the approved intent after retries, overlap, and process restarts.
Use the domain as a verification attribute, not as the only stored handle. A name-based lookup on every mutation removes a cached ID but adds another resolution step whose result can drift too. Storing only the provider ID is worse because the application loses an independent way to detect misbinding. Keeping all three identities gives the control loop enough information to refuse an ambiguous write.
| Field | Role | Can change? | Use in a write |
|---|---|---|---|
zone_key |
Application identity | Rarely | Load desired state and audit history |
domain |
Boundary assertion | Under an explicit rename or migration | Verify the resolved remote zone |
provider_zone_id |
Remote locator | On recreation, account move, or provider change | Address the remote operation after verification |
The invariant is compact: one application zone key maps to one expected domain and, for the active provider binding, at most one current provider zone ID. Persist the binding with its provider and account scope. An ID without that scope is incomplete.
Drift is the incident, not a failed request
The dangerous state is a green deployment paired with the wrong published MX set. The application may have accepted the change, the worker may have completed, and the remote write may have returned normally. None of those observations establishes that intent and publication match.
So the unit of work should be a reconciliation attempt, not a one-shot record update. Start with desired state: the exact MX owner, preference, and exchange values approved for company mail. Read the current zone binding. Resolve or validate the remote zone. Read the relevant published records through an independent DNS observation path. Plan the smallest change, apply it, then observe again. Only that last comparison closes the operation.
Take a planned mail cutover for dispatch.example: inventory maps an internal zone_key to that domain, the active provider and account scope, and the last verified provider zone ID. The change request contains the complete approved MX set rather than an instruction such as “add the new exchange,” because an additive command says nothing about an obsolete exchange left behind. When the worker starts, it resolves the zone inside the expected account and checks the returned domain against dispatch.example. A mismatch stops the mutation and creates an identity alert. A match permits a full set comparison. If current state already equals desired state, the worker skips the write and proceeds to observation; if it differs, the worker submits the replacement once for that desired-state revision. The observer then queries the mail domain through the team's chosen independent DNS path and records the complete answer. A difference at that stage leaves the revision pending and schedules another observation. It doesn't cause another mutation unless the control-plane read also shows drift. This distinction matters during overlapping runs: the queue can deliver the same revision twice, and a periodic sweep can start while an event-triggered run is active, yet every participant reasons from the same application key, desired revision, and observed set. The runbook can therefore answer three separate questions after an alert: did we bind the intended zone, did the control plane accept the intended record set, and has published DNS converged?
Be strict here.
An MX set is a set, not a single scalar. Comparing only the first answer can hide an obsolete exchange. Comparing only exchange names can miss an unintended preference change. Conversely, treating answer order as meaningful creates noise because set equality and response order are different concerns. The reconciler should canonicalize the fields that define intent, compare the complete expected and observed sets, and record which side differed.
DMARC adds a related boundary around mail-domain policy. RFC 7489 defines a domain-based mechanism for message authentication policy, reporting, and conformance. It does not make an MX mutation correct, and an MX reconciler should not pretend that it does. It does mean a mail-domain change deserves a wider runbook: preserve the approved authentication and policy records, validate them separately, and avoid treating “mail routes” as the entire email control plane.
I would schedule reconciliation even when changes are event-driven. Events give low latency; a periodic sweep repairs missed work and detects out-of-band edits. I'm not sure there is one defensible interval for every logistics operation — the right interval depends on the organization's recovery objective, DNS caching behavior, query volume, and how quickly humans may change records outside the application. What matters is writing that interval down and alerting on sustained divergence, not choosing a ceremonial number.
Make the preventative path idempotent
The code path below keeps provider details behind an interface and makes the identity checks visible. It does not assume a vendor route or SDK. ResolveZone may use a provider-supported discovery mechanism; ObserveMX should use the observation path selected by the operator. The important bit is that a recovered locator is accepted only after its domain matches the inventory entry.
package dnschange
import (
"context"
"errors"
"fmt"
"sort"
"strings"
)
type Zone struct {
Key string
Domain string
Provider string
Account string
ProviderZoneID string
}
type MX struct {
Preference uint16
Exchange string
}
type ControlPlane interface {
ResolveZone(ctx context.Context, provider, account, domain string) (string, string, error)
ReadMX(ctx context.Context, zoneID, owner string) ([]MX, error)
ReplaceMX(ctx context.Context, zoneID, owner string, desired []MX) error
}
type Observer interface {
ObserveMX(ctx context.Context, owner string) ([]MX, error)
}
type Inventory interface {
SaveBinding(ctx context.Context, zoneKey, provider, account, zoneID string) error
}
func ReconcileMX(
ctx context.Context,
cp ControlPlane,
observer Observer,
inv Inventory,
zone Zone,
owner string,
desired []MX,
) error {
if zone.Key == "" || zone.Domain == "" || zone.Provider == "" || zone.Account == "" {
return errors.New("incomplete zone identity")
}
zoneID, resolvedDomain, err := cp.ResolveZone(ctx, zone.Provider, zone.Account, zone.Domain)
if err != nil {
return fmt.Errorf("resolve zone: %w", err)
}
if canonicalName(resolvedDomain) != canonicalName(zone.Domain) {
return fmt.Errorf("resolved zone domain does not match inventory")
}
if zone.ProviderZoneID != zoneID {
if err := inv.SaveBinding(ctx, zone.Key, zone.Provider, zone.Account, zoneID); err != nil {
return fmt.Errorf("save zone binding: %w", err)
}
}
current, err := cp.ReadMX(ctx, zoneID, owner)
if err != nil {
return fmt.Errorf("read mx: %w", err)
}
if !sameMX(current, desired) {
if err := cp.ReplaceMX(ctx, zoneID, owner, desired); err != nil {
return fmt.Errorf("replace mx: %w", err)
}
}
published, err := observer.ObserveMX(ctx, owner)
if err != nil {
return fmt.Errorf("observe mx: %w", err)
}
if !sameMX(published, desired) {
return errors.New("published mx has not converged")
}
return nil
}
func sameMX(a, b []MX) bool {
left := append([]MX(nil), a...)
right := append([]MX(nil), b...)
sort.Slice(left, func(i, j int) bool {
if left[i].Preference != left[j].Preference {
return left[i].Preference < left[j].Preference
}
return canonicalName(left[i].Exchange) < canonicalName(left[j].Exchange)
})
sort.Slice(right, func(i, j int) bool {
if right[i].Preference != right[j].Preference {
return right[i].Preference < right[j].Preference
}
return canonicalName(right[i].Exchange) < canonicalName(right[j].Exchange)
})
if len(left) != len(right) {
return false
}
for i := range left {
if left[i].Preference != right[i].Preference ||
canonicalName(left[i].Exchange) != canonicalName(right[i].Exchange) {
return false
}
}
return true
}
func canonicalName(name string) string {
return strings.TrimSuffix(strings.ToLower(strings.TrimSpace(name)), ".")
}
The remote mutation is conditional on a set comparison, so retrying the job does not intentionally duplicate state. The final observation can still report “not converged” while cached DNS answers age out; that is a pending control-loop state, not permission to hammer the write path. Record the desired-state revision, attempt time, observed set, and next eligible check. Queue retries then become controlled observations rather than blind mutations.
Concurrency needs one more guard. Two workers may reconcile different desired revisions for the same zone_key. Serialize by that application key or use a compare-and-swap on the desired revision before mutation. A lock keyed by provider_zone_id is insufficient during rebinding because the old and new locators differ precisely when coordination matters most.
Know when this design is the wrong fit
Persisting the provider zone ID is not suitable when the application never mutates DNS and only performs occasional human-reviewed checks. In that case, storing a domain and resolving it during the check keeps inventory smaller, while the runbook can handle ambiguity. It is also unnecessary for a fully declarative workflow in which another control plane owns both zone identity and record convergence; duplicate ownership would create competing writers.
The catch is operational cost. A reconciler adds inventory migrations, binding audits, independent observation, retry policy, and alerts. Teams unable to own that loop should keep DNS changes in their existing infrastructure workflow rather than adding a partial controller to an application. Store a locator only when the application truly owns record operations.
For the logistics mail case, the decision rule is direct: if application workers change MX records, retain the scoped provider zone ID for efficient remote access, but make zone_key the coordination key and the normalized domain the mandatory assertion. If humans or a separate declarative system own the writes, don't create a second writer merely to cache an identifier.
Top comments (0)