·

[dedo] 2/5: Getting Started: Todo Good and Todo Bad

Raveesh Agarwal

·

·

5–8 minutes
todo.proto marked lint clean beside bad-todo.proto with red squiggles and 24 findings.

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/todo follows every rule.
  • examples/bad-todo breaks 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.

  1. Headers you did not write. The response carries Cache-Control: private, max-age=… and an X-Flow-ID. The contract declared the cache policy. The runtime set the header.
  2. Docs you did not write. Open http://localhost:8080/docs. The REST reference is generated from the same .proto as OpenAPI 3.1. Every operation lists its scopes, error reasons and description.
  3. Agent tools you did not write. Point an MCP client at http://localhost:8080/mcp with Authorization: Bearer reader. You get get_todo, list_todos and create_todo, each with the declared title, description and hints. Creating a to-do with the reader token fails with permission_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_body unwraps the response, so REST callers get the bare Todo.

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.

  • ListTodos declares invalid_page_token in lowercase. The handler returns INVALID_PAGE_TOKEN.
  • DeleteTodo declares 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.

FindingWhat it costs
ZALANDO_SERVICE_RULES_REQUIREDNo owner team. The docs publish no owner, and no one knows whom to page.
DEDO_READ_METHOD_NO_SIDE_EFFECTSListTodos is unmarked. Clients and proxies cannot treat it as a safe retry.
ZALANDO_CACHE_REQUIRES_NO_SIDE_EFFECTSCreateTodo carries a public cache policy. A cache could replay a write.
DEDO_HTTP_RESPONSE_BODYUpdateTodo returns the wrapper. REST callers get {"todo": {...}} instead of the to-do.
ZALANDO_DEPRECATION_CONSISTENTRemoveTodo names DeleteTodos as its successor. That RPC does not exist, so clients are told to migrate to nothing.
ZALANDO_MONEY_FIELD_TYPEbudget_amount is a double. Which currency? How much rounding?
DEDO_TOOL_REQUIREDNo 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:

  1. Write the contract first.
  2. Let the linter read it with you.
  3. 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)
An AI agent calls create_todo over MCP on the same connect.Server; the open Vue UI receives the change through the WatchTodos stream without a reload.

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

Raveesh Agarwal is a full stack builder writing about Kubernetes, agentic AI and distributed systems. He runs a four-node Kubernetes cluster on his desk. More about me · Follow via RSS

Comments

2 responses to “[dedo] 2/5: Getting Started: Todo Good and Todo Bad”

  1. […] Part 2: Getting Started with Todo Good and Todo Bad: two services, one interface, and what each lint rule protects you from. […]

  2. […] Part 2: Getting Started with Todo Good and Todo Bad: two services, one interface, and what each lint rule protects you from. […]

Leave a Reply

Your email address will not be published. Required fields are marked *