The fastest way to learn a set of rules is to watch them break.
dedo’s repository ships two services with the same interface.
examples/todofollows every rule.examples/bad-todobreaks every rule once.
Both serve the same TodoService over Connect, gRPC and REST. Both use the same server code and the full runtime.
The only difference is the .proto.
This post walks through both. By the end you will have run a dedo service on every transport. You will also know what each lint finding protects you from.
Setup
You need Go 1.25 or later and the Buf CLI.
For the editor, install VS Code with the Buf extension (bufbuild.vscode-buf). Remove other Protobuf extensions.
git clone https://github.com/raveesh-me/dedo
cd dedo
make plugins # builds the lint plugin into dist/, onceCode language: Bash (bash)
The examples test the repository’s working tree. Each one vendors options.proto under third_party/ and loads the lint plugin from dist/.
Your own services use the published module and plugin instead. Post 3 covers that.
Todo Good
Start the good service:
cd examples/todo
go run .Code language: Bash (bash)
Create a to-do over REST, as a writer:
curl -H 'Authorization: Bearer writer' -H 'Content-Type: application/json' \
-d '{"title":"try it"}' localhost:8080/commerce/todo/v1/todosCode language: Bash (bash)
Read it back as a reader, and look at the headers:
curl -i -H 'Authorization: Bearer reader' localhost:8080/commerce/todo/v1/todos/1Code language: Bash (bash)
Three things to notice.
- Headers you did not write. The response carries
Cache-Control: private, max-age=…and anX-Flow-ID. The contract declared the cache policy. The runtime set the header. - Docs you did not write. Open http://localhost:8080/docs. The REST reference is generated from the same
.protoas OpenAPI 3.1. Every operation lists its scopes, error reasons and description. - Agent tools you did not write. Point an MCP client at
http://localhost:8080/mcpwithAuthorization: Bearer reader. You getget_todo,list_todosandcreate_todo, each with the declared title, description and hints. Creating a to-do with the reader token fails withpermission_denied, the same as REST.
That is the whole promise in one service.
One contract, four doors, one behaviour.
What the good contract declares
Here is CreateTodo from examples/todo:
// CreateTodo adds a to-do.
//
// Errors: TITLE_REQUIRED, TODO_ALREADY_EXISTS.
rpc CreateTodo(CreateTodoRequest) returns (CreateTodoResponse) {
option (google.api.http) = {
post: "/commerce/todo/v1/todos"
body: "todo"
response_body: "todo"
};
option (dedo.v1.method) = {
tool: {
title: "Create a to-do"
description: "Adds a to-do with a title, and optionally a budget and an assignee. Pass todo_id to choose its ID; otherwise the server picks one."
annotations: {
read_only_hint: false
destructive_hint: false
idempotent_hint: false
open_world_hint: false
}
}
scopes: "todo.todo.write"
errors: {reason: "TITLE_REQUIRED", code: INVALID_ARGUMENT}
errors: {reason: "TODO_ALREADY_EXISTS", code: ALREADY_EXISTS}
};
}Code language: Protocol Buffers (protobuf)
What makes it clean:
- The path carries a root and a version:
/commerce/todo/v1/. - The scope reads as app, resource, verb:
todo.todo.write. - Every error the handler returns is named.
- The tool text tells an agent what the RPC does and when to use it.
response_bodyunwraps the response, so REST callers get the bareTodo.
buf lint on this file reports nothing.
Todo Bad
Open examples/bad-todo/proto/raveesh_me/todo/v1/todo.proto in VS Code.
The Problems panel fills up. expected-lint.txt lists every finding by line:
14 ZALANDO_SERVICE_RULES_REQUIRED
21 DEDO_HTTP_PATH_VERSION
51 DEDO_READ_METHOD_NO_SIDE_EFFECTS
51 DEDO_TOOL_REQUIRED
65 ZALANDO_SCOPE_NAME_FORMAT
65 ZALANDO_CACHE_REQUIRES_NO_SIDE_EFFECTS
101 DEDO_HTTP_RESPONSE_BODY
156 ZALANDO_DEPRECATION_CONSISTENT
200 DEDO_NO_JSON_NAME
206 ZALANDO_MONEY_FIELD_TYPE
...Code language: plaintext (plaintext)
That is 24 findings: every ZALANDO and DEDO rule once, plus one Buf rule, one AIP rule and two house rules.
A list of rule IDs is easy to dismiss. So bad-todo goes further.
Its tests run the broken service and show what each mistake does in production.
Here are four of them.
A misnamed scope locks out writers
The bad contract declares CreateTodo like this:
scopes: "todo.write"Code language: Protocol Buffers (protobuf)
The writer’s token grants todo.todo.write. The names do not match.
The writer cannot write.
func TestMisnamedScopeLocksOutWriters(t *testing.T) {
e := newEnv(t)
_, err := e.client(app.WriterToken).CreateTodo(context.Background(),
&todov1.CreateTodoRequest{Todo: &todov1.Todo{Title: "x"}})
if connect.CodeOf(err) != connect.CodePermissionDenied {
t.Fatalf("err = %v, want permission_denied", err)
}
}Code language: Go (go)
ZALANDO_SCOPE_NAME_FORMAT requires <app>.<resource>.<read|write>. It catches the typo before merge.
Without it, you find out when your first customer cannot save anything.
An unversioned path moves the endpoint
The bad GetTodo binds to /{name=todos/*}. Every other RPC lives under /v1/.
// DEDO_HTTP_PATH_VERSION: GetTodo is served outside /v1/.
func TestUnversionedPath(t *testing.T) {
e := newEnv(t)
// /v1/todos/{id} exists for PATCH and DELETE only.
if code, _ := e.rest(t, http.MethodGet, "/v1/todos/a", app.ReaderToken, ""); code != http.StatusMethodNotAllowed {
t.Errorf("GET /v1/todos/a: %d, want 405", code)
}
code, body := e.rest(t, http.MethodGet, "/todos/a", app.ReaderToken, "")
if code != http.StatusNotFound || !strings.Contains(body, "TODO_NOT_FOUND") {
t.Errorf("/todos/a: %d %s, want the handler's TODO_NOT_FOUND", code, body)
}
}Code language: Go (go)
A client that reads /v1/todos/a gets a 405. The resource lives at a path no one would guess.
When v2 arrives, this RPC has nowhere to go.
Undeclared error reasons show up in logs
The bad contract makes two mistakes.
ListTodosdeclaresinvalid_page_tokenin lowercase. The handler returnsINVALID_PAGE_TOKEN.DeleteTododeclares no errors at all.
The service still answers. The runtime notices:
logs := e.log.String()
// "undeclared error reason" procedure=/raveesh_me.todo.v1.TodoService/ListTodos
// "undeclared error reason" procedure=/raveesh_me.todo.v1.TodoService/DeleteTodoCode language: Go (go)
Clients branch on error reasons.
A reason the contract does not declare is one no client knows to handle.
dedo’s interceptor logs every one with its flow ID, so you can find it.
A custom JSON name renames the field for everyone
The bad Todo sets json_name = "todoTitle" on title:
// DEDO_NO_JSON_NAME: responses name the field "todoTitle" instead of
// the protojson default "title".
func TestCustomJSONName(t *testing.T) {
b, err := protojson.Marshal(&todov1.Todo{Title: "x"})
if err != nil {
t.Fatal(err)
}
if got := string(b); !strings.Contains(got, `"todoTitle"`) {
t.Errorf("JSON = %s, want todoTitle", got)
}
}Code language: Go (go)
The schema says title. Every JSON client sees todoTitle.
Generated clients and hand-written ones now disagree about the same field.
The rest of the list
The other findings follow the same pattern: a small mistake that costs something real.
| Finding | What it costs |
|---|---|
ZALANDO_SERVICE_RULES_REQUIRED | No owner team. The docs publish no owner, and no one knows whom to page. |
DEDO_READ_METHOD_NO_SIDE_EFFECTS | ListTodos is unmarked. Clients and proxies cannot treat it as a safe retry. |
ZALANDO_CACHE_REQUIRES_NO_SIDE_EFFECTS | CreateTodo carries a public cache policy. A cache could replay a write. |
DEDO_HTTP_RESPONSE_BODY | UpdateTodo returns the wrapper. REST callers get {"todo": {...}} instead of the to-do. |
ZALANDO_DEPRECATION_CONSISTENT | RemoveTodo names DeleteTodos as its successor. That RPC does not exist, so clients are told to migrate to nothing. |
ZALANDO_MONEY_FIELD_TYPE | budget_amount is a double. Which currency? How much rounding? |
DEDO_TOOL_REQUIRED | No tool description. An agent has to guess what the RPC does. |
The pattern
Both services share the same handlers, the same runtime and the same wiring.
Every difference lives in the
.proto, and every difference is visible in the editor before you commit.
That is the habit dedo builds:
- Write the contract first.
- Let the linter read it with you.
- Fix what it finds while the fix is one line.
Try it on a deployment
examples/todo also runs as a small deployment. Everything builds in containers. Podman, kind and kubectl are the only host tools.
cd examples/todo
make up # a kind cluster with the service and a live Vue UI on :8090
make e2e # Playwright: an agent adds a to-do over MCP; the open UI shows it without a reload
make downCode language: Bash (bash)

The end-to-end test is my favourite demo.
An AI agent calls create_todo over MCP. The browser is subscribed to the WatchTodos server stream. It shows the new to-do at once.
One server, one interceptor chain, two very different callers.
Next
You have seen what a dedo service looks like when it is right and when it is wrong.
The next post adds dedo to a service that already exists, one layer at a time.
The dedo series
- Part 1: Define Once, Deploy Everywhere: why one proto can carry the contract and its rules.
- Part 2: Getting Started with Todo Good and Todo Bad (this post): 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