Technical Article

Public IDs at the API Boundary, Internal IDs Inside the System

Exposing database IDs through an API seems harmless at first—until those identifiers become an immutable part of your public contract.

Imagine an internal user table using an auto-incrementing integer: id = 18472. Returning that value directly as /users/18472 creates tight coupling between your public API surface and your underlying persistence model. External clients now possess knowledge of your internal database mechanics that they should never have needed to know.

Two Identities for Two Contexts

A cleaner architecture distinguishes between two separate identities: The internal ID is optimized for relationships, joins, indexing, and storage performance within the database. The public ID is designed for URLs, API payloads, external events, and anything crossing the system boundary.

For example, a record might maintain id = 18472 internally while exposing public_id = "usr_01K4F8..." externally.

Inside the system boundaries, services continue using the representation best suited for storage efficiency and query execution. At the boundary, external clients interact solely with an opaque, structured identifier.

Decoupling Benefits

This architectural separation delivers practical advantages:

  • Encapsulated Persistence: Database schema details, table re-indexing, or storage engine migrations remain completely private.
  • Preventing Information Leakage: Sequential IDs reveal business metrics, customer growth rates, and daily order volumes to outside observers. Opaque public identifiers prevent enumeration attacks.
  • Resilient API Contracts: Internal database reorganizations or table sharding never alter external API contracts.
  • Developer Ergonomics: Typed prefixes (such as usr_ or ord_) provide instant context in logs, traces, and customer support workflows.
  • Uniform Conventions: Distinct underlying microservices and datastores can adhere to a consistent external identifier convention.

Enforcing Boundary Translations

The crucial discipline is ensuring this separation holds across application layers:

  1. Controllers and API gateways resolve incoming public IDs into internal IDs at the boundary.
  2. Domain logic, service layers, and repositories operate exclusively with internal identifiers.
  3. Outbound serializers convert internal IDs back into public IDs before returning responses to the caller.

In short, public IDs are part of your external product contract; internal IDs are an internal implementation detail. Separating these concerns from day one carries minimal cost, whereas untangling external consumers from your internal database IDs after the fact is extraordinarily complex.