An API today has more than one front door.
- There is the RPC door, for services that talk to each other.
- There is the REST door, for partners, browsers and curl.
- There is the documentation, which is a door of its own.
- And now there is a fourth: MCP tools, for AI agents.
Each door gets built by someone. Often by a different someone.
Each one drifts.
The REST path says /v1/products. The docs say /products. The gRPC service checks a scope the REST handler forgot. The agent tool describes a field that was renamed last sprint.
None of this is anyone’s fault. It is what happens when one truth gets written four times.
dedo is my answer. You write one .proto. You get all four doors, with the same auth, the same docs and the same behaviour on each.
This is the first post in a series about it.
The motivation
I lead engineering teams that build commerce platforms. Commerce is a domain of many services and many callers.
Every service has an owner, an audience, a set of scopes, a cache policy, a list of errors and, eventually, a deprecation.
Where do those facts live?
In most codebases, they live in five places at once.
- Scopes live in a middleware config.
- Cache headers live in a handler.
- Error codes live in a wiki page.
- Deprecation lives in a Slack announcement.
- Ownership lives in someone’s head.
A reviewer cannot see any of it in the pull request that changes the API.
So I asked a simple question.
What if the schema carried all of it?
The .proto file already describes the shape of every request and response. Protobuf supports custom options. An option can say “this RPC needs the pim.product.read scope”. Another can say “cache this for 30 seconds, privately”. Another can say “this RPC returns PRODUCT_NOT_FOUND with code NOT_FOUND“.
Once the schema says it, three things can read it:
- A linter, before merge.
- A runtime, at startup and on every call.
- Generators, which turn it into docs, interceptor config and agent tools.

