Skip to content
notakeithPublic

About

Car dealership backend: order + warehouse microservices on Spring Boot, gRPC + Kafka, transactional outbox, Keycloak.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Autosalon

Русская версия

Java Spring Boot Kafka gRPC Keycloak

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.

Features

  • 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

Architecture

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
Loading

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.

Domain

Orders

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 --> [*]
Loading

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.

Warehouse

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
Loading

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.

Getting Started

cd docker
cp .env.example .env
docker compose -f docker-file-dependency.yaml up --build

That 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_token

Realm 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 daemon

Unit 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.

API Reference

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.

Layout

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.

Tech Stack

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

About

Car dealership backend: order + warehouse microservices on Spring Boot, gRPC + Kafka, transactional outbox, Keycloak.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Contributors

Languages