Two Spring Boot microservices behind a car dealership — one owns orders, the other owns the warehouse. They talk over gRPC for reads and over Kafka for writes, with a transactional outbox so an order and the event announcing it are committed together.
- Two independently deployable services (
order-service,storage-service) split by domain, not by layer - Transactional outbox with a polling relay — an order's state change and its event commit atomically
- Nine-state order lifecycle (state pattern) governing stock and custom orders separately
- Idempotent Kafka consumers, safe against redelivery
- Keycloak authentication — realm roles mapped to Spring Security authorities, enforced with
@PreAuthorize - Testcontainers + EmbeddedKafka integration suite, run in CI on every push
flowchart LR
KC["Keycloak<br/>:8081"]
OS["order-service<br/>:8082"]
SS["storage-service<br/>:8083"]
ODB[("order_db")]
SDB[("storage_db")]
K{{"Kafka"}}
KC -.->|JWT| OS
KC -.->|JWT| SS
OS -->|"gRPC :9090"| SS
OS -->|order-events| K
K -->|order-events| SS
SS -->|storage-events| K
K -->|storage-events| OS
OS --- ODB
SS --- SDB
order-service owns orders and test drives. It never queries the warehouse database — it asks storage-service over gRPC when it needs to read a car, and publishes an event when a customer pays.
storage-service owns car models, components, assembled cars and assembly orders. It consumes
order-events, builds (or fails to build) the car, and answers on storage-events.
common holds what both share: the outbox, the base entity with auditing, the Keycloak role converter, the event payloads and the generated gRPC stubs.
Dependencies point strictly inward within each service; common is the only thing both depend on.
An order is either a stock order (a car that already exists) or a custom order (a car to
be assembled from components). The difference is expressed with single-table inheritance and a
state pattern — nine states, each deciding for itself what proceed() and cancel() mean.
stateDiagram-v2
[*] --> CREATED
CREATED --> MANAGER_APPROVED: proceed (stock)
CREATED --> WAREHOUSE_APPROVED: proceed (custom)
MANAGER_APPROVED --> AWAITING_PAYMENT: proceed
WAREHOUSE_APPROVED --> AWAITING_PAYMENT: proceed
AWAITING_PAYMENT --> PAID: pay
PAID --> READY_FOR_PICKUP: proceed (stock)
PAID --> AWAITING_DELIVERING: proceed (custom)
AWAITING_DELIVERING --> READY_FOR_PICKUP: proceed
READY_FOR_PICKUP --> COMPLETED: proceed
COMPLETED --> [*]
CREATED --> CANCELED: cancel
MANAGER_APPROVED --> CANCELED: cancel
WAREHOUSE_APPROVED --> CANCELED: cancel
AWAITING_PAYMENT --> CANCELED: cancel
PAID --> CANCELED: assembly failed
CANCELED --> [*]
CANCELED is reachable from CREATED, MANAGER_APPROVED, WAREHOUSE_APPROVED and
AWAITING_PAYMENT. Once an order is paid the customer can no longer cancel it — only the
warehouse can end it, by reporting a failed assembly. COMPLETED and CANCELED are terminal:
both proceed() and cancel() throw from there.
The state is persisted as a plain string through an AttributeConverter, so the converter and
getStateName() have to agree — a round-trip test enforces exactly that.
A CarModel is a catalogue entry: price, brand, engine, body type, plus the set of component
categories it requires and the base component for each. A Car is a physical car built from a
model — either stock (base components, immutable) or custom (components chosen per
category).
Components use single-table inheritance with a factory per subtype. There are four categories, and they are Russian words used as map keys throughout the domain:
| Category | Class |
|---|---|
Колеса |
Wheels |
Трансмиссия |
Transmission |
Руль |
Steering |
Интерьер |
Interior |
Since the categories are keys, the spelling has to be identical everywhere. The API accepts
either Колёса or Колеса (any case) and canonicalises it, but internally there is one spelling.
The paid-order flow
sequenceDiagram
autonumber
actor C as Customer
participant O as order-service
participant OB as outbox
participant K as Kafka
participant S as storage-service
C->>O: POST /api/orders/{id}/pay
rect rgb(238, 238, 238)
note over O,OB: one transaction
O->>O: state → PAID
O->>OB: OrderSentForApproval
end
O-->>C: 200 OK
OB->>K: relay publishes to order-events
note right of OB: marked processed only after<br/>the broker acknowledges it
K->>S: OrderSentForApproval
S->>S: assemble the car,<br/>save the assembly order
S->>K: AssemblyFinished (through its own outbox)
K->>O: AssemblyFinished
alt assembled
O->>O: state → READY_FOR_PICKUP
else failed
O->>O: state → CANCELED
end
Consumers are idempotent: a redelivered order-events message is ignored if an assembly order for
that source order already exists.
Note that the callback sets READY_FOR_PICKUP directly, so a custom order finished this way never
passes through AWAITING_DELIVERING — that state is only reachable by calling proceed()
manually. The two paths have drifted apart and are worth reconciling.
cd docker
cp .env.example .env
docker compose -f docker-file-dependency.yaml up --buildThat brings up Postgres, Keycloak, Kafka and both services. Postgres and Kafka are health-gated,
so the services wait for them instead of crash-looping against ddl-auto: validate.
| Service | URL |
|---|---|
| order-service | http://localhost:8082 |
| order-service Swagger | http://localhost:8082/swagger-ui.html |
| storage-service | http://localhost:8083 |
| storage-service Swagger | http://localhost:8083/swagger-ui.html |
| Keycloak | http://localhost:8081 |
| Postgres | localhost:5432 (order_db, storage_db) |
| Kafka | localhost:9092 |
Demo users & getting a token
The realm at docker/keycloak/autosalon-realm.json is imported on first start. It defines the
client autosalon-backend (public, direct access grants enabled) and four demo users:
| Username | Password | Realm role |
|---|---|---|
customer |
customer |
USER |
manager |
manager |
MANAGER |
warehouse |
warehouse |
WAREHOUSE_ADMIN |
admin |
admin |
ADMIN |
curl -s -X POST \
http://localhost:8081/realms/autosalon-realm/protocol/openid-connect/token \
-d grant_type=password \
-d client_id=autosalon-backend \
-d username=customer \
-d password=customer | jq -r .access_tokenRealm roles arrive as realm_access.roles and are mapped to ROLE_* authorities by
KeycloakRoleConverter; authorisation itself is @PreAuthorize on the controllers. These
credentials are for local development only.
./gradlew build # compile + unit tests
./gradlew test # unit tests only
./gradlew integrationTest # Testcontainers, needs a running Docker daemonUnit tests are plain JVM tests over the domain. Integration tests start a real Postgres per class via Testcontainers and, where Kafka is involved, an embedded broker. Both run in CI on every push.
Everything is documented in Swagger UI. The shape of it:
order-service
| Method | Path | Who |
|---|---|---|
POST |
/api/orders/stock |
USER, ADMIN |
POST |
/api/orders/custom |
USER, ADMIN |
GET |
/api/orders |
any authenticated — staff see all, customers see their own |
GET |
/api/orders/{id} |
staff, or the owner |
POST |
/api/orders/{id}/proceed |
MANAGER, WAREHOUSE_ADMIN, ADMIN |
POST |
/api/orders/{id}/pay |
staff, or the owner |
POST |
/api/orders/{id}/cancel |
ADMIN, or the owner |
POST |
/api/orders/{id}/claim |
MANAGER, WAREHOUSE_ADMIN, ADMIN |
GET |
/api/cars, /api/cars/{id} |
USER, MANAGER, ADMIN — proxied to storage over gRPC |
/api/test-drives/** |
create as USER; confirm/reject as staff |
storage-service
| Method | Path | Who |
|---|---|---|
GET |
/api/cars/available, /api/cars/search, /api/cars/{id} |
public |
POST |
/api/cars/create-stock, /api/cars/create-custom |
WAREHOUSE_ADMIN, ADMIN |
/api/car-models/** |
WAREHOUSE_ADMIN, ADMIN |
|
/api/components/** |
WAREHOUSE_ADMIN, ADMIN |
|
/api/assembly/** |
WAREHOUSE_ADMIN, ADMIN |
/api/cars/search takes any subset of brand, modelName, color, bodyType, fuelType,
driveType, transmissionType, minPrice/maxPrice, minHorsePower/maxHorsePower,
minEngineVolume/maxEngineVolume. Omitted criteria are ignored; numeric bounds are inclusive.
common/ shared kernel
outbox/ outbox message, publisher, relay scheduler
entity/ BaseEntity, auditing
security/ Keycloak role converter, security context helper
event/ cross-service event payloads
proto/ car_stock.proto — gRPC contract
order-service/
domain/order/state/ the nine order states
domain/service/ OrderService, TestDriveService, gRPC client
domain/listener/ storage-events consumer
presentation/ controllers, security, exception handling
storage-service/
domain/car/ Car, CarModel, enums
domain/component/ component hierarchy + factories
domain/assembly/ assembly orders
domain/filter/ CarSpecificationBuilder
domain/listener/ order-events consumer
presentation/grpc/ gRPC server
docker/ compose stack, Dockerfiles, Keycloak realm
Each service owns its schema through Liquibase; ddl-auto is validate, so a mapping that drifts
from the changelog fails at startup rather than silently reshaping the database.
| Language | Java 17 |
| Framework | Spring Boot 3.2.5 (Web, Data JPA, Security, OAuth2 Resource Server, Validation) |
| Build | Gradle, multi-module |
| Database | PostgreSQL, schema owned by Liquibase (ddl-auto: validate) |
| Messaging | Kafka (KRaft), transactional outbox with a polling relay |
| RPC | gRPC via net.devh:grpc-spring-boot-starter, protobuf 3.25 |
| Auth | Keycloak, realm roles mapped to Spring authorities |
| Mapping | MapStruct |
| Docs | springdoc-openapi / Swagger UI |
| Tests | JUnit 5, Mockito, Testcontainers, EmbeddedKafka |
| CI | GitHub Actions — assemble, unit tests, integration tests |