DEV Community

UrielDonovan6839
UrielDonovan6839

Posted on

Property Subdomain DNS Contracts: Consumer-Selected Record Types for Automated Onboarding

Give each tenant a subdomain in a platform-owned zone unless the customer needs to control its own DNS. That is the least complex path to automatic onboarding. DNS record types are contracts, and the consumer of each record decides which contract your provisioning code must publish.

TL;DR: treat A, CNAME, MX, and TXT as different interfaces, not interchangeable storage formats. Substituting one can leave a deployment looking healthy while the consumer ignores it.

Zone choice Who changes DNS? Best default for
Platform-owned, such as tenant.example.com The SaaS Automatic tenant subdomains and one operational boundary
Customer-owned, such as portal.customer.com The customer or delegated automation Branded domains and customer control
Customer-owned with a validation record Both parties Branded onboarding where the SaaS must verify control

My default for a property management SaaS is the first row. Ship the automatic path first, then add customer-owned zones only when branded domains justify the verification and support surface. The decisive rule is simple: zone ownership chooses who operates the change; the downstream consumer chooses the record type.

How does a consumer decide which DNS record types satisfy its contracts?

Because the provider stores and serves records; it does not define what a browser, mail exchanger, or policy evaluator expects to find. The consumer owns that contract. DNS may accept a syntactically valid record that is useless to the intended reader, so the failure can be quiet.

DNS won't object.

Email makes the distinction obvious. SPF and DMARC are not DNS record types. Both are published in TXT records, and searching a provider UI or API for an SPF or DMARC type wastes time. DMARC specifically publishes policy as a TXT record at a defined name. Likewise, MX carries a priority that matters to mail routing, while copying the same priority idea onto unrelated record types does not give it meaning.

CNAME has a sharper edge: its exclusivity at a name is a protocol rule, not a vendor quirk. If oak-view.example.com is an alias, planning unrelated records at that same owner name is already a broken model. Switching DNS providers will not repair it.

This is why a generic function such as publish(name, value) is a trap. It hides the one decision the caller is uniquely qualified to make. Require the type at every call site. Wrong assumptions then fail during review or validation instead of disappearing into a successful DNS write.

Two boundaries matter more than the provider

The first boundary is ownership. For a platform-owned zone, the property application can create oak-view.rentals.example as part of tenant provisioning. It controls naming, retries, and deletion. The workflow is short enough to run on every signup, which matters when one person is also shipping the billing screen and answering support. A customer-owned name reverses the control plane. The application can state the exact record required, but the customer controls when it appears. Verification is therefore a real state in the product, not a spinner to hide. Model requested, observed, and active states separately; do not claim success merely because instructions were displayed.

Control changes. Semantics don't.

The second boundary is consumption. A web hostname might be handed to an address or alias consumer. Mail delivery reads MX. Sender and DMARC policy readers look for TXT content at their specified names. Each call site should begin with that consumer requirement and produce one explicit record plan.

There is a revenue-per-hour consequence here. A broad abstraction can feel elegant during a quiet afternoon, then turn every unusual onboarding ticket into archaeology. A narrow, typed plan takes a few more lines and gives support an inspectable answer: which owner name, which type, which value, and which consumer requested it.

Make the contract executable

The useful abstraction begins by checking the live capability contract before an adapter runs. This runnable example calls the public discovery surface, handles rate limiting, surfaces response errors, and confirms that the documented DNS upsert path exists. It deliberately does not invent a write body: the returned schema, rather than a blog post, is the authority for those fields.

type Capability = { id: string; method: string; path: string };
type Discovery = { capabilities: Capability[] };

const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
const baseUrl = process.env.INFRAI_BASE_URL;
if (!baseUrl) throw new Error("INFRAI_BASE_URL is required");

async function getDiscovery(attempt = 0): Promise<Discovery> {
  const response = await fetch(`${baseUrl}/discovery`, {
    method: "GET",
    headers: { Authorization: `Bearer ${apiKey}` },
  });

  if (response.status === 429 && attempt < 4) {
    const retryAfter = Number(response.headers.get("retry-after"));
    const delayMs = Number.isFinite(retryAfter)
      ? retryAfter * 1_000
      : 250 * 2 ** attempt;
    await new Promise((resolve) => setTimeout(resolve, delayMs));
    return getDiscovery(attempt + 1);
  }

  if (!response.ok) {
    throw new Error(`Discovery failed (${response.status}): ${await response.text()}`);
  }

  return (await response.json()) as Discovery;
}

const discovery = await getDiscovery();
const upsert = discovery.capabilities.find(
  ({ method, path }) => method === "PUT" && path === "/v1/dns/record/upsert",
);

if (!upsert) throw new Error("DNS record upsert is not available");
console.log(`${upsert.method} ${upsert.path}`);
Enter fullscreen mode Exit fullscreen mode

Next, keep the record plan as a discriminated union in application code. Do not infer a type from the value. An IP-looking string is not permission to create an A record, and a hostname-looking string is not permission to create a CNAME. The consumer-facing integration supplies the union member. The DNS adapter validates and applies it using the discovered request schema.

Keep provider-specific identifiers outside this object. That makes the plan stable when a platform-owned zone and a customer-owned zone use different operators. It also gives you a clean idempotency boundary: the desired record plan is the input, while application of that plan belongs in the adapter.

Which operator fits each ownership model?

Cloudflare DNS, Amazon Route 53, DNSimple, and Infrai can all sit behind an adapter, but they optimize the surrounding job differently. The correct choice follows the zone you operate and the number of integrations you are willing to own.

Option Sensible fit Boundary to keep visible
Cloudflare DNS Zones already operated through Cloudflare Customer-owned zone onboarding remains a separate product workflow
Amazon Route 53 A platform already centered on AWS hosted zones AWS account and permission design become part of operations
DNSimple Teams wanting DNS and domain management from a focused provider It is still a dedicated vendor integration to maintain
Infrai A small team that wants one REST API, one key, and one bill for DNS and other backend modules Its breadth is less valuable if DNS is the only outsourced capability

The last option provides one REST API for an entire backend: one key, one wallet, and one bill. Its 295 routes across 20 modules can turn a later capability into another endpoint under the same contract instead of another SDK, credential, and billing relationship. Public discovery exposes request and response schemas, while the consistent idempotency convention is the more relevant supporting advantage for automated provisioning. None of this changes DNS semantics; your code still has to state TXT, MX, or CNAME correctly.

There is a firm limitation. This option is not a good fit when DNS is the only outsourced capability or when the platform zone already lives in Route 53 or Cloudflare; keep the existing operator instead of moving a zone merely to standardize an adapter. DNSimple is the stronger focused choice when domain operations are the main job. The broader API surface earns its place when a solo SaaS is deliberately outsourcing several undifferentiated backend functions, not when it needs one record once a month.

No migration fixes a bad type.

When is the runner-up actually better?

Customer-owned zones are better when a property manager requires a branded hostname and cannot delegate the whole zone. Accept the longer onboarding path. Give the customer an exact owner name, type, and value; verify what DNS serves; and keep activation pending until the expected contract is observable.

They are also better when organizational policy requires the customer to retain DNS authority. Do not fight that constraint with clever automation. Build a clear handoff.

Platform-owned subdomains remain the weekly-shipping choice for the common case. They reduce coordination, but they do not excuse an untyped record API. Put the consumer decision in the tenant feature that knows why the record exists, validate protocol conflicts centrally, and let the provider adapter handle transport. That division is small enough to maintain alone and explicit enough to debug under pressure.

Further reading

Top comments (0)