Improve gRPC development by sharing protobuf contracts via NuGet packages
Distributed systems need to agree on the shape of the data being exchanged and the endpoints involved. These shapes, often referred to as contracts, can then be used to automatically generate clients that interact with the service.
Frameworks used to build these systems generally fall into two categories:
- those that generate metadata from the code, for example ASP.NET Core producing an OpenAPI specification from controllers and Minimal API endpoints,
- those that generate code stubs from a contract.
gRPC services hosted on ASP.NET Core follow the second approach. The developer starts by defining a contract in the form of a Protobuf file, and the tooling then generates abstract classes that must be implemented by the service.
Personally, I have a strong preference for contract-based development. In my experience, generating metadata from application code tends to result in leaky or awkward contracts, that have quirky naming conventions, or that require non-trivial workarounds in order to keep the public API clean and intentional.
The problem
Unfortunately, contract-based development workflows come with a built-in challenge: sharing those contracts.
There are several common reasons why contracts need to be shared:
- the producing service and the consuming service may live in different repositories;
- different teams may be responsible for the producer and its consumers;
- services and clients may evolve at different speeds;
- not all consumers are necessarily written in the same language or use the same tooling.
Once you move beyond a single service and a single client, the question is no longer whether contracts should be shared, but how.
Naive approaches
For simplicity, let’s assume that the producing service and the consuming one live in different repositories.
Both services need access to the contracts. The producing service needs them to generate the service stubs; the consuming service needs them to generate the client code.
There are a few obvious ways to solve this problem.
Copying Protobuf files around
The most straightforward solution is to physically copy the .proto files from one repository to another. This can be done manually or automated via a CI pipeline.
This approach works reasonably well in a simple 1:1 scenario, but it quickly breaks down as soon as:
- more than one consumer needs the contracts;
- changes become frequent;
- different versions need to be supported simultaneously.
At that point, keeping multiple copies in sync becomes tedious and error-prone, and it is often unclear which copy represents the source of truth.
Git submodules
Another approach is to put the Protobuf contracts in a shared repository and reference it via Git submodules (or similar mechanisms).
While this technically avoids duplication, it introduces a different set of problems:
- repository coupling;
- awkward versioning;
- brittle CI setups;
- and a generally poor developer experience.
Git submodules are notoriously difficult to work with in CI/CD pipelines, so we won’t explore this option further.
Sharing generated code
Another common approach is to generate code from the Protobuf files and publish it as a shared package.
On the surface, this looks appealing:
- no duplicated
.protofiles; - consumers just reference a package and start coding;
- everything is strongly typed and ready to use.
Unfortunately, this approach tightly couples all consumers to the same version of the tooling and the same code generation strategy, along with a shared set of design decisions.
It also makes certain scenarios unnecessarily hard. For example, a team consuming the service might want to generate a simulator or mock implementation as part of their development workflow, but the published package may already have baked-in assumptions that get in the way.
Protobuf file as the artifact
In this section, we’ll look at a different approach: treating Protobuf contracts themselves as the versioned artifact, and using NuGet purely as a distribution mechanism.
The core idea is deliberately simple: instead of sharing generated code, we share the .proto files themselves.
This article is accompanied by a complete working example available on GitHub. The repository contains the contract package, a gRPC service, and a client consuming the shared Protobuf files. https://github.com/kralizek/grpcpackages
NuGet is not used to distribute binaries, but to version and distribute contracts. In fact, the approach described here does not even require an SDK-style project. Code generation remains a local concern of each consuming project.
This approach starts with a directory containing one or more Protobuf files and a nuspec file used to generate the package. For example, you might have a simple structure like:
contracts/
greet.proto
Greeter.Contracts.nuspec
The nuspec file will be responsible for specifying which files need to be included in the package and suggesting the build action that will be used when importing them.
Here is a quick sample.
<?xml version="1.0"?>
<package>
<metadata>
<id>Greeter.Contracts</id>
<version>1.0.0</version>
<authors>Renato Golia</authors>
<license type="expression">MIT</license>
<description>Contracts for the Greeter service.</description>
<contentFiles>
<files include="any/any/contracts/*.proto" buildAction="None" />
</contentFiles>
</metadata>
<files>
<file src="*.proto" target="contentFiles\any\any\contracts" />
</files>
</package>
In the snippet above, we can see two distinct responsibilities:
- the files being included in the package via the
package.files.fileelement; - the suggested build action for consuming projects via the
package.metadata.contentFiles.fileselement.
Also, it is worth noting how the files are copied into a well-known and framework-agnostic directory in the NuGet package structure (contentFiles/any/any).
To build the package from the nuspec file, we need to use the nuget CLI.
Instructions on how the CLI tool can be installed can be found here.
This requires the standalone nuget CLI, as dotnet pack does not support packing raw .nuspec files without a proper project file.
Once installed, we can create the package using the command nuget pack. When versioning your contracts, consider using semantic versioning to communicate the impact of changes to consumers.
$ cd /path/to/contracts
$ nuget pack Greeter.Contracts.nuspec -OutputDirectory /output/path
This command produces a package that we can push to our NuGet feed of choice. Ensure your target NuGet feed is configured using nuget sources add before pushing.
$ nuget push /output/path/Greeter.Contracts.1.0.0.nupkg -Source YourFeedName
Consuming the package
When the package have been pushed a project can then consume it to generate the service stubs or client.
First of all, we need to reference the package in our project.
<ItemGroup>
<PackageReference Include="Greeter.Contracts" Version="1.0.0" />
</ItemGroup>
The classic <PackageReference /> shown above is a good start, but we need to extend it with two attributes.
The first attribute we add is PrivateAssets="all" so that the package doesn’t flow transitively to downstream dependencies. This is important if we want to generate a NuGet package with the generated code, without leaking the contract package to consumers.
The second attribute is the less-known GeneratePathProperty="true". This attribute instructs MSBuild to create a property whose name depends on the package name that points to the location where the content of the package is unpacked. In our scenario, a variable called PkgGreeter_Contracts will be available to later stages of the build.
Additional information about the GeneratePathProperty attribute can be found here.
Generating the service stub
Now that we have access to the contract files, we can reference them and instruct the tooling to generate the service stub.
<ItemGroup>
<Protobuf Include="$(PkgGreeter_Contracts)\contentFiles\any\any\contracts\greet.proto" GrpcServices="Server" />
</ItemGroup>
This works exactly as the usual gRPC service guidelines. We have only changed how the Protobuf file is referenced.
Generating the client
Finally, we can do the same for the client.
<ItemGroup>
<Protobuf Include="$(PkgGreeter_Contracts)\contentFiles\any\any\contracts\greet.proto" GrpcServices="Client" />
</ItemGroup>
Apart from the value of GrpcServices, the configuration is identical. The same contract package can therefore be used consistently by both services and clients.
Recap
Sharing Protobuf contracts is a fundamental requirement in gRPC-based systems, yet it is often treated as an afterthought.
By packaging raw .proto files as NuGet artifacts, we can keep contracts explicit, versioned, and independent from any specific code generation strategy. Services and clients remain free to evolve their tooling and implementations, while still agreeing on a well-defined wire contract.
This approach requires very little infrastructure, relies on existing NuGet mechanisms, and avoids the hidden coupling introduced by sharing generated code. Most importantly, it scales naturally as systems grow in size, complexity, and number of consumers.
If you are already using NuGet in your build pipeline, treating Protobuf files as the artifact is a simple change that can significantly improve the long-term maintainability of your gRPC APIs.
Support this blog
If you liked this article, consider supporting this blog by buying me a pizza!