Recently, at work, I faced the challenge of exposing internal functionality of our system through a Public API. I really liked the approach we are using, which is why I decided to write this article.
It is nothing new or super innovative; in fact, Stripe's public API works this way.
To truly understand how this works "under the hood", I decided to create an example repository from scratch implementing a versioning system. The result is this repository: mapeveri/public-api-versioning.
When you expose your system to the world, versioning is not optional. Implementing a solid strategy offers:
if (version < OLD) statements, you encapsulate legacy logic in isolated transformation classes. This keeps your core business logic clean, testable, and focused on the present.The core idea is not to keep multiple copies of your code (a v1 controller, a v2 one, etc.); that would be unmanageable. Instead, a better maintainable approach is to have a single version of the code (the most recent one) and apply transformations to adapt that version to what the old client expects.
first_name + last_name -> name), or rename them back so the client receives exactly what they expect.To handle these transformations cleanly, we define each version in its own separate file. This keeps the logic isolated and easy to navigate.
For example, if in version `2025-01-01` we had `name`, but the currently (`2026-01-01`) we split that into `first_name` and `last_name`, we would create a file like app/controllers/api/v1/versions/version20250101.rb:
# Definition of the old version
class Version20250101 < Version
timestamp "2025-01-01"
# Input Transformation (Request):
# Converts what the user sends ("name") into what my current controller expects ("first_name", "last_name")
payload do |t|
t.split_field :name, into: [:first_name, :last_name]
end
# Output Transformation (Response):
# Converts what my controller returns ("first_name", "last_name") into what the old user expects ("name")
response do |t|
t.combine_fields :first_name, :last_name, into: :name
end
end
With this pattern, your business logic is always clean and updated, and the "technical debt" of supporting old versions is encapsulated in these transformation classes.
A common question is: "Can I combine this with URL versioning (v1, v2)?". The answer is yes.
You can use Major Versions (v1, v2) for paradigm shifts (e.g., moving from REST to GraphQL, or a complete rewrite of your resource IDs) and Date Versions for the continuous evolution within that major version.
Stripe, for example, has kept `v1` in their URL for over a decade. They haven't needed a `v2` because their transformation system handles evolution so well. However, having that `v1` namespace acts as a safety valve: if they ever need to break the world, they have `v2` reserved.
Supporting backward compatibility doesn't mean supporting everything forever. You need a strategy to communicate end-of-life.
This pattern allows for granular deprecation:
Warning or Sunset. This alerts consumers monitoring their logs that they are using a dying version without breaking their automation.410 Gone, providing a clear signal to the client.This observability is key: instead of silently failing, your API actively communicates its lifecycle to your consumers.
Implementing a public API requires a mindset shift: you go from thinking only about functionality to thinking about contracts and stability. Versioning via transformations is a powerful strategy because it decouples your software's evolution from the public interface you've promised to maintain.