Over-Engineered on Purpose — Part 3: Building the First Service, the Hard Way
This is Part 3 of a series where I'm building a microservice platform from scratch. Part 1 covers the architecture, Part 2covers the gRPC contracts. Full codebase on GitHub.
Quick story. Went for a backend interview, was asked to write a paginated SQL query. Froze. Not because I didn't understand pagination — I'd used Pageable in Spring Data JPA a hundred times. I just couldn't write the actual SQL. Went home, looked it up. It's LIMIT and OFFSET. That's literally it.
Humbling? Very. But it shaped how I built the Catalog Service. I'm using JPA for the repository layer but writing every migration by hand in raw SQL with Flyway. No ddl-auto=update. No letting Hibernate generate my schema. If a table exists, it's because I wrote the CREATE TABLE statement myself.
The Database First
So Flyway versions your schema changes — V1__init.sql, V2__add_indexes.sql — and runs pending migrations when the service starts. Straightforward stuff.
Here's an excerpt of my initial migration:
CREATE SCHEMA IF NOT EXISTS catalog;
CREATE TABLE catalog.categories (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
description TEXT,
icon_url VARCHAR(500),
default_price_type VARCHAR(20) DEFAULT 'DAILY'
CHECK (default_price_type IN ('HOURLY','DAILY','WEEKLY','DISTANCE_BASED')),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE catalog.machines (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
owner_id UUID NOT NULL,
category_id UUID NOT NULL REFERENCES catalog.categories(id),
name VARCHAR(255) NOT NULL,
description VARCHAR(500),
-- ... more fields
);Few things worth noting.
UUIDs over auto-incrementing integers. In a distributed system where you might have multiple instances of a service running, auto-increment becomes a coordination problem. UUIDs don't care — generate them anywhere, no collisions. Yes they're bigger, no I don't care at this scale.
CHECK constraints in the database itself, not just Java validation annotations. The database is the last line of defense. If my code has a bug or someone connects directly, the database still says no to invalid data.
Everything is schema-qualified — catalog.categories, not just categories. The Catalog Service's database user only has permissions on the catalog schema. I even set up a dedicated user with scoped permissions:
CREATE USER catalog_service WITH PASSWORD 'catalog_service_password';
GRANT USAGE ON SCHEMA catalog TO catalog_service;
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA catalog TO catalog_service;
ALTER DEFAULT PRIVILEGES IN SCHEMA catalog GRANT ALL ON TABLES TO catalog_service;The gRPC Server
With the database ready, next is the gRPC server — the endpoints the API Gateway and other services will call.
Spring gRPC makes this pretty clean. Annotate with @Service, extend the generated base class from your proto definitions, override the methods:
@Service
@RequiredArgsConstructor
@Slf4j
public class CatalogGrpcService extends CatalogServiceGrpc.CatalogServiceImplBase {
private final CategoryService categoryService;
private final CatalogMapper catalogMapper;
@Override
public void createCategory(
CreateCategoryRequest request,
StreamObserver<CategoryResponse> responseObserver) {
try {
CategoryEntity toCreate = catalogMapper.fromCreateCategoryRequest(request);
CategoryEntity created = categoryService.createCategory(toCreate);
CategoryResponse response = CategoryResponse.newBuilder()
.setResponse(catalogMapper.toGrpcEntity(created))
.build();
responseObserver.onNext(response);
responseObserver.onCompleted();
} catch (Exception ex) {
log.error("Failed to create category", ex);
responseObserver.onError(
Status.INTERNAL
.withDescription("Failed to create category")
.withCause(ex)
.asRuntimeException()
);
}
}
}If you're coming from REST controllers the StreamObserver pattern feels weird at first. Instead of returning a response you push it — onNext sends, onCompleted signals done, onError for failures. You get used to it.
The service layer underneath is intentionally boring:
@Service
@RequiredArgsConstructor
public class CategoryServiceImpl implements CategoryService {
private final CategoryRepository categoryRepository;
@Override
public CategoryEntity createCategory(CategoryEntity category) {
return categoryRepository.save(category);
}
}No gRPC types here. No protobuf. Just JPA entities and a repository. I'm doing all the type conversion at the gRPC layer so the service layer stays transport-agnostic. If I ever slap a REST layer alongside gRPC, this code doesn't change. Also being that Spring is a master of abstraction, getting this up and running is really straightforward.
The Mapping Problem
In a typical REST app you might map between a request DTO, a JPA entity, and a response DTO. Two conversions. Fine.
With gRPC, the protobuf-generated classes are their own universe. They're immutable. They use builders. They have their own timestamp types, their own enum types, their own everything. They do not play nice with JPA entities.
So every request is:
- •
Protobuf request message→JPA entity(to persist)
- •
JPA entity→Protobuf response message(to send back)
Sounds like the same two conversions right? But the types are so much more different from each other than a DTO and an entity. Protobuf objects use builders — Category.newBuilder().setName("...").build(). JPA entities use setters. Protobuf has com.google.protobuf.Timestamp, JPA uses LocalDateTime. Protobuf enums are not Java enums. Everything needs a bridge.
I'm using MapStruct to handle the bulk of it — it generates mapping code at compile time from interface definitions:
@Mapper(
componentModel = MappingConstants.ComponentModel.SPRING,
nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE,
nullValueCheckStrategy = NullValueCheckStrategy.ALWAYS
)
public interface CatalogMapper {
CategoryEntity fromCreateCategoryRequest(CreateCategoryRequest request);
Category toGrpcEntity(CategoryEntity entity);
}MapStruct is genuinely great for this. No reflection at runtime, compile-time errors when mappings break. But even with MapStruct, the weird conversions pile up. Timestamp formats. Enum mappings. Nested objects where one side is a builder pattern and the other is a POJO. Every new endpoint means asking yourself: what shape is this data in right now, and what shape does the next layer need? A big thing I'm realising is that mapping from one type to another is just... a big part of all this. Like a disproportionately big part. It's not hard exactly, but it's constant. It's the background radiation of working with gRPC in a Spring Boot world.
The Pagination Helper
One conversion that kept repeating was pagination — converting between Spring's Pageable/Page and my protobuf pagination messages. So I extracted it:
public final class PaginationHelper {
public static Pageable toPageable(PaginationRequest request) {
int page = Math.max(0, request.getPage());
int size = request.getSize() > 0 ? request.getSize() : 20;
String sortBy = request.getSortBy();
if (sortBy == null || sortBy.isBlank()) {
sortBy = "createdAt";
}
Sort sort = request.getDescending()
? Sort.by(Sort.Direction.DESC, sortBy)
: Sort.by(Sort.Direction.ASC, sortBy);
return PageRequest.of(page, size, sort);
}
public static PaginationResponse toProto(Page<?> page) {
return PaginationResponse.newBuilder()
.setCurrentPage(page.getNumber())
.setTotalPages(page.getTotalPages())
.setTotalElements(page.getTotalElements())
.setPageSize(page.getSize())
.setHasNext(page.hasNext())
.setHasPrevious(page.hasPrevious())
.build();
}
}Every list endpoint now just calls toPageable() on the way in and toProto() on the way out. Small utility, big reduction in duplicate code.
Where Things Stand
The Catalog Service is alive. Categories and machines can be created, listed, and queried over gRPC. I can hit it with Postman's gRPC feature and get responses back. But it's running in isolation. Nobody can reach it from the outside world. There's no gateway, no service discovery, nothing connecting it to the other services we planned in Part 1. Next up, I'm setting up Eureka for service discovery and building the API Gateway — the layer that takes REST requests from clients and translates them into gRPC calls to the services. That's where gRPC and Spring Cloud start disagreeing about how the world should work, and things get genuinely interesting.
