
API Style Guide Advisory Group: Vijaya Vangapandu, Devin Thomson and Greg Giacovelli
In Part 1 of this series, we explored the challenges TinderⓇ faces in building scalable APIs, focusing on URI design, HTTP methods, request headers and request/response body standards.
But good API design isn’t just about endpoints. It also depends on the schemas that define the data moving through them.
At Tinder’s scale, hundreds of services, clients, and event pipelines depend on shared data models. For years, those models evolved at different paces in different places: JSON contracts in some APIs, Protobuf definitions scattered across repositories, and ad-hoc solutions. Over time, that fragmentation made it hard to answer simple questions: Where does this schema live? Which version is correct? And what happens if someone changes it?
This post focuses on how we tackled that complex problem. Here, we walk through how Tinder standardized Protobuf as the single source of truth across REST, gRPC, and our Event Bus (Tinders’ event processing pipeline), and how we built the governance and tooling needed to make those schemas reliable at scale.
Before we standardized, Protobuf at Tinder was fragmented across three worlds: TinderApp/proto for client-server contracts, dozens of tinder backend repos, and improvised definitions for our Event Bus and internal services. No one was quite sure where a given schema should live or which version to rely on, especially for Node.js services and events/messages/models that didn’t map cleanly to a single service repo.
On top of that, the JavaScript toolchain behind TinderApp/proto was effectively frozen on google-protobuf and ts-protoc-gen, which meant we couldn’t even use optional in many places. Different teams copied patterns from whatever examples they found: inconsistent package names, mixed casing styles, and diverging rules for field presence. The result was exactly what you’d expect at our size: brittle clients, defensive parsing code, and production bugs that came down to “this field changed type” or “this enum grew a new value.”
Before Protobuf became the contract of record, we tried to lean on OpenAPI/Swagger for REST contracts. In practice, our implementation was incomplete and sometimes inverted defaults, which meant repos claiming “we use OpenAPI” behaved differently from the actual spec and confused everyone who trusted the docs.
We also experimented with “Wrapper Response” style envelopes to normalize responses at the JSON layer, but over time that envelope became a dumping ground rather than a clear contract. It didn’t address the deeper problem: we still lacked a consistent schema that worked across REST, gRPC, and Event Bus.
On the Protobuf side, we let each tinder-* repo publish its own protos independently. That seemed flexible, but it made discoverability and governance nearly impossible with dozens of CI pipelines, each with slightly different rules, and no easy way to prevent breaking changes.
We started by writing down the pain in an RFC: where should internal protos live, where should Event Bus schemas live, and how do we stop accidentally re‑solving these questions for every new service? That RFC proposed a single home, tinder-proto, with clear separation between external (client‑facing) and internal (backend & Event Bus) protos, and explicit artifact naming and package conventions for each.
In parallel, the API Standardization WG scoped the broader API story: when do we use REST vs gRPC, how do we map REST responses to Protobuf, and how do we use schema‑driven generation for documentation and SDKs. The Protobuf style guide became the schema layer of that strategy, aligned with the REST Style Guide, so that JSON and Protobuf told the same story.
We treated Protobuf governance the same way we treated the REST Style Guide: as a product, not a wiki page. The API Standardization Working Group was chartered to close gaps between our current APIs and industry standards, with a specific mandate around schema standardization and Protobuf usage.
From there, we did three big things:
tinder-api-proto for client–server, tinder-proto for internal) so there was exactly one place to define and discover API modelsWhat were the technical challenges in building the solution?
A few of the harder parts:
protoc internals.The future of Tinder! But more concretely, we created:
(tinder-api-proto for public APIs, evolving toward tinder-proto for both external and internal models) with consistent artifact naming (<project>-proto-models, <project>-api-proto).runAffectedBufLint and runAffectedBreakingChange so regressions fail fast.At a high level, our Protobuf standards rest on three pillars:
Under those pillars, the style guide defines the details: proto3 syntax everywhere, hierarchical message design, strict field lifecycle rules, and consistent naming across filenames, packages, messages, fields, and enums.
All files use proto3, and we strongly prefer incremental evolution over “V2” style message forks. You don’t introduce TextElementV2; you add a new field to TextElement, mark old fields deprecated = true with clear comments, and then reserve their field numbers when you finally remove them.
// ✅ Correct approach
message TextElement {
string body = 1;
string hex_color = 2; // Added incrementally
}
// ❌ Avoid this
message TextElementV2 {
string body = 1;
string hex_color = 2;
}
option java_multiple_files = true;
option java_outer_classname = "ConversationsApiV1";
option java_package = "com.tinder.api.conversations.v1";

Every enum MUST include an UNSPECIFIED value at position 0
enum ConversationType {
CONVERSATION_TYPE_UNSPECIFIED = 0; // Required default
CONVERSATION_TYPE_DM = 1;
CONVERSATION_TYPE_GROUP = 2;
}
We use optional for scalar presence, avoid required, and only use wrapper types in legacy contexts where they’re already established.
Errors are modeled as dedicated messages (e.g., ErrorProto) aligned with REST error guidance, instead of "oneof data-or-error” wrappers in the main response type.
We lean on well‑known types like Duration where they add value, but prefer ISO‑8601 strings over google.protobuf.Timestamp to keep the ecosystem interoperable.
On every change, CI:
# BufLint Job:
./gradlew clean runAffectedBufLint
# Breaking Change Detection:
./gradlew clean runAffectedBreakingChange
# Unit Testing:
./gradlew clean runAffectedUnitTests
The tangible effects have shown up in three places:
In practical terms, this means fewer “mystery crashes” on old app versions, simpler rollbacks, and more predictable upgrades when we add or deprecate fields.
Today, if you’re adding a new API:
Client teams no longer have to reverse‑engineer response shapes by sniffing traffic; they can rely on generated models and descriptors. Backend teams don’t have to remember ten different ways we modeled “user” or “photo” in different services. And the API Standardization WG can focus on genuinely hard design questions instead of re‑litigating casing or enum defaults on every PR.
Like most engineering stories, this one includes a little archaeology as things evolve. We’re still living with some pre‑standards history:
optional and snake_case.tinder-proto RFC calls out that keeping a hard separation between internal and external protos can mean some model duplication (e.g., internal vs external User), which isn’t fully resolved yet.And culturally, we’re still migrating teams off of older patterns (like V2Response wrappers or JSON‑only models) toward Protobuf‑first contracts everywhere.
The roadmap is less about inventing new rules and more about finishing the migration and tightening the loop:
tinder-proto, under clear internal/external boundaries.The end state is simple to describe, even if it took us years to get here: every structured payload at Tinder, whether it flows through REST, gRPC, or Event Bus, moves through a small set of well‑governed Protobuf contracts, growing safely over time. The goal is not perfection, but predictability: a system where engineers can work, evolve, and build future systems knowing that foundation is secure.