Resource Names for Enterprise TypeScript Monorepos
Multi-service TypeScript applications have a naming problem. Every service invents its own way to reference resources. One team builds paths with template literals, another passes loose IDs through function arguments, a third hardcodes service prefixes as magic strings. It works until someone joins the team and asks “how do I construct a resource path here?” and gets a different answer depending on which file they are looking at.
As a freelance team lead software engineer, I ran into this problem in a monorepo with ten services. The inconsistency was subtle at first, but it compounded. A rename in one service broke string interpolation in another. A new feature needed to identify resources across service boundaries, and there was no standard format to do it. The fix was not more code reviews. It was a convention.
Resource names, as described in the Google API Design Guide, offer exactly that. The idea: every resource in your system has a hierarchical name that follows a consistent format. Applied to a TypeScript monorepo, this convention replaces ad-hoc string building with a shared vocabulary that every service understands.
The Problem with String Interpolation
Consider a multi-tenant application with this hierarchy:
Organization → Space → Project → DocumentIn practice, resource paths tend to be assembled differently depending on who wrote the code:
// developer A: template literals
const path = `organizations/${orgId}/spaces/${spaceId}`;
// developer B: concatenation
const parent = "organizations/" + orgId;
// developer C: hardcoded service prefix
const resourceType = "billing.acmeapis.com/Invoice";
// developer D: manual parsing
const typeLabel = resourceType.split("/").pop();Each of these is a one-off. There is no shared encoding logic, no type safety on service names, and no consistent way to go from a path back to its constituent IDs. When the format changes, you are grepping through the entire codebase.
The deeper problem is that these strings carry meaning, but that meaning is implicit. A path like organizations/org_123/spaces/space_456 encodes a parent-child relationship, a hierarchy, and a set of IDs. But the code treats it as a dumb string. There is no structured way to extract the organization ID, no way to validate the format, and no way to know which service owns the resource.
Resource Names as a Convention
A resource name is a hierarchical path that identifies a specific resource. The format alternates between collection names and IDs:
organizations/org_123/spaces/space_456/projects/proj_789It reads like a breadcrumb trail: organization org_123 contains space space_456, which contains project proj_789. This convention comes from Google, where it has been the standard for all Cloud APIs since 2014. Publishers, books, users, campaigns: everything follows the same collection/id pattern.
The key insight is that resource names should not be assembled with string interpolation. They should be encoded from structured data and decoded back into structured data:
// encode: structured data → path string
const name = encodeResourceId({
organizations: "org_123",
spaces: "space_456",
});
// → "organizations/org_123/spaces/space_456"
// decode: path string → structured data
const ids = decodeResourceId(name, ["organizations", "spaces"]);
// → { organizations: "org_123", spaces: "space_456" }No template literals, no manual splitting. The encoding logic lives in one place and every service uses the same functions. When you need to extract the organization ID from a path, you call decodeResourceId instead of writing path.split("/")[1].
Full Resource Names for Cross-Service Identification
Within a single service, relative resource names work fine. A billing service can pass around organizations/org_123/invoices/inv_456 and every function in that service knows what it means.
But what happens when a resource needs to be identified across services? Imagine a comment system that can attach comments to any resource in any service: invoices in billing, products in inventory, users in identity. The comment service receives a resource path, but it has no idea which service owns it.
This is where full resource names come in. The format prepends //service.domain/ to the relative path:
//billing.acmeapis.com/organizations/org_123/invoices/inv_456Now the name is self-describing. It tells you both which service owns the resource and which resource it is. A comment service receiving this name can route back to the billing service without any out-of-band context.
The encoding function handles this with an optional service parameter:
const fullName = encodeResourceId(
{ organizations: "org_123", invoices: "inv_456" },
{ service: "billing" },
);
// → "//billing.acmeapis.com/organizations/org_123/invoices/inv_456"Decoding is transparent. It strips the service prefix automatically:
const ids = decodeResourceId(fullName, ["organizations", "invoices"]);
// → { organizations: "org_123", invoices: "inv_456" }
const service = getResourceService(fullName);
// → "billing"Callers do not need to know whether a name is relative or full. The decode function handles both formats and returns the same shape. This is important because it means existing code that works with relative names does not break when full names start flowing through the system.
Resource Types: What Kind of Resource
Resource names identify which resource. Resource types identify what kind of resource. The format is service.domain/TypeName:
billing.acmeapis.com/Invoice
inventory.acmeapis.com/ProductThis distinction matters in cross-cutting features. A comment system stores two fields per comment: the resourceType (what kind of thing the comment is attached to) and the resource (which specific thing). The resource type lets you filter all comments on invoices across the entire system. The resource name lets you find comments on one specific invoice.
Without a convention, these resource type strings end up hardcoded everywhere:
// scattered across the codebase
const resourceType = "billing.acmeapis.com/Invoice";
const label = resourceType.split("/").pop(); // "Invoice"With encode and decode functions for resource types, the service domain becomes a centralized concern:
const type = encodeResourceType("billing", "Invoice");
// → "billing.acmeapis.com/Invoice"
const { service, type } = decodeResourceType("billing.acmeapis.com/Invoice");
// → { service: "billing", type: "Invoice" }No more .split("/").pop() to extract the type label. No more hardcoded domain strings scattered across files.
Typed Service Names
You may have noticed that the functions accept "billing" rather than the full domain "billing.acmeapis.com". This is intentional. The domain suffix is an infrastructure concern. It is the same for every service and it should not leak into application code.
A shared constant holds the domain, and a union type constrains the valid service names:
type ServiceName = "billing" | "identity" | "inventory" | "notifications";Now encodeResourceId({ ... }, { service: "billin" }) is a compile-time error. The domain suffix is appended internally by the utility, so if the domain ever changes, you update one constant instead of hundreds of files.
This is a small thing, but it eliminates an entire category of bugs: typos in service domain strings that only surface at runtime when a cross-service lookup fails.
Domain Constants Instead of Magic Strings
With encodeResourceType available, resource type constants in each domain are constructed rather than hardcoded:
// domain-specific constants file
import { encodeResourceType } from "@acme/shared/utils/resource-id";
export const INVOICE_RESOURCE_TYPE = encodeResourceType("billing", "Invoice");
export const PAYMENT_RESOURCE_TYPE = encodeResourceType("billing", "Payment");Consumers import these constants instead of writing "billing.acmeapis.com/Invoice" inline. The service name is typed, the domain suffix is centralized, and the format is guaranteed by the utility.
This also makes it easy to find all resource types in a service: search for encodeResourceType("billing" and you get the complete list. Try doing that when resource types are hardcoded strings.
Organizing the Utility in a Monorepo
In a monorepo with a shared utility package, this naturally splits into a small number of focused files:
packages/shared/src/utils/resource-id/
├── constants.ts → service domain, ServiceName type
├── resource-id.ts → encodeResourceId, decodeResourceId, getResourceService
├── resource-type.ts → encodeResourceType, decodeResourceType
└── index.ts → barrel exportThe shared package owns the format: how resource names and resource types are encoded. Each domain owns the vocabulary: which resource types exist for its service. Neither needs to know about the other’s internals.
Every service in the monorepo imports from the same entry point. When a new service is added, you add its name to the ServiceName union and start defining resource type constants. The encoding and decoding logic is already there.
Resource names are a small convention with outsized returns. They replace string interpolation with structured encoding, give cross-service references a standard format, and make the service domain a single-source-of-truth constant rather than a string copied into dozens of files. In a TypeScript monorepo where multiple services share code, that consistency compounds. New developers see one pattern instead of ten. Refactors change one utility instead of grep-and-replace. And cross-service features like comments, audit logs, or permissions get a universal way to identify any resource in the system.
The convention costs almost nothing to adopt. The payoff is a codebase where resource identity is a solved problem.