This document defines the requirements for implementing a new framework module in the performance benchmarking suite. The goal is to enable like-for-like performance comparison across Java frameworks by guaranteeing architectural, behavioral, and data parity.
The application is a simple "Fruit Store" domain: fruits sold at stores with per-store pricing. Every module implements the same domain model, the same REST API, the same data access patterns, and seeds the same test data. The only things that differ are the framework-specific annotations, DI mechanisms, and configuration idioms.
- MUST — Required for a fair comparison. Violations invalidate benchmark results.
- SHOULD — Strongly recommended. Deviations require justification.
- MAY — Allowed to vary. This is the framework-specific adaptation surface.
The quarkus3/ module serves as the reference implementation. When in doubt, match its behavior. The domain and dto packages are byte-identical across all modules and MUST be copied verbatim.
Root package: org.acme
| Package | Purpose | Portable? |
|---|---|---|
org.acme.domain |
JPA entity classes | Yes — copy verbatim |
org.acme.dto |
Data Transfer Objects | Yes — copy verbatim |
org.acme.repository |
Data access layer | No — framework-specific |
org.acme.service |
Business logic | No — framework-specific |
org.acme.rest |
REST controller | No — framework-specific |
| Package | Purpose |
|---|---|
org.acme.mapping |
Entity/DTO mappers (implementation-specific — any mapping approach is acceptable) |
org.acme.config |
Framework-specific configuration classes |
org.acme (root) |
Framework entry point class (e.g., SpringBoot3Application) |
All entity classes reside in org.acme.domain. They MUST be copied verbatim from the reference implementation — they use only jakarta.persistence.*, org.hibernate.annotations.*, and jakarta.validation.* annotations with no framework-specific imports.
@Embeddable Java record embedded into the stores table.
| Field | Type | Annotations |
|---|---|---|
address |
String |
@Column(nullable = false), @NotBlank(message = "Address is mandatory") |
city |
String |
@Column(nullable = false), @NotBlank(message = "City is mandatory") |
country |
String |
@Column(nullable = false), @NotBlank(message = "Country is mandatory") |
| Annotation | Value |
|---|---|
@Entity |
|
@Table |
name = "fruits" |
| Field | Type | Annotations |
|---|---|---|
id |
Long |
@Id, @GeneratedValue(strategy = SEQUENCE, generator = "fruits_seq"), @SequenceGenerator(name = "fruits_seq", sequenceName = "fruits_seq", allocationSize = 1) |
name |
String |
@Column(nullable = false, unique = true), @NaturalId, @NotBlank(message = "Name is mandatory") |
description |
String |
(none) |
storePrices |
List<StoreFruitPrice> |
@OneToMany(mappedBy = "fruit") |
Constructors: no-arg + (Long id, String name, String description). Standard getters/setters. toString() using StringJoiner.
| Annotation | Value |
|---|---|
@Entity |
|
@Table |
name = "stores" |
@Cacheable |
(L2 cache) |
| Field | Type | Annotations |
|---|---|---|
id |
Long |
@Id, @GeneratedValue(strategy = SEQUENCE, generator = "stores_seq"), @SequenceGenerator(name = "stores_seq", sequenceName = "stores_seq", allocationSize = 1) |
name |
String |
@Column(nullable = false, unique = true), @NaturalId, @NotBlank(message = "Name is mandatory") |
currency |
String |
@Column(nullable = false), @NotBlank(message = "Currency is mandatory") |
address |
Address |
@Embedded |
Constructors: no-arg + (Long id, String name, Address address, String currency). Standard getters/setters. toString() using StringJoiner.
@Embeddable Java record implementing Serializable. Composite primary key.
| Field | Type | Annotations |
|---|---|---|
storeId |
Long |
@Column(nullable = false) |
fruitId |
Long |
@Column(nullable = false) |
Convenience constructor: (Store store, Fruit fruit) — extracts IDs with null-safety.
| Annotation | Value |
|---|---|
@Entity |
|
@Table |
name = "store_fruit_prices" |
| Field | Type | Annotations |
|---|---|---|
id |
StoreFruitPriceId |
@EmbeddedId |
store |
Store |
@MapsId("storeId"), @ManyToOne(fetch = EAGER, optional = false), @JoinColumn(name = "store_id", nullable = false), @Fetch(FetchMode.SELECT), @Cache(usage = CacheConcurrencyStrategy.NONSTRICT_READ_WRITE) |
fruit |
Fruit |
@MapsId("fruitId"), @ManyToOne(fetch = LAZY, optional = false), @JoinColumn(name = "fruit_id", nullable = false) |
price |
BigDecimal |
@NotNull, @DecimalMin(value = "0.00", message = "Price must be >= 0"), @Digits(integer = 10, fraction = 2), @Column(nullable = false, precision = 12, scale = 2) |
Constructors: no-arg + (Store store, Fruit fruit, BigDecimal price). Setters for store and fruit MUST maintain composite key consistency by reconstructing StoreFruitPriceId.
All DTO classes reside in org.acme.dto. They MUST be copied verbatim. All are Java records.
record AddressDTO(
@NotBlank(message = "Address is mandatory") String address,
@NotBlank(message = "City is mandatory") String city,
@NotBlank(message = "Country is mandatory") String country
)
Compact constructor validates all fields are non-null and non-blank, throws IllegalArgumentException.
record StoreDTO(Long id, String name, String currency, AddressDTO address)
Compact constructor validates name and currency are non-null and non-blank.
record StoreFruitPriceDTO(StoreDTO store, float price)
Compact constructor validates price >= 0. Note: uses float (not BigDecimal).
record FruitDTO(
Long id,
@NotBlank(message = "Name is mandatory") String name,
String description,
List<StoreFruitPriceDTO> storePrices
)
Compact constructor validates name is non-null. Defaults storePrices to empty ArrayList if null.
The service layer MUST convert between domain entities and DTOs. The mapping approach is framework-specific — implementations MAY use any mechanism (hand-written mappers, MapStruct, framework-native conversion, etc.) as long as the REST API produces correct JSON output conforming to the OpenAPI spec (openapi.yml).
The existing modules use hand-written static mapper classes in org.acme.mapping:
| Class | Methods |
|---|---|
AddressMapper |
static AddressDTO map(Address), static Address map(AddressDTO) |
StoreMapper |
static StoreDTO map(Store), static Store map(StoreDTO) |
StoreFruitPriceMapper |
static StoreFruitPriceDTO map(StoreFruitPrice) (one-way only) |
FruitMapper |
static FruitDTO map(Fruit), static Fruit map(FruitDTO) |
This pattern is not required. New implementations MAY adopt it or use any alternative.
The following directed dependency graph defines which packages MAY reference which. Any dependency not listed is forbidden.
rest → service, dto
service → repository, dto, domain (+ any mapping mechanism)
repository → domain
dto → (no org.acme.* dependencies)
domain → (no org.acme.* dependencies)
config → (unrestricted — framework-specific)
restMUST NOT accessdomainorrepositorydtoMUST NOT accessdomaindomainMUST NOT access any otherorg.acme.*packagerepositoryMUST NOT accessdto,service, orrest
Base path: /fruits
| Method | Path | Request Body | Success | Failure |
|---|---|---|---|---|
GET |
/fruits |
— | 200, List<FruitDTO> |
— |
GET |
/fruits/{name} |
— | 200, FruitDTO |
404 (empty body) |
POST |
/fruits |
FruitDTO (JSON, validated) |
200, FruitDTO |
— |
- Class:
FruitControllerinorg.acme.rest - MUST depend only on
FruitService(no direct repository, mapper, or domain access) - MUST delegate all business logic to
FruitService - The
GET /{name}endpoint MUST return the framework's idiomatic response wrapper to enable 404 handling (Responsefor JAX-RS,ResponseEntityfor Spring MVC, etc.) - The
POSTendpoint MUST apply bean validation on the request body
Jackson MUST be configured with NON_EMPTY serialization inclusion. Response shape for a fruit:
{
"id": 1,
"name": "Apple",
"description": "Hearty fruit",
"storePrices": [
{
"store": {
"id": 1,
"name": "Store 1",
"currency": "USD",
"address": {
"address": "123 Main St",
"city": "Anytown",
"country": "USA"
}
},
"price": 1.29
}
]
}| Concern | Quarkus (JAX-RS) | Spring (MVC) |
|---|---|---|
| Controller class | @Path("/fruits") |
@RestController + @RequestMapping("/fruits") |
| GET | @GET |
@GetMapping |
| POST | @POST + @Consumes |
@PostMapping(consumes = ...) |
| Path parameter | @PathParam |
@PathVariable |
| Request body | (implicit) | @RequestBody |
| Response wrapper | jakarta.ws.rs.core.Response |
ResponseEntity |
Class: FruitService in org.acme.service
| Method | Signature | Transaction |
|---|---|---|
getAllFruits |
List<FruitDTO> getAllFruits() |
SUPPORTS / read-only |
getFruitByName |
Optional<FruitDTO> getFruitByName(String name) |
SUPPORTS / read-only |
createFruit |
FruitDTO createFruit(FruitDTO fruitDTO) |
REQUIRED (default) |
- MUST be a singleton/application-scoped bean (framework annotation varies)
- MUST depend on
FruitRepository - MUST convert between
Fruitentities andFruitDTO(mapping mechanism MAY vary) - Read operations MUST use SUPPORTS propagation
- Write operation MUST use default (REQUIRED) propagation
- Transaction annotation MAY vary (
jakarta.transaction.Transactionalvsorg.springframework.transaction.annotation.Transactional)
All three methods MUST be instrumented with named spans:
| Method | Span Name | Parameter Attributes |
|---|---|---|
getAllFruits |
FruitService.getAllFruits |
— |
getFruitByName |
FruitService.getFruitByName |
arg.name on the name parameter |
createFruit |
FruitService.createFruit |
arg.fruit on the fruitDTO parameter |
Instrumentation mechanism MAY vary:
- OpenTelemetry API:
@WithSpan/@SpanAttribute - Micrometer Observation API:
@Observed/@ObservationKeyValue - Other framework-native mechanisms that produce equivalent spans
Class or interface: FruitRepository in org.acme.repository
| Operation | Signature | Transaction |
|---|---|---|
| Find by name | Optional<Fruit> findByName(String name) |
SUPPORTS / read-only |
| List all | Returns List<Fruit> |
SUPPORTS / read-only |
| Persist | Saves a Fruit entity |
(inherited) |
| Framework | Approach | List all | Persist |
|---|---|---|---|
| Quarkus | implements PanacheRepository<Fruit> (class) |
listAll() |
persist(entity) |
| Spring | extends JpaRepository<Fruit, Long> (interface) |
findAll() |
save(entity) |
| Other | Framework-equivalent repository pattern | equivalent | equivalent |
MUST depend only on org.acme.domain.
| Table | Columns | Primary Key | Sequence |
|---|---|---|---|
fruits |
id (bigint), name (varchar, unique, not null), description (varchar) |
id via fruits_seq |
fruits_seq (allocationSize=1) |
stores |
id (bigint), name (varchar, unique, not null), currency (varchar, not null), address (varchar, not null), city (varchar, not null), country (varchar, not null) |
id via stores_seq |
stores_seq (allocationSize=1) |
store_fruit_prices |
store_id (bigint, FK), fruit_id (bigint, FK), price (numeric(12,2), not null) |
Composite (store_id, fruit_id) |
— |
Schema MAY be generated by Hibernate or by explicit DDL, but the result MUST be equivalent.
The seed data SQL MUST be identical across all modules (only the filename may differ: import.sql for Quarkus, data.sql for Spring, etc.).
Contents:
- 10 fruits (IDs 1-10): Apple, Pear, Banana, Orange, Strawberry, Mango, Grape, Pineapple, Watermelon, Kiwi
- 8 stores (IDs 1-8): Store 1 through Store 8, with specific addresses, cities, countries, and currencies (USD, EUR, GBP, JPY, CAD, AUD, EUR, MXN)
- 34 store-fruit-price records with specific (store_id, fruit_id, price) triples
- Sequence restarts:
fruits_seq RESTART WITH 11,stores_seq RESTART WITH 9
Reference file: quarkus3/src/main/resources/import.sql
| Concern | Required Value | Rationale |
|---|---|---|
| Database | PostgreSQL on localhost:5432, database fruits, user/password fruits |
Shared infrastructure |
| Jackson serialization | NON_EMPTY inclusion |
JSON output parity |
| Hibernate L2 cache | Enabled for Store entity and StoreFruitPrice.store association |
Cache behavior parity |
| Hibernate batch fetch size | 16 | Query behavior parity |
| Open Session in View | Disabled | Performance parity |
| Garbage collector | ParallelGC (-XX:+UseParallelGC) |
GC behavior and overhead parity |
| Trace sampling ratio | 10% (0.1) |
Observability overhead parity |
| Health endpoint | Exposed | Operational parity |
| Metrics endpoint | Prometheus-compatible, exposed | Observability parity |
- Configuration file format and property naming
- L2 cache provider setup mechanism
- Connection pool implementation (SHOULD target ~50 connections)
- HTTP server configuration
- GraalVM / native image configuration
- OpenTelemetry integration wiring
- Dev services / test database provisioning
- All
FruitServicemethods MUST emit named spans (see Section 8) - Trace sampling: 10%
- JDBC connections SHOULD be instrumented for tracing
A Prometheus-compatible metrics endpoint MUST be exposed via the framework's native actuator/health mechanism.
A health check endpoint MUST be exposed.
- Class:
FruitRepositoryTestsinorg.acme.repository - MUST run against a real PostgreSQL (testcontainers, dev services, or equivalent)
- MUST run within a transaction that rolls back after each test
- MUST test
findByName: persistFruit(null, "Grapefruit", "Summer fruit"), query by name, assert name, description, and thatidis non-null and> 2L
- Class:
FruitControllerTestsinorg.acme.rest - MUST mock the repository layer (not the service layer)
- MUST use a shared
createFruit()helper that builds:Fruit(1L, "Apple", "Hearty Fruit")with oneStoreFruitPrice:Store(1L, "Some Store", Address("123 Some St", "Some City", "USA"), "USD")price = BigDecimal.valueOf(1.29)
| Test | Behavior | Key Assertions |
|---|---|---|
getAll |
Mock list-all → one fruit | 200, size=1, all fields including nested store/address/price |
getFruitFound |
Mock findByName("Apple") → fruit | 200, all fields |
getFruitNotFound |
Mock findByName("Apple") → empty | 404 |
addFruit |
POST {"name":"Grapefruit","description":"Summer fruit"} |
200, name and description in response |
- All tests MUST verify mock interactions (
verify+verifyNoMoreInteractions)
MAY include integration tests in org.acme.e2e that run against the full application stack without mocks.
| Concern | Quarkus | Spring | Other |
|---|---|---|---|
| Test annotation | @QuarkusTest |
@SpringBootTest |
equivalent |
| Mock injection | @InjectMock |
@MockitoBean |
equivalent |
| HTTP testing | REST Assured | MockMvc | equivalent |
| Database | Dev services | Testcontainers | equivalent |
| Transaction rollback | @TestTransaction |
@Transactional |
equivalent |
The following aspects are explicitly allowed to vary between implementations. They represent the boundary where frameworks use their idiomatic approaches.
| Concern | Examples |
|---|---|
| Bean declaration | @ApplicationScoped, @Service, @Singleton, @jakarta.inject.Singleton |
| Injection style | Constructor injection, field injection, method injection — any style is acceptable |
| Injection trigger | @Inject (CDI), @Autowired (Spring), implicit, etc. |
See the table in Section 7.
See the table in Section 9.
| Framework | Import |
|---|---|
| Quarkus (CDI) | jakarta.transaction.Transactional with TxType.SUPPORTS |
| Spring | org.springframework.transaction.annotation.Transactional with propagation = SUPPORTS, readOnly = true |
| Other | Framework equivalent |
The org.acme.config package is fully framework-specific. Examples from existing modules:
L2CacheConfiguration— programmatic JCache/Caffeine setup (Spring)GraalVMConfig— native image runtime hints (Spring)DataSourceConfig— JDBC telemetry wrapping (Spring Boot 4)OpentelemetryConfiguration— metrics beans (Spring Boot 4)
A new framework MAY add any configuration classes needed, provided they reside in org.acme.config.
The mapping approach is entirely up to the implementor. Examples:
| Approach | Used By |
|---|---|
Hand-written static mapper classes in org.acme.mapping |
quarkus3, springboot3, springboot4 |
| MapStruct or similar code-generation mapper | (alternative) |
| Framework-native conversion | (alternative) |
| Inline mapping in service methods | (alternative) |
The only requirement is that the REST API produces correct JSON conforming to the OpenAPI spec.
- Quarkus: no explicit main class needed
- Spring Boot:
@SpringBootApplicationclass inorg.acme - Other: framework equivalent
The following rules are candidates for automated enforcement:
| Category | Rule | Enforceable? |
|---|---|---|
| Packages | All production classes reside in specified packages | Yes |
| Packages | Package dependency rules (Section 6) | Yes |
| Domain | Entity classes exist with correct names and annotations | Yes |
| Domain | Entity classes have no framework-specific imports | Yes |
| DTOs | DTO classes are Java records | Yes |
| DTOs | DTOs have no dependencies on domain package | Yes |
| Service | FruitService depends only on repository + dto + domain (+ mapping) | Yes |
| REST | FruitController depends only on service + dto | Yes |
| Repository | FruitRepository depends only on domain | Yes |
| Data | Seed data is identical across modules | No (file comparison) |
| Config | Required runtime properties are set | No (integration tests) |
When creating a new module (e.g., micronaut/):
- Copy
org.acme.domainandorg.acme.dtoverbatim from the reference implementation - Copy the seed data SQL file (adjust filename if needed by framework convention)
- Implement entity/DTO mapping using your preferred approach (see Section 5)
- Implement
FruitRepositoryinorg.acme.repositoryusing framework-idiomatic data access - Implement
FruitServiceinorg.acme.servicematching the contract in Section 8 - Implement
FruitControllerinorg.acme.restmatching the API contract in Section 7 - Add any framework-specific configuration in
org.acme.config - Configure mandatory settings from Section 11 using framework-native configuration
- Write tests per Section 13
- Verify the REST API returns identical JSON for identical requests (conforming to
openapi.yml) - Verify all ArchUnit rules pass (when available)