That is the whole idea. Define once. Deploy everywhere.
The name comes from home. My daughter is one. Whenever she wants something, she says “de do”, Hindi for “give it to me”. She says it so often that the family now calls her Dedo.
The project works the same way. Hand it one .proto. It gives back RPC, REST, docs and MCP tools.
What you get from one file
From one .proto | How |
|---|---|
| RPC over Connect and gRPC | connect-go v2 |
| REST with resource-style URLs | google.api.http annotations, served by Vanguard |
| REST reference docs | protoc-gen-dedo-openapi writes OpenAPI 3.1 |
| MCP tools for agents | protoc-gen-dedo-mcp generates each service’s tools |
| Auth, cache and deprecation headers, flow IDs, error audit | the policy runtime, read from the options |
Here is one RPC with every declaration dedo asks for:
// GetProduct returns one product.
rpc GetProduct(GetProductRequest) returns (GetProductResponse) {
option idempotency_level = NO_SIDE_EFFECTS;
option (google.api.http) = {
get: "/commerce/pim/v1/{name=products/*}"
response_body: "product"
};
option (dedo.v1.method) = {
scopes: "pim.product.read"
cache: {visibility: VISIBILITY_PRIVATE, max_age: {seconds: 30}}
errors: {reason: "PRODUCT_NOT_FOUND", code: NOT_FOUND}
tool: {
title: "Get a product"
description: "Returns one product by its resource name, such as products/123."
annotations: {read_only_hint: true, open_world_hint: false}
}
};
}Code language: Protocol Buffers (protobuf)
Read it top to bottom.
It is safe to retry. It lives at a versioned REST path. It needs one scope. It can be cached privately for 30 seconds. It fails in one declared way. An agent sees it as a read-only tool called get_product.
A reviewer reads all of that in one diff.
The influences
dedo did not invent its rules. It borrows them from two bodies of work that have thought about APIs for years.
Google’s AIPs
The API Improvement Proposals are Google’s design rules for resource-oriented APIs.
- They were written for protobuf.
- They define resources with names like
shelves/1/books/2. - They define standard methods:
Get,List,Create,Update,Delete. - They define how those methods map to HTTP, how pagination works, how partial updates use a
FieldMask, and how errors carry a machine-readable reason. - Google ships a linter for them, api-linter.
The AIPs answer one question well: what should the contract look like?
Zalando’s RESTful API Guidelines
Zalando’s guidelines come from a large company running hundreds of HTTP services.
They cover what the AIPs leave out:
- Who is the audience of this API?
- Which team owns it?
- How are security scopes named?
- When is a response cacheable?
- How do you tell a client an endpoint is going away?
- How do you trace one request across services?
Zalando answers a second question well: how should the API behave at the edge, and who is accountable for it?
Where they disagree
They disagree often.
| Topic | Zalando | AIP (dedo follows) |
|---|---|---|
| JSON field names | snake_case | lowerCamelCase |
| API version | Out of the URL | In the package and the path |
| Errors | Problem JSON | google.rpc.Status with ErrorInfo |
| Pagination | Links object | page_token and next_page_token |
dedo picks a side every time, and writes the choice down.
The order is fixed:
- DEDO rules win. They are what make one proto serve every transport.
- AIP rules come next. The contract is a protobuf API, and the AIPs were written for that.
- ZALANDO rules come last. dedo adopts the ones a proto can express.
Every conflict has a row in a table. Every skipped rule has a reason. When someone asks “why is the version in the path?”, the answer is a link.
Lint as architecture
Here is the part that makes it work.
A standard has to live somewhere people cannot miss it.
The best place is the tool that already runs on every keystroke and every pull request:
That tool is the linter.
dedo ships a Buf check plugin, buf-plugin-dedo-lint. It runs four classes of rules under one buf lint:
| Prefix | Source |
|---|---|
AIP_* | Google’s AIPs, through api-linter |
ZALANDO_* | The Zalando guidelines dedo adopts |
DEDO_* | dedo’s own rules |
HOUSE_* | Your organisation’s rules, in rules.yml |
Your editor and CI run the same command.
In the editor, the findings appear as you type. Each one ends with a link to a page that explains the rule, why it exists, a failing example and a passing one. The link is pinned to the plugin’s release, so the text always matches the rule that fired.
In CI, the same findings block the merge.
This changes the conversation in review.
A reviewer no longer has to remember that read methods must be side-effect free, or that money fields use google.type.Money. The linter remembers. The reviewer spends their attention on the design.
And when someone needs to break a rule, they say so in the file:
// buf:lint:ignore ZALANDO_MONEY_FIELD_TYPE
// amount counts loyalty points; it carries no currency.
int64 amount = 4;Code language: Protocol Buffers (protobuf)
The suppression sits next to the code. It carries a reason. A reviewer sees it.
That is a decision on the record.
Deterministic, and fast
Two properties matter for a linter that guards architecture.
It must be deterministic. The editor, a laptop and CI must all report the same findings. dedo pins the lint plugin in go.mod, builds it from that pin, and refers to it by path in buf.yaml. Every machine runs the same version.
It must be fast. Buf starts the plugin about a dozen times per lint, and the editor reruns lint as you type. As a native binary, one lint of the todo example takes about 0.3 seconds. As WebAssembly, about 5. The native binary is the default. The Wasm build stays on the BSR for machines that cannot download binaries.
The runtime agrees with the linter
Lint catches mistakes before merge. The runtime refuses to run without the declarations at all.
At startup, policy.Build reads every option from the compiled descriptors. If an RPC declares no scope, the service does not start. An open endpoint is a failure mode dedo closes twice.
On every call, one interceptor checks the caller’s scopes, sets Cache-Control, Deprecation and Sunset, and logs any error reason the RPC returned without declaring.
Every transport reaches the same connect-go connect.Server. Connect, gRPC, REST and MCP all pass through the same interceptor chain. Your handlers hold business logic only.
What comes next
The next four posts put dedo to work: a first service, an existing one, your own rules, and multi-tenant auth.
dedo is open source under Apache 2.0 at github.com/raveesh-me/dedo.
Start with one .proto. See what it gives you.
The dedo series
- Part 1: Define Once, Deploy Everywhere (this post): why one proto can carry the contract and its rules.
- Part 2: Getting Started with Todo Good and Todo Bad: two services, one interface, and what each lint rule protects you from.
- Part 3: Adding dedo to an Existing Service: one layer at a time, with an audit mode before you enforce.
- Part 4: House Rules, Your Conventions as Lint: your organisation’s conventions as CEL checks, shared from one repository.
- Part 5: Multi-Tenant SaaS Auth: the tenant boundary, declared in the proto and enforced on every transport.

Leave a Reply