A frontend and a backend can each look reasonable in isolation and still disagree about how to talk to each other.
Which fields does a request contain? What does the response look like? Where does the request go? If we answer those questions separately on each side, we have several things to keep in sync.
In Taproot, we start with a shared contract and generate much of the code that connects the pieces. Our C# backend, TypeScript frontend, and supporting services all get their interfaces from the same place.
That has been especially useful as we build with AI. An assistant working on a feature can follow an existing interface through the system. We still have to design that interface and implement its behavior, but we can automate a lot of the repetition around it.

Decide what crosses the boundary
An API contract describes how one part of the software communicates with another: the operations it offers and the messages those operations accept and return.
Taproot keeps these definitions in Protocol Buffers, usually called protobuf. Each .proto file describes related services and messages. When we add a normal client-facing endpoint, that is where the work starts.
Writing the contract brings some questions up early. Does the interface need a full record or a lightweight search result? Which identifier should the caller supply? What happens when an input is missing?
The answers shape the product as much as the code. A screen can only offer an interaction if the service provides the information and operations behind it. Starting at the boundary gives us something concrete to review before the details spread across the implementation.
One definition, several consumers
Taproot's backend uses gRPC, a framework for calling service operations. The protobuf definitions generate C# message types and service base classes, and we write the implementation behind those methods.
The browser uses HTTP and JSON. We expose suitable gRPC operations through ASP.NET Core's JSON transcoding, which maps an HTTP request onto a gRPC method. The HTTP mapping is declared next to the operation in its protobuf contract.
From the API, we capture an OpenAPI document, which is a machine-readable description of its HTTP interface. We use that snapshot to generate TypeScript types and client functions with Hey API, and the frontend calls those functions.
Protobuf contracts
→ C# message types and service base classes
→ API implementation and HTTP/JSON interface
→ OpenAPI snapshot
→ TypeScript types and client functions
→ Frontend featuresA second branch comes off the same contracts. Our static-site generator and image-processing service use TypeScript protobuf tooling for their gRPC interfaces, so they don't need the browser's HTTP route at all. Each consumer gets an interface that suits its language and transport.
A real example: searching for a place
Taproot has a place picker that lets an author search for a location. Its contract includes this operation:
rpc SearchPlaces(SearchPlacesRequest) returns (SearchPlacesResponse) {
option (google.api.http) = {get: "/v1/places/search"};
}The request describes the search query, location coordinates, maximum number of results, and a session token. The response contains a list of predictions, each with a Google place ID, a name, and a formatted address.
Those lightweight predictions are separate from the complete place record returned when someone selects a result. That split fits the interaction, since typing into a search field and selecting a place are different operations.
On the backend, we implement the generated SearchPlaces method. In the browser, the picker imports the generated searchPlaces function. The handwritten component supplies its search parameters and uses the returned predictions to update the interface.
The picker still has to handle typing, loading state, and results that arrive after the user has changed the search. We write that interaction ourselves. Generation supplies the connection it runs over.
Generating saves AI requests
We generate message types, service scaffolding, and client code wherever the contract can supply them, which cuts down the code we maintain by hand.
Our rule is to change the source and regenerate the output. If a generated client is missing an operation, we trace the problem back through the contract and the generation process. Editing the generated file directly would leave a change that the next generation run could erase.
That rule gives an AI assistant a clear path through the work. It can read the contract, update the service, regenerate the client, and change the consumer. Along the way, the generated types catch some mismatches while the change is still local.
For example, removing a response property can produce a TypeScript error wherever a component still uses it, so we find out before the screen runs. That depends on using the types properly. Code that bypasses them with unchecked assumptions loses the benefit.
The monorepo helps here too, because we can review the contract, implementation, and consumer in one change. I wrote about that in why moving to a monorepo made AI-assisted development easier.
Check every link in the chain
Generation can faithfully produce code from a stale input, so the process needs checks of its own.
Taproot has a check that regenerates client artifacts in temporary directories and compares them with the local generated output. It can catch output that no longer matches its input.
For the browser client, that input is the saved OpenAPI snapshot. A client that matches the snapshot tells us those two pieces agree. It does not tell us that either one matches the current API.
A separate comparison checks the OpenAPI document the running API serves against the saved snapshot. The offline snapshot check has a narrower job: it validates the format and guards against an unexpectedly truncated capture.
We also require frontend API calls to go through the generated SDK, with explicit exceptions for particular transport needs. A guard catches raw fetch() calls that bypass that rule.
Each check covers one gap, and together they make it harder for a change to slip through the system by some other path. It's the same idea behind how we enforce coding standards: a written convention is worth more when we can check whether the code follows it.
A correct shape still needs correct behavior
A generated request type can tell us that a search has a string field called query. It cannot tell us whether the string contains anything useful.
Our place-search implementation rejects an empty query and supplies a default result count when the requested count is not positive. The service also enforces authorization. Those jobs belong to the implementation, shared schema or not.
We still need behavior tests and review for permissions, validation, persistence, and the user interaction, because a response can have all the right fields and still contain the wrong information.
Compatibility needs deliberate attention too. A browser tab can stay open while a new server version is deployed, and having both projects in one repository does not mean the running versions change at the same time.
Protobuf field numbers identify fields in the binary format, so they must not be reused for a different purpose. The protobuf best-practices guide explains this and recommends reserving deleted fields. Binary compatibility only covers part of it, though, because our browser-facing JSON interface needs its own compatibility review.
The tooling has a cost
We maintain generators, configuration, snapshots, and checks. Refreshing the browser's OpenAPI snapshot requires a running API. A contract change can produce a large generated diff, and someone still has to read it.
That investment pays off for Taproot because the same interfaces cross several services and two programming languages. A smaller application might get what it needs from an OpenAPI contract and one generated client. A TypeScript-only system might share interfaces some other way.
In any of those setups, the aim is to give the interface one owner and derive the repeated representations from it.
Start with one operation
If you want to try contract-driven development, pick one feature that crosses your frontend and backend. Describe its request and response, including the behavior a schema cannot express on its own. Generate a client and use it in the feature.
Then make a small change to the interface and see whether regeneration and type checking point you to the affected consumers. Also work out how you will detect a stale schema, and how you will support callers that are still on the previous version.
In Taproot, this approach divides the work more clearly. The contract owns the interface and the generators carry it into each language that needs it, which leaves our implementation and review time for what the feature should actually do.
For the next piece of the process, read why documentation-first development helped us build with AI. A schema describes the messages, but examples and explanations help the next contributor understand how to use them.