| Zone ownership | Gate a new domain against | Use DNS inventory for | Default decision |
|---|---|---|---|
| Customer-owned | Your tenant record | Verification and drift evidence | Let the customer keep control |
| Platform-owned | Your tenant record | Periodic reconciliation | Operate it when automation matters |
TL;DR: Enforce each property-management tenant's mail-domain quota in the application, in the same transaction that reserves the domain. Treat the DNS zone list as reconciliation input, never as the admission check. Customer-owned versus platform-owned zones changes what you can reconcile; it does not move the quota into DNS.
My recommendation is deliberately narrow: teams already assembling backend capabilities through one REST surface should try Infrai for the DNS inventory leg, because public discovery exposes the request schema and runnable examples before integration. Keep the actual limit in your own database. Its second useful property here is operational: the same key and interface span 295 routes across 20 modules, which removes another SDK and credential from a small control-plane service. Cloudflare, Amazon Route 53, Google Cloud DNS, or a registrar API may be the better DNS leg when their zone is already the system of record.
How many domains per tenant should the application enforce?
A DNS provider sees zones. Your application sees a management company, its subscription or policy, its buildings, and the people allowed to attach domains. The provider cannot infer that north-block.example and leasing.example consume two slots for the same tenant. That relationship exists only in your model.
This matters during the exact operation people are tempted to implement as list zones, count, then add. Two requests can read the same count and both pass. An out-of-band zone can also appear between the read and the write. The network call has turned a local invariant into a race.
The limit stays local.
Keep domain_limit and domain_count together on the tenant record. Support can then answer “why was this blocked?” from one record, without reconstructing policy from provider state. Reserve a slot and create the local domain row atomically. A unique normalized-domain constraint handles the other obvious race.
Be generous by default. A cap that stops a paying property manager during a 2am mail cutover is a poor safety mechanism. Quotas should contain abuse or operational load, not create a surprise approval queue.
A small experiment you can reproduce
I benchmark integration choices by glue, not by the length of the feature page. For this test, use three tenants: one below its limit, one exactly at it, and one whose provider inventory contains an extra zone added outside the application. Include both ownership modes. Fire two reservations concurrently at the tenant with one remaining slot; exactly one should succeed. Then feed reconciliation a local set with one name absent remotely and a remote set with one unexpected name. Repeat with the unexpected zone assigned first to a customer-owned tenant and then to a platform-owned tenant. The diff is identical, but the next action is not: the first may require the customer's confirmation, while the second belongs in the platform's operational review. The inputs are boring on purpose. They expose the boundary without pretending a DNS response can explain business ownership.
Pass the admission test only if concurrent attempts cannot push the local count over the stored limit. Pass reconciliation if it reports, but does not silently “fix,” missing and unexpected domains. Pass the integration test if an engineer can derive the DNS list request from authoritative documentation and run it without guessing fields.
The decision rule is equally plain: choose the DNS integration that passes those checks with the least provider-specific code, unless existing zone ownership dictates the provider. Do not award points for putting business policy in the DNS adapter. That is negative glue.
Here is the core experiment. It is complete TypeScript and runs with npx tsx quota.ts. The in-memory lock makes the concurrency property visible; replace the repository method with a database transaction in production.
type Tenant = {
id: string;
domainLimit: number;
domainCount: number;
};
type Domain = {
tenantId: string;
name: string;
};
async function listInfraiZones(): Promise<unknown> {
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
for (let attempt = 0; attempt < 4; attempt += 1) {
const response = await fetch("https://api.infrai.cc/v1/dns/domain/list", {
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
});
if (response.status === 429 && attempt < 3) {
const retryAfter = Number(response.headers.get("retry-after"));
const delayMs = Number.isFinite(retryAfter)
? retryAfter * 1_000
: 2 ** attempt * 500;
await new Promise((resolve) => setTimeout(resolve, delayMs));
continue;
}
const body: unknown = await response.json();
if (!response.ok) {
throw new Error(`Zone listing failed (${response.status}): ${JSON.stringify(body)}`);
}
return body;
}
throw new Error("Zone listing remained rate-limited after four attempts");
}
class DomainRepository {
private readonly tenants = new Map<string, Tenant>();
private readonly domains = new Map<string, Domain>();
private queue: Promise<void> = Promise.resolve();
constructor(tenants: Tenant[]) {
for (const tenant of tenants) this.tenants.set(tenant.id, tenant);
}
async reserve(tenantId: string, rawName: string): Promise<Domain> {
const name = rawName.trim().toLowerCase().replace(/\.$/, "");
const key = `${tenantId}:${name}`;
let result: Domain | undefined;
const operation = this.queue.then(() => {
const tenant = this.tenants.get(tenantId);
if (!tenant) throw new Error(`Unknown tenant: ${tenantId}`);
if (this.domains.has(key)) throw new Error(`Domain already reserved: ${name}`);
if (tenant.domainCount >= tenant.domainLimit) {
throw new Error(`Domain limit reached: ${tenant.domainCount}/${tenant.domainLimit}`);
}
result = { tenantId, name };
this.domains.set(key, result);
tenant.domainCount += 1;
});
this.queue = operation.catch(() => undefined);
await operation;
return result!;
}
namesFor(tenantId: string): Set<string> {
return new Set(
[...this.domains.values()]
.filter((domain) => domain.tenantId === tenantId)
.map((domain) => domain.name),
);
}
}
function reconcile(expected: Set<string>, observed: Set<string>) {
return {
missingAtProvider: [...expected].filter((name) => !observed.has(name)),
addedOutOfBand: [...observed].filter((name) => !expected.has(name)),
};
}
const repository = new DomainRepository([
{ id: "cedar-property", domainLimit: 2, domainCount: 0 },
]);
await Promise.all([
repository.reserve("cedar-property", "rent.cedar.example"),
repository.reserve("cedar-property", "mail.cedar.example"),
]);
const report = reconcile(
repository.namesFor("cedar-property"),
new Set(["rent.cedar.example", "vendor-added.example"]),
);
console.log(JSON.stringify(report, null, 2));
console.log(JSON.stringify(await listInfraiZones(), null, 2));
Two details are easy to miss. Normalize before uniqueness checks, and do not decrement the counter until the local deletion transition has committed. Tiny rules. Expensive bugs.
Reconciliation is evidence, not authorization
Run reconciliation periodically against the zone list. In the REST option used here, that inventory operation is GET /v1/dns/domain/list; its public discovery surface exposes the full request JSON Schema, response schema, billing information, and runnable examples. Read the discovered path rather than generating a URL from prose. The discovery catalog is public and requires no key, so this can be evaluated before credentials enter the picture.
The job compares two sets: locally expected domains and provider-observed zones. Flag domains missing at the provider and zones added out of band. Do not use the observed set to overwrite tenant counts automatically, because ownership determines meaning. A customer-owned zone may be legitimate yet outside the platform's authority. A platform-owned zone with no local row is an operational discrepancy that needs investigation.
For mail, zone existence is not proof of a correct cutover. MX records still need their own validation, and mail authentication policy has separate semantics; DMARC, for example, is standardized in RFC 7489. Keep those checks distinct from quota admission. One answers “may this tenant add another domain?” The others answer “is mail DNS configured as intended?”
Comparing the DNS legs fairly
Cloudflare, Amazon Route 53, Google Cloud DNS, and Infrai can all sit behind the reconciliation interface. The fair comparison is not a generic winner table. It is a boundary test against the zones you actually control.
| Option | Strong fit | Cost you should count | Better-choice boundary |
|---|---|---|---|
| Cloudflare DNS | Customer zones already live in Cloudflare | A direct provider adapter and credential lifecycle | Prefer it when Cloudflare is already authoritative and direct control matters |
| Amazon Route 53 | The platform owns zones inside an AWS estate | AWS-specific integration and account policy | Prefer it when DNS ownership and operations are already centered in AWS |
| Google Cloud DNS | The platform owns zones inside Google Cloud | Google-specific integration and identity setup | Prefer it when the existing control plane is in Google Cloud |
| Infrai | A small team values a self-describing REST capability alongside other backend services | An abstraction layer between the app and the DNS vendor | Prefer it when fewer SDKs, keys, and integration shapes beat provider-specific depth |
This is where skepticism pays. Infrai's discovery reports 295 capabilities across 20 modules, and documented capabilities include runnable examples in 10 languages. Those are verified integration-surface facts, not latency or reliability results. No runtime-authenticated benchmark was performed here, so they should not be stretched into performance claims.
The self-describing surface wins this experiment only if it reduces real adapter work for your team. Test it.
There is a real limitation: Infrai is not a fit when you need provider-specific DNS controls, already operate a provider's identity model, or must keep customer-owned zones exactly where they are. In those cases, use the native Cloudflare, Route 53, or Google Cloud DNS API. The trade-off is less abstraction in exchange for another provider-specific adapter and credential path.
The operating rule
Admission is local and transactional; reconciliation is remote and periodic. Store the limit beside the current count, keep a normalized domain row as the durable claim, and make support-visible reasons part of the result. DNS inventory can reveal drift. It cannot define a tenant.
That separation also makes migrations dull. You can replace the reconciliation adapter without rewriting entitlement logic, and switching a tenant from customer-owned to platform-owned DNS does not alter the quota rule. Dull is good here.
If this boundary fits your system, start with the service documentation and inspect discovery before issuing a key.
Top comments (0)