Over-Engineered on Purpose — Part 2: Why Proto-First Design Forces You to Think
This is Part 2 of a series where I'm building a microservice platform from scratch. Part 1 covers the idea, architecture, and tech stack. The full codebase is on GitHub.
Before writing any service logic, I needed to answer a question that sounds simple but isn't: what data flows between my services, and in what shape? In a monolith, you don't think about this much. Your service layer calls another service layer. The objects are right there. If you need a field, you add it. If a method signature changes, your IDE lights up every file that needs updating. In a microservice architecture, that luxury disappears. Your services are separate applications, possibly written by different teams, deployed independently. The contract between them — what you can ask for and what you'll get back — has to be defined explicitly, upfront, and shared. This is where gRPC and Protocol Buffers come in.
Why gRPC and Not Just REST?
Let me be clear: REST with JSON would work fine here. Most microservice architectures use it. I chose gRPC because I wanted to learn it, not because my project demands it.
That said, once I started working with it, a few things clicked.
gRPC is a remote procedure call framework originally developed at Google. Instead of defining URL paths and HTTP methods, you define services and methods — functions you can call on a remote server as if they were local. The data exchanged is defined using Protocol Buffers, a binary serialization format that's smaller and faster than JSON.
But the real benefit for me wasn't performance. It was the .proto file.
A .proto file is a single source of truth. It defines your messages (the data structures) and your services (the operations) in a language-neutral format. You write it once, and the protobuf compiler generates code for whatever language your services use — Java, Go, Python, whatever. If two services disagree on what a request looks like, the proto file is the arbiter.
This matters because when you're designing a system with four services that need to talk to each other, you want those conversations defined before you start building. It forces you to think about your domain boundaries.
Defining the Domain in Proto
I started by asking: what are the core entities in my system, and which service owns each one?
From Part 1, the split is:
- •
User Service owns users, roles, and verification
- •
Catalog Service owns machines, categories, images, and maintenance records
- •
Booking Service owns bookings, payments, and reviews
- •
Notification Service is stateless — it consumes events, owns no data
With that in mind, I wrote the proto definitions for each service. Let me walk through the Catalog Service as an example, since it's the most complex.
The Enums
Before defining any messages, I needed to establish the vocabulary — the fixed sets of values the system understands:
enum MachineCondition {
MACHINE_CONDITION_UNSPECIFIED = 0;
EXCELLENT = 1;
GOOD = 2;
FAIR = 3;
}
enum MachineStatus {
MACHINE_STATUS_UNSPECIFIED = 0;
AVAILABLE = 1;
RENTED = 2;
MAINTENANCE = 3;
INACTIVE = 4;
}
enum PriceCalculationType {
PRICE_CALCULATION_TYPE_UNSPECIFIED = 0;
HOURLY = 1;
DAILY = 2;
WEEKLY = 3;
DISTANCE_BASED = 4;
}That UNSPECIFIED = 0 pattern is a Protocol Buffers convention worth knowing. In proto3, the default value for an enum field is 0. If you don't explicitly set a field, it gets the zero value. By making zero mean "unspecified" rather than something meaningful like "EXCELLENT," you can tell the difference between "this was never set" and "this was intentionally set to the first option."
This connects to a broader concept in protobuf: implicit vs explicit presence. By default in proto3, a field set to its default value (0 for integers, empty string for strings) is indistinguishable from a field that was never set. It's like a form where blank fields auto-fill with "0" — you can't tell if someone typed zero or left it empty. If you need that distinction, you mark the field as optional, which enables explicit presence tracking.
The Messages
With the vocabulary established, I defined the data structures:
message Category {
string id = 1;
string name = 2;
string description = 3;
string icon_url = 4;
PriceCalculationType default_price_type = 5;
int32 machine_count = 6;
}
message Machine {
string id = 1;
string owner_id = 2;
string category_id = 3;
string name = 4;
string description = 5;
rentitup.common.Money base_price = 6;
PriceCalculationType price_type = 7;
MachineCondition condition = 8;
MachineStatus status = 9;
rentitup.common.Location location = 10;
bool is_available = 11;
rentitup.common.Timestamp created_at = 12;
rentitup.common.Timestamp updated_at = 13;
map<string, string> specifications = 14;
repeated MachineImage images = 15;
Category category = 16;
user.User owner = 17;
double average_rating = 18;
int32 total_reviews = 19;
int32 total_rentals = 20;
}A few things to notice here.
Field numbers are permanent identifiers, not positions. The = 1, = 2 are not about ordering — they're the wire format tags. If you ever remove a field, you don't reuse its number. This is what makes protobuf backward compatible. An old client that doesn't know about field 20 will simply ignore it.
Nested messages and cross-service references. The Machine message references Category (same service), user.User(different service), and common types like Money, Location, and Timestamp. This is where the design gets interesting. The user.User reference means the Catalog Service's proto definition depends on the User Service's proto definition. Both need to live somewhere accessible to all services.
The map and repeated types. specifications is a key-value map for arbitrary machine attributes (engine size, weight, year) that don't warrant their own fields. images is a repeated field — protobuf's way of saying "a list."
The Services
With messages defined, I could define what operations the Catalog Service exposes:
service CatalogueService {
rpc CreateCategory(CreateCategoryRequest) returns (CategoryResponse);
rpc GetCategory(GetCategoryRequest) returns (CategoryResponse);
rpc UpdateCategory(UpdateCategoryRequest) returns (CategoryResponse);
rpc DeleteCategory(DeleteCategoryRequest) returns (DeleteCategoryResponse);
rpc ListCategories(ListCategoriesRequest) returns (ListCategoriesResponse);
rpc CreateMachine(CreateMachineRequest) returns (MachineResponse);
rpc GetMachine(GetMachineRequest) returns (MachineResponse);
rpc UpdateMachine(UpdateMachineRequest) returns (MachineResponse);
rpc ListMachines(ListMachinesRequest) returns (ListMachinesResponse);
rpc SearchMachines(SearchMachinesRequest) returns (ListMachinesResponse);
rpc GetMachinesByOwner(GetMachinesByOwnerRequest) returns (ListMachinesResponse);
rpc GetFeaturedMachines(GetFeaturedMachinesRequest) returns (ListMachinesResponse);
rpc GetNearbyMachines(GetNearbyMachinesRequest) returns (ListMachinesResponse);
}Each RPC method has a dedicated request and response message. You don't reuse Machine directly as a request — you create CreateMachineRequest with only the fields needed for creation. This is deliberate. The shape of "what you send to create a machine" is different from "what the system knows about a machine." Separating them means you can evolve one without breaking the other.
I wrote similar service definitions for the User Service and Booking Service. The Notification Service has no gRPC service definition at all — it only consumes events from RabbitMQ.
The Shared Libs Problem
Here's where the monorepo structure becomes relevant.
The proto files reference each other across services. The Machine message in the catalog proto imports user.User from the user proto and Money from a common proto. These definitions need to be compiled together and available to every service.
The solution is a shared library module. In my monorepo, it's called shared-libs, and it contains all the .proto files plus the common types (timestamps, money, location, pagination) used across services.
When Gradle builds shared-libs, the protobuf compiler generates Java classes from all the proto files. Every microservice depends on shared-libs and gets access to all the generated code — the message classes, the service stubs, everything.
This is one of the places where a monorepo really earns its keep. If each service lived in its own repository, sharing proto definitions would mean publishing shared-libs as a Maven artifact and managing version dependencies. In a monorepo, it's just another module in the same build.
Setting Up the Monorepo
The project structure looks like this:
backend/
├── buildSrc/
│ └── src/main/kotlin/
│ ├── java-common-conventions.gradle.kts
│ ├── java-library-conventions.gradle.kts
│ └── spring-service-conventions.gradle.kts
├── shared-libs/
│ ├── build.gradle.kts
│ └── src/main/proto/
│ ├── common.proto
│ ├── user.proto
│ ├── catalog.proto
│ └── booking.proto
├── catalog-service/
│ └── build.gradle.kts
├── user-service/
├── booking-service/
├── notification-service/
├── settings.gradle.kts
└── build.gradle.ktsGradle handles monorepos well. The settings.gradle.kts at the root tells Gradle which subprojects exist. Each subproject has its own build.gradle.kts for dependencies specific to that service.
The interesting piece is buildSrc. This is a special directory that Gradle compiles before the main build. It's where you define convention plugins — reusable build configurations that multiple services share.
I created a spring-service-conventions plugin that bundles all the dependencies every microservice needs:
plugins {
id("buildlogic.java-application-conventions")
id("org.springframework.boot")
id("io.spring.dependency-management")
id("com.google.protobuf")
}
val springGrpcVersion = "1.0.2"
val mapstructVersion = "1.6.3"
dependencyManagement {
imports {
mavenBom("org.springframework.grpc:spring-grpc-dependencies:$springGrpcVersion")
}
}
dependencies {
implementation("io.grpc:grpc-services")
implementation("org.springframework.grpc:spring-grpc-spring-boot-starter")
implementation("org.springframework.boot:spring-boot-starter-validation")
implementation("org.flywaydb:flyway-core")
implementation("org.flywaydb:flyway-database-postgresql")
runtimeOnly("org.postgresql:postgresql")
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
compileOnly("org.projectlombok:lombok")
annotationProcessor("org.projectlombok:lombok")
implementation("org.mapstruct:mapstruct:$mapstructVersion")
annotationProcessor("org.mapstruct:mapstruct-processor:$mapstructVersion")
}Notice that some dependencies have explicit versions (MapStruct at 1.6.3) while others don't (Flyway, PostgreSQL, Lombok). That's because of BOMs — Bills of Materials.
A BOM is a special dependency file that defines a curated set of versions tested to work together. When you apply the Spring Boot plugin, it brings its own BOM that manages versions for Hibernate, Flyway, Lombok, and dozens of other libraries. For gRPC, I import its BOM explicitly. But MapStruct doesn't have a BOM in the Spring ecosystem, so I specify the version myself.
The result is that adding a new microservice is clean. The service's own build.gradle.kts only needs to apply the convention plugin and declare anything unique to that service. All the shared stuff — Spring Boot, gRPC, Postgres, Flyway, Lombok, MapStruct — comes from the convention plugin.
What Writing Contracts First Taught Me
Before writing any Java code, I had to decide which fields belong on CreateMachineRequest vs Machine. I had to think about whether owner should be embedded in the machine response or fetched separately. I had to define pagination as a reusable message type because three different services need it.
These are all domain modeling decisions. In a monolith, you might make them incrementally — add a field here, create a DTO there. With proto-first design, you make them upfront. It's not that you can't change them later (you can, protobuf is designed for evolution), but the act of writing them down forces clarity.
The contracts are also a communication tool. If another developer joined this project, I could hand them the proto files and they'd understand every interaction between services without reading any Java code. That's powerful.
Was some of this anticipatory? Absolutely. I defined methods like GetNearbyMachines and GetFeaturedMachines that I haven't implemented yet. The contracts will evolve as I build. But having the skeleton in place gives me a map of where I'm going.