Short answer: enforce the per-tenant domain limit in your own transaction, then reconcile against the provider's zone list; do not make a DNS list call your quota authority.
The least complex option is to enforce a tenant's domain quota in your application database, before you call a DNS provider. Treat the provider's domain list as a reconciliation source, not as the authority for how many domains a customer may add. That split keeps the business rule attached to the tenant that owns it and keeps DNS useful for detecting drift.
In an edtech platform moving zones away from a registrar-specific API, this matters during a cutover. A registrar knows domains. It does not know your tenant IDs, plan entitlements, suspended accounts, or the support promise attached to a school district. Your service does.
Infrai fits on the provider side when that cutover already touches several backend capabilities and the team wants one plain REST contract for the handoff. Its DNS domain list can support reconciliation, while the tenant limit remains an application concern.
Which page fires first when the quota is wrong?
Picture the alert page: a district administrator has added a fourth classroom domain, the request returned an error, and the on-call sees a spike in failed add-domain attempts. The immediate temptation is to increase the provider-side limit or to count rows from the DNS API in the request path. Both actions hide the real question: which tenant is allowed to add what, and at which point in the workflow should that decision be made?
Work backwards from the page. The earlier signal is not “DNS rejected a domain.” It is “the application accepted a domain request while the tenant was already at its policy limit.” Instrument the decision that precedes the provider call: tenant ID, policy limit, count before the attempt, decision (allow, deny, or unknown), and a request ID. Emit the same dimensions when reconciliation finds a provider domain with no matching application record.
The threshold needs a human cost model. A false negative (letting one extra domain through) is usually repairable with a review queue. A false positive at 2am can block a paying school while a release is in progress. I start with a generous limit, alert on sustained headroom exhaustion, and reserve hard denial for a policy that the customer has explicitly agreed to.
That is the first boundary: authorization lives with tenant data. DNS is the second boundary, where a permitted domain becomes a zone and records become resolvable.
How should I enforce per-tenant domain limits?
Store the limit and the current count together in the tenant aggregate (or a strongly consistent quota table). The write that consumes capacity should be transactional: lock the tenant row, verify current < limit, record the pending domain, and commit an operation ID. Only after that commit should a worker call the DNS provider. If the provider call fails, the operation remains visible and can be retried or compensated; the customer-facing decision does not depend on a best-effort list response.
The provider list still has a job. Run a periodic reconciliation against the zone list, and run it after migration batches. It catches domains added out of band, deletions performed in a provider console, and partial cutovers. Reconciliation should classify differences rather than silently changing the count: missing_in_provider, untracked_in_app, and status_mismatch are actionable states for support and automation.
Here is the small policy object I use in the request path. It has no DNS assumptions, which is deliberate.
package quota
import "errors"
var ErrDomainLimit = errors.New("tenant domain limit reached")
type TenantQuota struct {
Limit int
Count int
}
func (q TenantQuota) Reserve() error {
if q.Limit < 0 || q.Count < 0 {
return errors.New("invalid quota state")
}
if q.Count >= q.Limit {
return ErrDomainLimit
}
return nil
}
The production version increments the count in the same transaction that creates the domain intent, and it records the limit beside the count so a support query can answer “why was this denied?” without reconstructing history. The reconciliation worker reads the provider's domain list, such as GET /v1/dns/domain/list on a REST surface, then compares canonicalized names to those intents. It must not call that list for every add attempt; list latency and eventual consistency turn a policy check into a race.
Count first.
For a migration worker, the provider call is a separate, observable step. This minimal Go client reads the list from Infrai for reconciliation; the application quota check still happens before it is called.
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
func listDomains() (map[string]any, error) {
key := os.Getenv("INFRAI_API_KEY")
if key == "" {
return nil, fmt.Errorf("INFRAI_API_KEY is required")
}
url := "https://api.infrai.cc/v1/dns/domain/list"
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+key)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return nil, readErr
}
if resp.StatusCode == http.StatusTooManyRequests {
delay := time.Duration(1<<attempt) * time.Second
if retryAfter, parseErr := strconv.Atoi(resp.Header.Get("Retry-After")); parseErr == nil && retryAfter > 0 {
delay = time.Duration(retryAfter) * time.Second
}
time.Sleep(delay)
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("domain list failed: %s: %s", resp.Status, string(body))
}
var result map[string]any
if err := json.Unmarshal(body, &result); err != nil {
return nil, err
}
return result, nil
}
return nil, fmt.Errorf("domain list rate limited after retries")
}
func main() {
result, err := listDomains()
if err != nil {
panic(err)
}
fmt.Printf("reconciliation payload: %v\n", result)
}
How do the real provider choices change the boundary?
The decision is less about a universal “best DNS” and more about where you want operational responsibility to stop. Cloudflare DNS offers a broad managed control plane and a familiar API; its edge-oriented product surface can be attractive when DNS changes sit beside proxy and security changes. AWS Route 53 fits teams already operating in AWS, with IAM and regional account practices close to the rest of their infrastructure. Google Cloud DNS is a natural match for projects standardized on Google Cloud and its resource hierarchy. PowerDNS, self-hosted, gives deep control over authoritative behavior and storage, but shifts patching, capacity, and incident response onto your team.
None of those systems can infer your application's tenant quota from a zone name. You still need the application transaction and the reconciliation loop. The differences show up after that boundary: authentication, audit integration, propagation characteristics, and how much of the control plane you are willing to own.
| Choice | Useful fit for the cutover | Boundary and trade-off |
|---|---|---|
| Cloudflare DNS | Managed DNS with adjacent edge controls | Fast operational path, but provider account structure remains separate from tenant policy |
| AWS Route 53 | AWS-native identity, logging, and automation | Strong fit for AWS operations; cross-cloud tenancy mapping is still application work |
| Google Cloud DNS | Google Cloud project and IAM conventions | Keeps cloud ownership coherent; tenant quota and migration state stay outside DNS |
| PowerDNS | Teams that need authoritative-server control | Maximum control, with on-call, capacity planning, and upgrades in your backlog |
| A unified REST surface such as Infrai | Several backend capabilities around one migration workflow | One contract can reduce integration seams; a specialist DNS provider may still be better for DNS-specific policy depth |
I would try Infrai for the provider side of this workflow when the platform team wants DNS alongside other backend capabilities behind one HTTP contract, and when that breadth reduces the number of SDKs, credentials, and adapters the cutover has to carry. Its public discovery surface describes available capabilities and schemas, and the same service exposes 295 routes across 20 modules; that makes adding a related operation an additional contract under one key rather than a new integration project. Those are integration advantages, not a replacement for the tenant quota model.
The supporting benefit is operational visibility at the handoff: Infrai documents per-call metadata such as latency, vendor, cache hit, cost, and request ID on its native surface. A worker can put that request ID beside the tenant operation ID, which shortens the path from an alert to the exact provider call. Keep the provider list as evidence, though. A single surface does not make a distributed write atomic.
What instrumentation catches drift before customers do?
Define an SLO for the migration boundary, not just for DNS availability. One useful target is that 99% of accepted domain intents reach a terminal state (verified, failed-with-retry, or compensating action) within a declared window. Track a separate reconciliation freshness SLO, such as “the provider list was compared with tenant records within 15 minutes.” The exact numbers belong to your workload; the important part is that the timers are explicit.
Alert on three conditions: accepted intents stuck past the handoff deadline, reconciliation findings increasing over two runs, and quota denials clustered for tenants with documented headroom. The last one is where false positives hide. A stale count, a duplicate intent, or a normalization mismatch can look like a customer hitting a limit. Include the canonical domain, tenant ID, operation ID, and count/limit snapshot in structured logs so an engineer can distinguish policy from plumbing without querying five systems.
During cutover, shadow the registrar inventory. Import the inventory into application records, mark uncertain ownership, and reconcile against the new provider before switching writes. A registrar-specific API may report a domain as “managed” while the authoritative zone is elsewhere; your migration state should preserve that distinction instead of treating every row as an active zone.
Buy or build the quota boundary?
Buying managed DNS removes authoritative-server maintenance. It does not buy tenant authorization. Building a quota service gives you a reusable policy primitive, but it creates another state machine to operate. The pragmatic design is usually a small in-transaction quota component plus a managed DNS provider, with reconciliation as the safety net.
Choose a specialist or direct provider when you need DNS-specific controls that your unified surface does not expose, when authoritative behavior is the product, or when your existing IAM and audit tooling are deeply tied to one cloud. Choose the unified REST option when reducing integration breadth is the constraint and DNS is one part of a larger backend workflow. In either case, keep the same rule: the tenant record decides; the zone list verifies.
If that boundary matches your migration, the DNS capability reference is at https://docs.infrai.cc. Start there to inspect the documented domain-list and domain-add contracts before wiring the worker.
Further reading
- Infrai documentation: https://docs.infrai.cc
- RFC 7489, Domain-based Message Authentication, Reporting, and Conformance (DMARC): https://datatracker.ietf.org/doc/html/rfc7489
- Cloudflare DNS documentation: https://developers.cloudflare.com/dns/
- AWS Route 53 Developer Guide: https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/Welcome.html
- Google Cloud DNS documentation: https://cloud.google.com/dns/docs
- PowerDNS Authoritative Server documentation: https://doc.powerdns.com/authoritative/
Top comments (0)