Protobuf finally has a language server. What contract-first gRPC in .NET gains
Buf shipped a production-grade LSP for Protobuf. Here is what it changes for a .NET team that already generates C# with Grpc.Tools, where it fits in your build, and where the marketing runs ahead of the reality.
Jean-Pierre Broeders
Freelance .NET Developer
Buf shipped something this week that Protobuf has been missing for as long as I have worked with it: a real language server. It climbed the Hacker News front page, and the comments split into the two reactions you would expect. Half say "finally." The other half say "wait, this didn't already exist?" Both are fair.
Protobuf is everywhere. gRPC carries a large share of internal traffic at companies that will never blog about it. And yet the .proto file, the single source every one of those services is generated from, has been edited for years with a syntax-highlighting plugin and a lot of guessing. You renamed a message and hoped. You bumped a field number and found out at code review, or worse, at runtime.
I write .NET for a living, mostly backend services, and a good chunk of those speak gRPC. So instead of another "isn't this neat" post, let me walk through what the language server actually changes for a .NET team, where it slots into a build that already leans on Grpc.Tools, and where I think the announcement oversells itself.
The .proto file was the blind spot
Here is the thing that never sat right with me. In a .NET gRPC project, the C# is generated. You add a Protobuf item to your .csproj, Grpc.Tools runs the compiler at build time, and out comes a stack of C# with full IntelliSense. Rename a generated Order class in your editor and Roslyn walks the whole solution for you.
But the file that produced all of it got none of that. The .proto was a second-class citizen in its own repo. No go-to-definition when you imported another file. No warning when you typed a field name that did not exist on the message you referenced. No hint that field number 3 was already taken three lines up. The compiler would catch it eventually, but "eventually" meant a failed build with a line number, not a red squiggle while you type.
That gap gets wider the bigger your API surface grows. A five-message toy service is fine to edit blind. A shared schema with forty messages, nested imports, and three teams touching it is not. That is exactly where most gRPC pain lives, and it is exactly where the tooling was weakest.
What the language server actually does
Buf's LSP speaks the standard Language Server Protocol, so it plugs into VS Code, Neovim, and anything else that talks LSP. The feature list is what you would want from any decent language server, applied to Protobuf for the first time in a form that holds up:
- Go to definition. Ctrl-click a message type or an imported symbol and land on its declaration, across files.
- Code completion. Start typing a field type and get the messages, enums, and well-known types that are actually in scope.
- Find references. See every place a message or enum is used before you rename it.
- Semantics-aware highlighting. Not regex coloring. The editor knows a token is a type versus a field versus a keyword.
- Diagnostics as you type. Duplicate field numbers, unknown types, and other structural mistakes surface immediately instead of at build time.
Buf is also open about what is still on the roadmap: automatic import suggestions, completion for custom options, and Protovalidate integration with CEL syntax highlighting. That last one matters if you validate on the schema rather than in hand-written C#, and I will come back to why that is a good idea.
The compiler underneath is the real story
The features are nice. The reason they are trustworthy is the engine Buf built them on, and this is the part I would not skip.
Most Protobuf tooling has historically leaned on protoc and the FileDescriptorProto model it produces. That model is built for code generation, not for editors. It throws away the precise source positions an editor needs to underline the right three characters. Buf instead uses their own protocompile frontend with a custom AST, and they describe it as a "query-driven frontend which enables incremental compilation and much better diagnostics."
Query-driven and incremental is the phrase that matters. It means the server can recompute just the part of the schema you touched instead of reparsing the world on every keystroke. That is the difference between a language server that feels instant and one you turn off after a day because it lags. If you have ever fought a sluggish plugin on a large repo, you know which side of that line decides whether a tool survives in your setup.
Setting it up
The install is boring, which is a compliment.
In VS Code you install the Buf extension from the marketplace. It detects the Buf CLI on your machine and starts the server. In Neovim you install the Buf CLI and wire it up through nvim-lspconfig, or point your config straight at the command:
buf lsp serve
Any other LSP-capable editor works the same way: register a server whose command is buf lsp serve. The server reads your module layout from buf.yaml, so it resolves imports the same way your builds and CI do. That is the point. One source of truth for where files live and what the rules are:
version: v2
modules:
- path: proto
lint:
use:
- STANDARD
breaking:
use:
- FILE
Where this fits in a .NET build
Now the question a .NET reader is actually asking: does this replace Grpc.Tools? No. And that distinction is the whole point.
The language server and the linter work on the schema. They help you write correct .proto and keep it correct. Your C# still comes from Grpc.Tools at build time, driven by the same Protobuf items you already have:
<ItemGroup>
<PackageReference Include="Grpc.AspNetCore" Version="2.71.0" />
</ItemGroup>
<ItemGroup>
<Protobuf Include="proto/acme/orders/v1/order.proto" GrpcServices="Server" />
</ItemGroup>
You can generate C# through buf generate instead, and Buf hosts a protocolbuffers/csharp plugin on their registry for the message types. But the C# gRPC service stubs, the base classes you actually inherit from, have never had a first-class home in Buf's public remote plugin catalog the way the Go and Java stubs do. For most .NET teams that means the sane split is: keep MSBuild and Grpc.Tools for codegen, because it is already wired into your build, your Rider and VS tooling, and your NuGet story. Adopt the LSP, buf lint, and buf breaking as the authoring and governance layer on top.
Here is the kind of schema that layer keeps honest:
syntax = "proto3";
package acme.orders.v1;
import "google/protobuf/timestamp.proto";
service OrderService {
rpc GetOrder(GetOrderRequest) returns (Order);
rpc ListOrders(ListOrdersRequest) returns (stream Order);
}
message GetOrderRequest {
string order_id = 1;
}
message Order {
string id = 1;
string customer_id = 2;
OrderStatus status = 3;
google.protobuf.Timestamp created_at = 4;
}
enum OrderStatus {
ORDER_STATUS_UNSPECIFIED = 0;
ORDER_STATUS_PENDING = 1;
ORDER_STATUS_PAID = 2;
ORDER_STATUS_CANCELLED = 3;
}
And the C# you write against it stays small, because the contract does the heavy lifting:
public sealed class OrderServiceImpl : OrderService.OrderServiceBase
{
private readonly IOrderStore _store;
public OrderServiceImpl(IOrderStore store) => _store = store;
public override async Task<Order> GetOrder(
GetOrderRequest request,
ServerCallContext context)
{
var order = await _store.FindAsync(request.OrderId, context.CancellationToken);
if (order is null)
{
throw new RpcException(
new Status(StatusCode.NotFound, $"order {request.OrderId} not found"));
}
return order;
}
}
Lint and breaking-change detection earn their keep
The LSP gets the headlines, but for a team the two boring commands next to it are worth more.
buf lint enforces the conventions that make a schema pleasant to consume: enum values prefixed with the enum name, that mandatory zero value, package versioning like v1. Notice the ORDER_STATUS_UNSPECIFIED = 0 in the enum above. That is not me being tidy, it is a rule, and the linter fails the build if you forget it. Small thing, but it is the difference between a schema a new team can read and one that grows warts nobody dares remove.
buf breaking is the one I would not run a shared API without. It compares your branch against the committed schema and fails if you removed a field, renamed one, or changed a type in a way that breaks the wire format. In a wire protocol those mistakes are silent and expensive. You do not get a compile error in the consumer. You get a field that quietly deserializes to its default and a support ticket three weeks later. Catching it in the pull request is the whole game.
Wire it into GitHub Actions and it runs on every PR:
name: proto
on: pull_request
jobs:
buf:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: bufbuild/buf-action@v1
with:
lint: true
breaking: true
breaking_against: "https://github.com/${{ github.repository }}.git#branch=main"
That is a lot of safety for ten lines of YAML, and none of it depends on the editor tooling. You get the value even for the developer who never installs the LSP.
The honest trade-off
Buf is a company, and Buf wants you on their registry. Nothing wrong with that, but be clear-eyed about which pieces tie you in.
The parts I would adopt without a second thought are the local ones. buf.yaml, buf lint, buf breaking, and the LSP all run off the CLI against files in your repo. Rip Buf out tomorrow and you still have plain .proto files that protoc and Grpc.Tools compile. No lock-in there.
The part to think about is remote plugins and the Buf Schema Registry in your build path. Generating code from a hosted plugin is convenient right up to the afternoon the registry has a bad day and your build cannot resolve it. If you go that route, pin plugin versions and keep a local fallback. For a .NET shop that is not really a temptation anyway, because Grpc.Tools already owns codegen and there is no reason to move it. Take the authoring and governance layer, which is the genuinely new thing here, and leave your build where it is.
Frequently asked questions about contract-first gRPC in .NET
Does the Buf language server replace Grpc.Tools in my .NET build?
No, they solve different problems. The LSP and linter work on the .proto source to help you write and review it, while Grpc.Tools generates the C# from that source at build time. You run both, and neither knows or cares about the other.
Can I generate C# with buf generate instead of MSBuild?
You can generate the message types through Buf's protocolbuffers/csharp plugin, but the C# gRPC service base classes have never had a first-class remote plugin the way Go and Java do. For most .NET teams the pragmatic choice is to keep codegen in Grpc.Tools and use Buf only for the schema layer.
Is buf lint worth adding if my proto file is small? Even for a small file it enforces the conventions that hurt later, like the mandatory zero enum value and consistent package versioning. It costs almost nothing to run and it stops a five-message service from quietly growing into an inconsistent forty-message one.
Do I need a Buf Schema Registry account to use the language server?
No. The LSP, buf lint, and buf breaking all run locally from the CLI against files in your repo. The registry only comes into play if you choose to use remote plugins or publish your schema, and you can adopt every local feature without touching it.
Why does buf breaking matter more than a normal code review?
A breaking change to a wire protocol is silent. Remove or renumber a field and the consumer will not fail to compile, it will deserialize the missing field to its default and misbehave later. buf breaking compares against the committed schema and fails the PR, which is the only cheap place to catch it.
Further reading
- The valley of webhooks: what to build when delivery is at-least-once
- TypeScript 7 Is Here: Why a 10x Faster Compiler Changes Your Workflow
- Ruff now enables 413 rules by default. What that means for your .NET analyzers
Conclusion: Treat the Protobuf LSP as the missing authoring layer for your schema, not as a reason to move C# generation off Grpc.Tools. The editor intelligence is welcome, but the real win for a .NET team is that buf lint and buf breaking now sit on top of a .proto you can finally edit with confidence.
Sources: Buf's Protobuf LSP announcement · Hacker News discussion.
