| name | arcana-springboot-developer-skill |
| description | Spring Boot development guide based on Arcana Cloud SpringBoot enterprise architecture. Provides comprehensive support for Clean Architecture, dual-protocol (gRPC/REST), OSGi Plugin System, Server-Side Rendering, and enterprise security. Suitable for Spring Boot project development, architecture design, code review, and debugging. |
| allowed-tools | ["Read","Grep","Glob","Bash","Write","Edit"] |
Spring Boot Developer Skill
Professional Spring Boot development skill based on Arcana Cloud SpringBoot enterprise architecture.
⚡ Workflow — Always Start From the Reference Project
EVERY task starts by cloning the complete reference project: git clone https://github.com/jrjohn/arcana-cloud-springboot.git — never scaffold from scratch (no Spring Initializr, no hand-rolled Gradle setup).
- Clone the reference project:
git clone https://github.com/jrjohn/arcana-cloud-springboot.git [new-project-directory]
- Establish a green baseline — build and test the UNTOUCHED clone first, before changing anything:
./gradlew clean build && ./gradlew test
If the pristine clone is not green, fix the environment (JDK, network) before touching code.
- Rename + strip demo endpoints — follow 0. Project Setup - CRITICAL to rename the project/package and delete the example User Management API, while explicitly KEEPING infrastructure: gRPC server setup, DI container/config, security/auth (
config/, security/), deployment modes/configs (deployment/), and the proto toolchain (src/main/proto/ + generateProto).
- Add features by copying the nearest working entity, then adapting (see 🔁 Adding a feature) — the File-by-File Feature Recipe — New Entity End-to-End below is the completeness checklist (keep the build green after each layer).
🔁 Adding a feature = copy the nearest working feature (NOT from scratch)
When you add a new feature/entity, do NOT re-create it from memory by walking the File-by-File Recipe on a blank slate. Copy the nearest already-working feature in the cloned reference, then adapt it.
- Find the closest conformant feature already in the reference — e.g. the example
user entity implemented end-to-end (handler/controller → service trait + impl → repository → model/DTO, with AppError/Result error handling and tests) — it already demonstrates the full layered pattern.
- Duplicate its ENTIRE file set (domain model, DTOs + mapper, repository interface, repository impl, service interface, service impl, REST controller, gRPC proto, gRPC service impl, DI wiring, mock/seed data, and unit tests per layer) 1:1, keeping every layer.
- Rename + adapt to the new domain (types, routes, endpoints, DI/AppState bindings).
- Diff against the original to confirm nothing was dropped — same layer split, same service/repository trait boundary, same AppError/Result error model, same tests.
Why this is mandatory: the File-by-File Recipe is a checklist of what must exist, not a from-scratch build order. Re-deriving the pattern each time makes every step a chance to skip a layer (the service trait, the repository), wire a shortcut (handler → DB/API directly, Result<_, String> instead of AppError), or drop the tests — the "vibe-coding" deviations that compile and pass coverage but fail architecture review. Copying a known-good feature carries conformance in by construction; the recipe is then only your verification that nothing is missing.
Supporting files — load on demand
| File | When to read |
|---|
reference.md | Full API/config reference — exact annotations, properties, and options |
patterns.md | Architecture & code patterns beyond what this file covers |
patterns/service-layer.md | Service layer deep dive — transactions, orchestration, interfaces vs impls |
examples.md | Complete worked examples to copy-adapt |
checklists/production-ready.md | Pre-ship checklist before declaring a task done |
verification/commands.md | Verification/grep commands for wiring and stub audits |
Quick Reference Card
New REST Endpoint Checklist:
1. Add method to Controller with @GetMapping/@PostMapping
2. Add method signature to Service interface
3. Implement method in ServiceImpl with @Override
4. Add Repository method if data access needed
5. Add @Valid for RequestBody validation
6. Add @PreAuthorize for security if needed
7. Verify mock data returns non-empty values
New gRPC Service Checklist:
1. Define service in src/main/proto/*.proto
2. Run ./gradlew generateProto
3. Create GrpcService class extending generated ImplBase
4. Add @GrpcService annotation
5. Implement ALL rpc methods (count must match)
6. Wire to existing Service layer
Quick Diagnosis:
| Symptom | Check Command |
|---|
| Empty response | `grep "List.of()\ |
| 404 on endpoint | Check Service method exists for Controller call |
| gRPC UNIMPLEMENTED | Compare rpc count in .proto vs @Override in GrpcService |
| 500 error | grep "throw.*UnsupportedOperationException" src/main/java/ |
Rules Priority
🔴 CRITICAL (Must Fix Immediately)
| Rule | Description | Verification |
|---|
| Zero-Empty Policy | Repository stubs NEVER return empty lists | `grep "emptyList\ |
| API Wiring | ALL Controller methods must call existing Service methods | Check Controller→Service calls |
| gRPC Implementation | ALL proto rpc methods MUST be implemented | Count rpc vs @Override |
| Security | ALL non-public endpoints MUST have authentication | Check @PreAuthorize usage |
🟡 IMPORTANT (Should Fix Before PR)
| Rule | Description | Verification |
|---|
| Input Validation | All endpoints use @Valid | `grep "@RequestBody" *.java |
| Mock Data Quality | Realistic, varied values (not all same) | Review mock data |
| Error Handling | Global exception handler configured | Check @ControllerAdvice |
| Transaction Management | Service methods have @Transactional | Check ServiceImpl classes |
🟢 RECOMMENDED (Nice to Have)
| Rule | Description |
|---|
| API Documentation | OpenAPI/Swagger annotations |
| Monitoring | Actuator endpoints enabled |
| Caching | Redis/Caffeine caching for hot data |
| Rate Limiting | API rate limits configured |
Error Handling Pattern
ApiException - Unified Error Model
@Getter
public class ApiException extends RuntimeException {
private final ErrorCode errorCode;
private final HttpStatus httpStatus;
private final Map<String, Object> details;
public enum ErrorCode {
NETWORK_UNAVAILABLE,
TIMEOUT,
SERVICE_UNAVAILABLE,
UNAUTHORIZED,
TOKEN_EXPIRED,
INVALID_CREDENTIALS,
ACCESS_DENIED,
NOT_FOUND,
VALIDATION_FAILED,
CONFLICT,
DATA_INTEGRITY_ERROR,
INTERNAL_ERROR
}
public static ApiException notFound(String message) {
return new ApiException(ErrorCode.NOT_FOUND, HttpStatus.NOT_FOUND, message);
}
public static ApiException unauthorized(String message) {
return new ApiException(ErrorCode.UNAUTHORIZED, HttpStatus.UNAUTHORIZED, message);
}
public static ApiException validation(String message, Map<String, Object> details) {
return new ApiException(ErrorCode.VALIDATION_FAILED, HttpStatus.BAD_REQUEST, message, details);
}
}
Error Handling Flow
┌─────────────────────────────────────────────────────────────────┐
│ Error Flow │
├─────────────────────────────────────────────────────────────────┤
│ Repository Layer: │
│ - Catch database exceptions │
│ - Map to ApiException with appropriate code │
│ - Throw ApiException │
├─────────────────────────────────────────────────────────────────┤
│ Service Layer: │
│ - Catch repository exceptions │
│ - Add business context if needed │
│ - Re-throw as ApiException │
├─────────────────────────────────────────────────────────────────┤
│ Controller Layer: │
│ - Let exceptions propagate to GlobalExceptionHandler │
│ - Or handle specific cases with try-catch │
├─────────────────────────────────────────────────────────────────┤
│ GlobalExceptionHandler (@ControllerAdvice): │
│ - Map ApiException to ErrorResponse │
│ - Set appropriate HTTP status │
│ - Return consistent error format │
└─────────────────────────────────────────────────────────────────┘
Global Exception Handler
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
@ExceptionHandler(ApiException.class)
public ResponseEntity<ErrorResponse> handleApiException(ApiException ex) {
log.error("API error: {} - {}", ex.getErrorCode(), ex.getMessage());
ErrorResponse response = ErrorResponse.builder()
.code(ex.getErrorCode().name())
.message(ex.getMessage())
.details(ex.getDetails())
.timestamp(Instant.now())
.build();
return ResponseEntity.status(ex.getHttpStatus()).body(response);
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidationException(MethodArgumentNotValidException ex) {
Map<String, String> errors = ex.getBindingResult().getFieldErrors().stream()
.collect(Collectors.toMap(
FieldError::getField,
FieldError::getDefaultMessage,
(a, b) -> a
));
ErrorResponse response = ErrorResponse.builder()
.code("VALIDATION_FAILED")
.message("Validation failed")
.details(Map.of("fields", errors))
.timestamp(Instant.now())
.build();
return ResponseEntity.badRequest().body(response);
}
}
Test Coverage Targets
Coverage by Layer
| Layer | Target | Focus Areas |
|---|
| Controller | 80%+ | Request mapping, validation, response codes |
| Service | 90%+ | Business logic, edge cases, transactions |
| Repository | 75%+ | Query methods, data mapping |
| Integration | 60%+ | End-to-end flows |
What to Test
Controller Tests (MockMvc):
@WebMvcTest(UserController.class)
class UserControllerTest {
@Autowired MockMvc mockMvc;
@MockBean UserService userService;
@Test
void getUser_WhenExists_Returns200() throws Exception {
when(userService.findById("123")).thenReturn(Optional.of(testUser));
mockMvc.perform(get("/api/users/123"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value("123"));
}
@Test
void createUser_WhenInvalid_Returns400() throws Exception {
mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content("{}"))
.andExpect(status().isBadRequest());
}
}
Service Tests (Unit):
@ExtendWith(MockitoExtension.class)
class UserServiceImplTest {
@Mock UserRepository userRepository;
@InjectMocks UserServiceImpl userService;
@Test
void create_WhenEmailExists_ThrowsValidationException() {
when(userRepository.existsByEmail("test@test.com")).thenReturn(true);
assertThatThrownBy(() -> userService.create(request))
.isInstanceOf(ApiException.class)
.hasFieldOrPropertyWithValue("errorCode", ErrorCode.CONFLICT);
}
}
Test Commands
./gradlew test jacocoTestReport
open build/reports/jacoco/test/html/index.html
Core Architecture Principles
Clean Architecture - Three Layers
┌─────────────────────────────────────────────────────┐
│ Controller Layer │
│ REST/gRPC Endpoints + Auth │
├─────────────────────────────────────────────────────┤
│ Service Layer │
│ Business Logic + Orchestration │
├─────────────────────────────────────────────────────┤
│ Repository Layer │
│ Data Access + Caching + Sync │
└─────────────────────────────────────────────────────┘
Deployment Modes
- Monolithic: All layers colocated (development)
- Layered + HTTP: Separate containers with HTTP
- Layered + gRPC: Separate containers with gRPC (2.5x faster)
- Kubernetes + HTTP: K8s deployment with HTTP
- Kubernetes + gRPC: K8s deployment with TLS-secured gRPC
Instructions
When handling Spring Boot development tasks, follow these principles:
Quick Verification Commands
Use these commands to quickly check for common issues:
grep -rn "throw.*UnsupportedOperationException\|TODO.*implement\|throw.*NotImplementedException" src/main/java/
echo "REST endpoints:" && grep -c "@GetMapping\|@PostMapping\|@PutMapping\|@DeleteMapping\|@RequestMapping" src/main/java/**/controller/*.java 2>/dev/null || echo 0
echo "gRPC methods in proto:" && grep -c "rpc " src/main/proto/*.proto 2>/dev/null || echo 0
echo "gRPC methods implemented:" && grep -c "@Override" src/main/java/**/grpc/*GrpcService.java 2>/dev/null || echo 0
./gradlew clean build
./gradlew test
echo "=== Service Methods Called in Controllers ===" && \
grep -roh "[a-zA-Z]*Service\.[a-zA-Z]*(" src/main/java/**/controller/*.java | sort -u
echo "=== Service Methods Defined ===" && \
grep -rh "public.*(" src/main/java/**/service/*.java | grep -oE "[a-zA-Z]+\(" | sort -u
echo "=== Controller Injection Points ===" && \
grep -rn "private.*final.*Service" src/main/java/**/controller/*.java
echo "=== Service Implementation Check ===" && \
grep -rn "@Service" src/main/java/**/service/*.java
grep -rn "@.*Mapping" -A5 src/main/java/**/controller/*.java | grep -E "return null|return ResponseEntity.ok\(\)|// TODO"
echo "=== Repository Methods Called in Services ===" && \
grep -roh "[a-zA-Z]*Repository\.[a-zA-Z]*(" src/main/java/**/service/*.java | sort -u
echo "=== Repository Interface Methods ===" && \
grep -rh "[A-Za-z]* [a-zA-Z]*(" src/main/java/**/repository/*Repository.java | grep -oE "[a-zA-Z]+\(" | sort -u
echo "=== Repository Interface Methods ===" && \
grep -rh "[A-Za-z]* [a-zA-Z]*(" src/main/java/**/repository/*Repository.java | grep -oE "[a-zA-Z]+\(" | sort -u
echo "=== Repository Implementation Methods ===" && \
grep -rh "@Override\|public.*(" src/main/java/**/repository/*RepositoryImpl.java | grep -oE "[a-zA-Z]+\(" | sort -u
⚠️ CRITICAL: All gRPC methods defined in .proto files MUST be implemented in GrpcService classes. Missing implementations cause runtime errors.
⚠️ API WIRING CRITICAL: Commands #6-#8 detect Controller endpoints that call Service methods that don't exist or are not implemented. A Controller can call userService.getAccountInfo() but if the Service class doesn't have this method or throws UnsupportedOperationException, the endpoint fails at runtime!
If any of these return results or counts don't match, FIX THEM before completing the task.
📊 Mock Data Requirements for Repository Stubs
The Chart Data Problem
When implementing Repository stubs, NEVER return empty lists for data that powers UI charts or API responses. This causes:
- Frontend charts that render but show nothing
- API responses with empty data arrays
- Client applications showing "No data" even when structure exists
Mock Data Rules
Rule 1: List data for charts MUST have at least 7 items
public WeeklySummary getCurrentWeekSummary(String userId) {
return new WeeklySummary(
List.of()
);
}
public WeeklySummary getCurrentWeekSummary(String userId) {
List<DailyReport> mockDailyReports = IntStream.range(0, 7)
.mapToObj(i -> createMockDailyReport(
new int[]{72, 78, 85, 80, 76, 88, 82}[i],
new int[]{390, 420, 450, 410, 380, 460, 435}[i]
))
.collect(Collectors.toList());
return new WeeklySummary(mockDailyReports);
}
Rule 2: Use realistic, varied sample values
List<Integer> scores = Collections.nCopies(7, 80);
int[] scores = {72, 78, 85, 80, 76, 88, 82};
Rule 3: Data must match DTO/Entity exactly
grep -A 20 "class TherapyData" src/main/java/**/dto/*.java
grep -A 20 "class TherapyData" src/main/java/**/model/*.java
Rule 4: Create helper methods for complex mock data
private DailyReport createMockDailyReport(int score, int duration) {
return DailyReport.builder()
.id(UUID.randomUUID().toString())
.sleepScore(score)
.sleepDuration(new SleepDuration(duration, ...))
.build();
}
Quick Verification Commands for Mock Data
grep -rn "List.of()\|Collections.emptyList()\|new ArrayList<>()" src/main/java/**/repository/*RepositoryImpl.java
grep -rn "dailyReports\|weeklyData\|chartData" src/main/java/**/repository/ | grep -E "emptyList|List\.of\(\)"
0. Project Setup - CRITICAL
⚠️ IMPORTANT: This reference project has been validated with tested Gradle settings and library versions. NEVER reconfigure project structure or modify build.gradle / gradle.properties, or it will cause compilation errors.
Step 1: Clone the reference project
git clone https://github.com/jrjohn/arcana-cloud-springboot.git [new-project-directory]
cd [new-project-directory]
Step 2: Reinitialize Git (remove original repo history)
rm -rf .git
git init
git add .
git commit -m "Initial commit from arcana-cloud-springboot template"
Step 3: Modify project name and package
Only modify the following required items:
rootProject.name in settings.gradle
group and archivesBaseName in build.gradle
- Rename package directory structure under
src/main/java/
- Update application name in
application.yml
⚠️ Rename warning: the Java package rename touches every import and package declaration in the project — a partial rename leaves a broken mix of com.arcana and new-package references. Use an IDE refactor (IntelliJ → Refactor → Rename Package), or do it mechanically:
grep -rl 'com.arcana' src/ | xargs sed -i '' 's/com.arcana/<new.package>/g'
mkdir -p src/main/java/<new/package/path>
git mv src/main/java/com/arcana/* src/main/java/<new/package/path>/
./gradlew clean build
Step 4: Clean up example code
The cloned project contains example API (e.g., Arcana User Management). Clean up and replace with new project business logic:
Core architecture files to KEEP (do not delete):
src/main/java/.../config/ - Common configuration (Security, gRPC, Database)
src/main/java/.../common/ - Common utilities
src/main/java/.../exception/ - Exception handling
arcana-plugin-api/ - Plugin interface definitions
arcana-plugin-runtime/ - OSGi runtime
deployment/ - Docker & K8s manifests
Example files to REPLACE:
src/main/java/.../controller/ - Delete example Controller, create new REST/gRPC endpoints
src/main/java/.../service/ - Delete example Service, create new business logic
src/main/java/.../repository/ - Delete example Repository, create new data access
src/main/java/.../model/ - Delete example Models, create new Domain Models
src/main/java/.../dto/ - Delete example DTOs, create new DTOs
src/main/proto/ - Modify gRPC proto definitions
Step 5: Verify build
./gradlew clean build
❌ Prohibited Actions
- DO NOT create new Spring Boot project from scratch (Spring Initializr)
- DO NOT modify version numbers in
gradle.properties or libs.versions.toml
- DO NOT add or remove dependencies (unless explicitly required)
- DO NOT modify Gradle wrapper version
- DO NOT reconfigure gRPC, OSGi, Spring Security, or other library settings
✅ Allowed Modifications
- Add business-related Java code (following existing architecture)
- Add Controller, Service, Repository
- Add Domain Models, DTOs
- Modify settings in
application.yml
- Add gRPC proto files (and recompile)
- Develop new Plugins
1. TDD & Spec-Driven Development Workflow - MANDATORY
⚠️ CRITICAL: All development MUST follow this TDD workflow. Every SRS/SDD requirement must have corresponding tests BEFORE implementation.
🚨 ABSOLUTE RULE: TDD = Tests + Implementation. Writing tests without implementation is INCOMPLETE. Every test file MUST have corresponding production code that passes the tests.
┌─────────────────────────────────────────────────────────────────┐
│ TDD Development Workflow │
├─────────────────────────────────────────────────────────────────┤
│ Step 1: Analyze Spec → Extract all SRS & SDD requirements │
│ Step 2: Create Tests → Write tests for EACH Spec item │
│ Step 3: Verify Coverage → Ensure 100% Spec coverage in tests │
│ Step 4: Implement → Build features to pass tests ⚠️ MANDATORY │
│ Step 5: Mock APIs → Use mock data for unfinished dependencies │
│ Step 6: Run All Tests → ALL tests must pass before completion │
│ Step 7: Verify 100% → Tests written = Features implemented │
└─────────────────────────────────────────────────────────────────┘
⛔ FORBIDDEN: Tests Without Implementation
⛔ Placeholder Endpoint Policy
Placeholder endpoints are ONLY allowed as a temporary route during active development. They are FORBIDDEN as a final state.
@GetMapping("/training")
public ResponseEntity<?> training() {
return ResponseEntity.ok(Map.of("message", "Coming Soon"));
}
@GetMapping("/training")
public ResponseEntity<List<TrainingDto>> training() {
return ResponseEntity.ok(trainingService.getAll());
}
Placeholder Check Command:
grep -rn "UnsupportedOperationException\|NotImplementedException\|TODO.*implement\|Coming Soon" src/main/java/
Step 1: Analyze Spec Documents (SRS & SDD)
Before writing any code, extract ALL requirements from both SRS and SDD:
Step 2: Create Test Cases for Each Spec Item
@SpringBootTest
@Transactional
class AuthServiceTest {
@Autowired
private AuthService authService;
@MockBean
private UserRepository userRepository;
@MockBean
private PasswordEncoder passwordEncoder;
@Test
void login_WithValidCredentials_ShouldSucceed() {
User mockUser = new User("1", "test@test.com", "hashedPassword", "Test User");
when(userRepository.findByEmail("test@test.com")).thenReturn(Optional.of(mockUser));
when(passwordEncoder.matches("password123", "hashedPassword")).thenReturn(true);
AuthResponse response = authService.login("test@test.com", "password123");
assertNotNull(response);
assertNotNull(response.getAccessToken());
}
@Test
void login_WithInvalidCredentials_ShouldThrowException() {
when(userRepository.findByEmail(any())).thenReturn(Optional.empty());
assertThrows(AuthenticationException.class, () -> {
authService.login("invalid@test.com", "wrong");
});
}
@Test
void login_ShouldReturnJwtWithRS256Algorithm() {
User mockUser = new User("1", "test@test.com", "hashedPassword", "Test User");
when(userRepository.findByEmail(any())).thenReturn(Optional.of(mockUser));
when(passwordEncoder.matches(any(), any())).thenReturn(true);
AuthResponse response = authService.login("test@test.com", "password123");
String token = response.getAccessToken();
DecodedJWT jwt = JWT.decode(token);
assertEquals("RS256", jwt.getAlgorithm());
}
@Test
void login_TokenShouldExpireIn24Hours() {
User mockUser = new User("1", "test@test.com", "hashedPassword", "Test User");
when(userRepository.findByEmail(any())).thenReturn(Optional.of(mockUser));
when(passwordEncoder.matches(any(), any())).thenReturn(true);
AuthResponse response = authService.login("test@test.com", "password123");
DecodedJWT jwt = JWT.decode(response.getAccessToken());
long expirationHours = ChronoUnit.HOURS.between(
jwt.getIssuedAt().toInstant(),
jwt.getExpiresAt().toInstant()
);
assertEquals(24, expirationHours);
}
}
Step 3: Spec Coverage Verification Checklist
Before implementation, verify ALL SRS and SDD items have tests:
Step 4: Mock External Dependencies - MANDATORY
⚠️ CRITICAL: Every Repository/Service method MUST return valid mock data. NEVER leave methods throwing UnsupportedOperationException or NotImplementedException.
Rules for Mock Classes:
- ALL methods must return valid mock data
- Use
Thread.sleep() or @Async with delay to simulate latency (500-1000ms)
- Mock data must match the entity/DTO structure exactly
- Check Enum values exist before using them
- Include all required fields for entities
For external services or databases not yet available, implement mock classes:
@Repository
@Profile("test")
public class MockUserRepository implements UserRepository {
private static final List<User> MOCK_USERS = List.of(
new User("1", "test@test.com", "$2a$12$...", "Test User"),
new User("2", "demo@demo.com", "$2a$12$...", "Demo User")
);
@Override
public Optional<User> findByEmail(String email) {
return MOCK_USERS.stream()
.filter(u -> u.getEmail().equals(email))
.findFirst();
}
@Override
public Optional<User> findById(String id) {
return MOCK_USERS.stream()
.filter(u -> u.getId().equals(id))
.findFirst();
}
@Override
public User save(User user) {
return user;
}
}
spring:
profiles: test
datasource:
url: jdbc:h2:mem:testdb
driver-class-name: org.h2.Driver
Step 5: Run All Tests Before Completion
./gradlew test
./gradlew test jacocoTestReport
./gradlew integrationTest
./gradlew test --tests "com.example.service.AuthServiceTest"
./gradlew check
Test Directory Structure
src/
├── main/java/... # Production code
├── test/java/... # Unit & Integration tests
│ ├── controller/
│ │ ├── AuthControllerTest.java
│ │ └── UserControllerTest.java
│ ├── service/
│ │ ├── AuthServiceTest.java
│ │ └── UserServiceTest.java
│ ├── repository/
│ │ └── UserRepositoryTest.java
│ ├── grpc/
│ │ └── AuthGrpcServiceTest.java
│ └── mock/
│ ├── MockUserRepository.java
│ └── MockExternalApiClient.java
└── testFixtures/java/... # Shared test utilities
└── TestDataFactory.java
2. Project Structure
arcana-cloud-springboot/
├── arcana-plugin-api/ # Plugin interface definitions
├── arcana-plugin-runtime/ # OSGi runtime management
├── arcana-ssr-engine/ # Server-side rendering
├── arcana-web/ # React/Angular apps
├── src/
│ └── main/
│ ├── java/
│ │ └── com/arcana/
│ │ ├── controller/ # REST/gRPC endpoints
│ │ ├── service/ # Business logic
│ │ ├── repository/ # Data access
│ │ ├── model/ # Domain models
│ │ ├── dto/ # Data transfer objects
│ │ ├── config/ # Configuration
│ │ └── security/ # Security config
│ └── resources/
├── config/ # External configuration
├── deployment/ # Docker & K8s manifests
└── plugins/ # Sample plugins
3. Dual-Protocol Support (gRPC + REST)
gRPC Service Definition
syntax = "proto3";
package com.arcana.user;
option java_multiple_files = true;
option java_package = "com.arcana.grpc.user";
service UserService {
rpc GetUser (GetUserRequest) returns (UserResponse);
rpc ListUsers (ListUsersRequest) returns (ListUsersResponse);
rpc CreateUser (CreateUserRequest) returns (UserResponse);
rpc UpdateUser (UpdateUserRequest) returns (UserResponse);
rpc DeleteUser (DeleteUserRequest) returns (Empty);
}
message GetUserRequest {
string id = 1;
}
message UserResponse {
string id = 1;
string name = 2;
string email = 3;
int64 created_at = 4;
}
message ListUsersRequest {
int32 page = 1;
int32 size = 2;
}
message ListUsersResponse {
repeated UserResponse users = 1;
int32 total = 2;
}
gRPC Service Implementation
@GrpcService
public class UserGrpcService extends UserServiceGrpc.UserServiceImplBase {
private final UserService userService;
public UserGrpcService(UserService userService) {
this.userService = userService;
}
@Override
public void getUser(GetUserRequest request, StreamObserver<UserResponse> responseObserver) {
try {
User user = userService.findById(request.getId())
.orElseThrow(() -> new UserNotFoundException(request.getId()));
responseObserver.onNext(toProto(user));
responseObserver.onCompleted();
} catch (Exception e) {
responseObserver.onError(
Status.NOT_FOUND
.withDescription(e.getMessage())
.asRuntimeException()
);
}
}
@Override
public void listUsers(ListUsersRequest request, StreamObserver<ListUsersResponse> responseObserver) {
Page<User> page = userService.findAll(
PageRequest.of(request.getPage(), request.getSize())
);
ListUsersResponse response = ListUsersResponse.newBuilder()
.addAllUsers(page.getContent().stream().map(this::toProto).toList())
.setTotal((int) page.getTotalElements())
.build();
responseObserver.onNext(response);
responseObserver.onCompleted();
}
private UserResponse toProto(User user) {
return UserResponse.newBuilder()
.setId(user.getId())
.setName(user.getName())
.setEmail(user.getEmail())
.setCreatedAt(user.getCreatedAt().toEpochMilli())
.build();
}
}
REST Controller
@RestController
@RequestMapping("/api/v1/users")
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
@GetMapping("/{id}")
public ResponseEntity<UserDto> getUser(@PathVariable String id) {
return userService.findById(id)
.map(UserDto::from)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
@GetMapping
public ResponseEntity<PageResponse<UserDto>> listUsers(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size) {
Page<User> result = userService.findAll(PageRequest.of(page, size));
return ResponseEntity.ok(PageResponse.from(result, UserDto::from));
}
@PostMapping
public ResponseEntity<UserDto> createUser(@Valid @RequestBody CreateUserRequest request) {
User user = userService.create(request.toEntity());
return ResponseEntity.status(HttpStatus.CREATED).body(UserDto.from(user));
}
@PutMapping("/{id}")
public ResponseEntity<UserDto> updateUser(
@PathVariable String id,
@Valid @RequestBody UpdateUserRequest request) {
return userService.update(id, request.toEntity())
.map(UserDto::from)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteUser(@PathVariable String id) {
userService.delete(id);
return ResponseEntity.noContent().build();
}
}
4. OSGi Plugin System
Plugin Interface
public interface ArcanaPlugin {
String getKey();
String getName();
String getVersion();
void onStart(PluginContext context);
void onStop();
}
public interface PluginContext {
<T> T getService(Class<T> serviceClass);
void registerBean(String name, Object bean);
void registerEndpoint(String path, Object controller);
void registerEventListener(String event, EventListener listener);
}
Plugin Implementation
@ArcanaPluginManifest(
key = "user-analytics",
name = "User Analytics Plugin",
version = "1.0.0",
description = "Provides user analytics and reporting"
)
public class UserAnalyticsPlugin implements ArcanaPlugin {
private PluginContext context;
private AnalyticsService analyticsService;
@Override
public String getKey() { return "user-analytics"; }
@Override
public String getName() { return "User Analytics Plugin"; }
@Override
public String getVersion() { return "1.0.0"; }
@Override
public void onStart(PluginContext context) {
this.context = context;
UserService userService = context.getService(UserService.class);
this.analyticsService = new AnalyticsService(userService);
context.registerBean("analyticsService", analyticsService);
context.registerEndpoint("/api/v1/analytics", new AnalyticsController(analyticsService));
context.registerEventListener("user.created", event -> {
analyticsService.trackUserCreated((User) event.getData());
});
}
@Override
public void onStop() {
}
}
Plugin Runtime Manager
@Service
@RequiredArgsConstructor
public class PluginRuntimeManager {
private final BundleContext bundleContext;
private final ApplicationContext applicationContext;
private final RedisTemplate<String, String> redisTemplate;
private final Map<String, Bundle> installedPlugins = new ConcurrentHashMap<>();
public void installPlugin(Path jarPath) throws BundleException {
if (!verifySignature(jarPath)) {
throw new SecurityException("Invalid plugin signature");
}
String location = jarPath.toUri().toString();
Bundle bundle = bundleContext.installBundle(location);
bundle.start();
String pluginKey = getPluginKey(bundle);
installedPlugins.put(pluginKey, bundle);
syncToCluster(pluginKey, "INSTALLED");
}
public void enablePlugin(String pluginKey) throws BundleException {
Bundle bundle = installedPlugins.get(pluginKey);
if (bundle != null && bundle.getState() != Bundle.ACTIVE) {
bundle.start();
syncToCluster(pluginKey, "ENABLED");
}
}
public void disablePlugin(String pluginKey) throws BundleException {
Bundle bundle = installedPlugins.get(pluginKey);
if (bundle != null && bundle.getState() == Bundle.ACTIVE) {
bundle.stop();
syncToCluster(pluginKey, "DISABLED");
}
}
public void uninstallPlugin(String pluginKey) throws BundleException {
Bundle bundle = installedPlugins.remove(pluginKey);
if (bundle != null) {
bundle.uninstall();
syncToCluster(pluginKey, "UNINSTALLED");
}
}
private void syncToCluster(String pluginKey, String status) {
redisTemplate.convertAndSend("plugin-events",
String.format("{\"key\":\"%s\",\"status\":\"%s\"}", pluginKey, status));
}
}
5. Security Configuration
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtTokenProvider jwtTokenProvider;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.csrf(csrf -> csrf.disable())
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/v1/auth/**").permitAll()
.requestMatchers("/api/v1/public/**").permitAll()
.requestMatchers("/actuator/health").permitAll()
.requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.addFilterBefore(
new JwtAuthenticationFilter(jwtTokenProvider),
UsernamePasswordAuthenticationFilter.class
)
.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
@Component
@RequiredArgsConstructor
public class JwtTokenProvider {
@Value("${jwt.secret}")
private String secretKey;
@Value("${jwt.expiration}")
private long validityInMilliseconds;
public String createToken(String username, List<String> roles) {
Claims claims = Jwts.claims().setSubject(username);
claims.put("roles", roles);
Date now = new Date();
Date validity = new Date(now.getTime() + validityInMilliseconds);
return Jwts.builder()
.setClaims(claims)
.setIssuedAt(now)
.setExpiration(validity)
.signWith(SignatureAlgorithm.HS256, secretKey)
.compact();
}
public Authentication getAuthentication(String token) {
UserDetails userDetails = loadUserByUsername(getUsername(token));
return new UsernamePasswordAuthenticationToken(
userDetails, "", userDetails.getAuthorities());
}
public String getUsername(String token) {
return Jwts.parser()
.setSigningKey(secretKey)
.parseClaimsJws(token)
.getBody()
.getSubject();
}
public boolean validateToken(String token) {
try {
Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token);
return true;
} catch (JwtException | IllegalArgumentException e) {
return false;
}
}
}
6. Resilience with Circuit Breaker
@Service
@RequiredArgsConstructor
public class ExternalApiService {
private final RestTemplate restTemplate;
private final CircuitBreakerRegistry circuitBreakerRegistry;
@CircuitBreaker(name = "externalApi", fallbackMethod = "fallback")
@Retry(name = "externalApi")
@RateLimiter(name = "externalApi")
public ExternalData fetchData(String id) {
return restTemplate.getForObject(
"https://external-api.com/data/{id}",
ExternalData.class,
id
);
}
private ExternalData fallback(String id, Exception e) {
return ExternalData.defaultValue();
}
}
7. Server-Side Rendering
@Service
@RequiredArgsConstructor
public class SSREngine {
private final GraalJSRuntime jsRuntime;
private final CacheManager<String, String> renderCache;
public String renderReact(String component, Map<String, Object> props) {
String cacheKey = component + ":" + props.hashCode();
return renderCache.get(cacheKey, () -> {
String script = String.format(
"ReactDOMServer.renderToString(React.createElement(%s, %s))",
component,
new ObjectMapper().writeValueAsString(props)
);
String html = jsRuntime.execute(script);
return html + generateHydrationScript(component, props);
});
}
private String generateHydrationScript(String component, Map<String, Object> props) {
return String.format(
"<script>ReactDOM.hydrate(React.createElement(%s, %s), document.getElementById('root'))</script>",
component,
new ObjectMapper().writeValueAsString(props)
);
}
}
File-by-File Feature Recipe — New Entity End-to-End
Concrete ordered recipe for adding a new entity (example: Order) through ALL layers. Paths use the reference base package com.arcana — substitute your renamed package. This template documents no Flyway/Liquibase migration toolchain; persistence follows the existing datasource config in application.yml (H2 for the test profile).
Create files in this order, keeping ./gradlew clean build green after each layer:
- Domain model —
src/main/java/com/arcana/model/Order.java
Plain domain model (builder pattern as in DailyReport.builder() examples). All required fields defined here first — DTOs and mock data must match it exactly.
- DTOs + mapper —
src/main/java/com/arcana/dto/OrderDto.java
Static factory mapper OrderDto.from(Order) (same style as UserDto.from(user) — no MapStruct in this architecture). Also CreateOrderRequest / UpdateOrderRequest with validation annotations and toEntity().
- Repository interface —
src/main/java/com/arcana/repository/OrderRepository.java
- Repository impl —
src/main/java/com/arcana/repository/OrderRepositoryImpl.java
Implement EVERY interface method. Zero-Empty Policy applies: stubs return realistic varied mock data (≥7 items for list/chart data), never List.of() / emptyList(). No separate DAO layer exists in this architecture — Repository is the data-access boundary.
- Service interface —
src/main/java/com/arcana/service/OrderService.java
- Service impl —
src/main/java/com/arcana/service/OrderServiceImpl.java
@Service class; place @Transactional on the ServiceImpl methods (transaction boundary lives in the Service layer, not Controller or Repository).
- REST controller —
src/main/java/com/arcana/controller/OrderController.java
@RestController + @RequestMapping("/api/v1/orders"), @Valid on every @RequestBody, @PreAuthorize on non-public endpoints. Every Service method called here MUST exist in step 5/6.
- gRPC proto —
src/main/proto/order.proto
service OrderService { rpc ... } with option java_package = "com.arcana.grpc.order"; then regenerate:
./gradlew generateProto
- gRPC service impl —
src/main/java/com/arcana/grpc/OrderGrpcService.java
@GrpcService class extending the generated OrderServiceGrpc.OrderServiceImplBase; implement ALL rpc methods (rpc count must equal @Override count) and wire to the existing OrderService from step 5.
- DI wiring — none needed explicitly: Spring component scan picks up
@RestController / @Service / @Repository, and @GrpcService is registered by the gRPC starter. Constructor injection via @RequiredArgsConstructor.
- Mock/seed data —
src/test/java/com/arcana/mock/MockOrderRepository.java
@Repository + @Profile("test") with a static seed list (see MockUserRepository pattern); reusable createMockOrder(...) factory helpers for complex objects.
- Unit tests per layer (locations per the Test Directory Structure):
src/test/java/com/arcana/controller/OrderControllerTest.java — @WebMvcTest + MockMvc (target 80%+)
src/test/java/com/arcana/service/OrderServiceTest.java — @ExtendWith(MockitoExtension.class) (target 90%+)
src/test/java/com/arcana/repository/OrderRepositoryTest.java (target 75%+)
src/test/java/com/arcana/grpc/OrderGrpcServiceTest.java
- Coverage check:
./gradlew test jacocoTestReport
open build/reports/jacoco/test/html/index.html
API Wiring Verification Guide
🚨 The API Wiring Blind Spot
Spring Boot Controllers often inject Services and call methods that may not exist or are not implemented:
@RestController
@RequestMapping("/api/v1/settings")
@RequiredArgsConstructor
public class SettingsController {
private final SettingsService settingsService;
@GetMapping("/account-info")
public ResponseEntity<AccountInfoDto> getAccountInfo() {
return ResponseEntity.ok(settingsService.getAccountInfo());
}
@PostMapping("/change-password")
public ResponseEntity<Void> changePassword(@RequestBody ChangePasswordRequest req) {
settingsService.changePassword(req);
return ResponseEntity.ok().build();
}
}
Problem: If the Service class doesn't have the method or it throws UnsupportedOperationException, the endpoint compiles but fails at runtime!
Detection Patterns
grep -roh "[a-zA-Z]*Service\.[a-zA-Z]*(" src/main/java/**/controller/*.java | sort -u
grep -rh "public.*(" src/main/java/**/service/*.java | grep -oE "[a-zA-Z]+\(" | sort -u
grep -rn "throw.*UnsupportedOperationException\|TODO.*implement" src/main/java/**/service/*.java
Verification Checklist
-
List Service methods called in each Controller:
grep -oh "settingsService\.[a-zA-Z]*(" src/main/java/**/controller/SettingsController.java | sort -u
-
List methods implemented in corresponding Service:
grep -h "public.*(" src/main/java/**/service/SettingsService.java | grep -oE "[a-zA-Z]+\("
-
Every method called MUST exist in the Service! Any missing method = runtime failure
Correct Wiring Example
@RestController
@RequestMapping("/api/v1/settings")
@RequiredArgsConstructor
public class SettingsController {
private final SettingsService settingsService;
@GetMapping("/account-info")
public ResponseEntity<AccountInfoDto> getAccountInfo() {
return ResponseEntity.ok(settingsService.getAccountInfo());
}
@PostMapping("/change-password")
public ResponseEntity<Void> changePassword(@RequestBody ChangePasswordRequest req) {
settingsService.changePassword(req.getCurrentPassword(), req.getNewPassword());
return ResponseEntity.ok().build();
}
}
@Service
@RequiredArgsConstructor
public class SettingsService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
public AccountInfoDto getAccountInfo() {
User user = getCurrentUser();
return AccountInfoDto.from(user);
}
public void changePassword(String currentPassword, String newPassword) {
User user = getCurrentUser();
if (!passwordEncoder.matches(currentPassword, user.getPassword())) {
throw new BadCredentialsException("Invalid current password");
}
user.setPassword(passwordEncoder.encode(newPassword));
userRepository.save(user);
}
}
Code Review Checklist
Required Items
Performance Checks
Security Checks
Common Issues
gRPC Connection Issues
- Check TLS certificate configuration
- Verify service discovery settings
- Ensure proper channel management
Plugin Loading Issues
- Verify OSGi bundle manifest
- Check dependency resolution
- Review Spring-OSGi bridge configuration
Performance Issues
- Enable gRPC for internal calls
- Configure connection pooling
- Review circuit breaker settings
Spec Gap Prediction System
When Spec is incomplete, use these universal rules to predict and supplement missing API endpoints.
Endpoint Type → Required Elements (Universal)
| Endpoint Type | Required Elements | Auto-Predict |
|---|
| List endpoint | Pagination, Sorting, Filtering | Search endpoint, Count endpoint |
| Detail endpoint | ID validation, 404 handling | Related data endpoints |
| Create endpoint | Validation, 201 response | Duplicate check |
| Update endpoint | Validation, 404 handling | Partial update (PATCH) |
| Delete endpoint | 404 handling, Cascade rules | Soft delete option |
| Auth endpoint | JWT response, Refresh token | Logout, Password reset |
Flow Completion Prediction
┌─────────────────────────────────────────────────────────────────┐
│ Flow Completion Rules │
├─────────────────────────────────────────────────────────────────┤
│ IF Spec has Login endpoint: │
│ → PREDICT: Register, Logout, Refresh token, Forgot password │
│ │
│ IF Spec has Register endpoint: │
│ → PREDICT: Email verification, Onboarding data endpoint │
│ │
│ IF Spec has List endpoint: │
│ → PREDICT: Detail endpoint (GET /{id}) │
│ → PREDICT: Search endpoint (GET /search) │
│ → PREDICT: Count endpoint (GET /count) │
│ │
│ IF Spec has Create endpoint: │
│ → PREDICT: Update endpoint (PUT /{id}) │
│ → PREDICT: Delete endpoint (DELETE /{id}) │
│ → PREDICT: Batch create endpoint │
│ │
│ IF Spec has User management: │
│ → PREDICT: Profile endpoint │
│ → PREDICT: Change password endpoint │
│ → PREDICT: Settings endpoint │
└─────────────────────────────────────────────────────────────────┘
CRUD Prediction Matrix
| Spec Mentions | Auto-Predict Endpoints |
|---|
| "List items" | GET /items, GET /items/{id}, GET /items/count |
| "Create item" | POST /items with @Valid, 201 response |
| "Update item" | PUT /items/{id}, PATCH /items/{id} |
| "Delete item" | DELETE /items/{id}, soft delete option |
| "Search items" | GET /items/search with query params |
| "Filter items" | Query parameters with Specification pattern |
Response Format Prediction
| Operation | Success Response | Error Response |
|---|
| GET list | 200 + Page | 400 (bad params) |
| GET detail | 200 + T | 404 (not found) |
| POST create | 201 + T + Location header | 400 (validation) |
| PUT update | 200 + T | 404, 400 |
| DELETE | 204 No Content | 404 |
| Auth login | 200 + JWT | 401 |
Spec Gap Detection Commands
echo "=== CRUD Completeness ===" && \
echo "GET endpoints:" && grep -c "@GetMapping" src/main/java/**/controller/*.java && \
echo "POST endpoints:" && grep -c "@PostMapping" src/main/java/**/controller/*.java && \
echo "PUT endpoints:" && grep -c "@PutMapping" src/main/java/**/controller/*.java && \
echo "DELETE endpoints:" && grep -c "@DeleteMapping" src/main/java/**/controller/*.java
grep -l "@RequestBody" src/main/java/**/controller/*.java | \
xargs grep -L "@Valid" 2>/dev/null && echo "(endpoints may be missing validation)"
grep -L "ResponseEntity.notFound\|ResponseEntity.badRequest" src/main/java/**/controller/*.java
echo "=== Auth Flow Check ===" && \
grep -q "login\|Login" src/main/java/**/controller/*.java || echo "⚠️ Missing: Login endpoint"
grep -q "register\|Register" src/main/java/**/controller/*.java || echo "⚠️ Missing: Register endpoint"
grep -q "logout\|Logout" src/main/java/**/controller/*.java || echo "⚠️ Missing: Logout endpoint"
grep -q "refresh\|Refresh" src/main/java/**/controller/*.java || echo "⚠️ Missing: Refresh token endpoint"
grep -l "@GetMapping" src/main/java/**/controller/*.java | \
xargs grep -L "Pageable\|Page<" 2>/dev/null && echo "(list endpoints may be missing pagination)"
Prediction Implementation Example
When implementing a resource API from Spec:
@RestController
@RequestMapping("/api/items")
@RequiredArgsConstructor
public class ItemController {
private final ItemService itemService;
@GetMapping
public ResponseEntity<Page<ItemResponse>> list(Pageable pageable) {
return ResponseEntity.ok(itemService.findAll(pageable).map(this::toResponse));
}
@GetMapping("/{id}")
public ResponseEntity<ItemResponse> get(@PathVariable String id) {
return itemService.findById(id)
.map(item -> ResponseEntity.ok(toResponse(item)))
.orElse(ResponseEntity.notFound().build());
}
@PostMapping
public ResponseEntity<ItemResponse> create(@Valid @RequestBody CreateItemRequest request) {
Item item = itemService.create(request);
return ResponseEntity
.created(URI.create("/api/items/" + item.getId()))
.body(toResponse(item));
}
@PutMapping("/{id}")
public ResponseEntity<ItemResponse> update(
@PathVariable String id,
@Valid @RequestBody UpdateItemRequest request) {
return itemService.findById(id)
.map(existing -> {
Item updated = itemService.update(id, request);
return ResponseEntity.ok(toResponse(updated));
})
.orElse(ResponseEntity.notFound().build());
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable String id) {
if (!itemService.existsById(id)) {
return ResponseEntity.notFound().build();
}
itemService.delete(id);
return ResponseEntity.noContent().build();
}
@GetMapping("/search")
public ResponseEntity<Page<ItemResponse>> search(
@RequestParam(required = false) String query,
Pageable pageable) {
return ResponseEntity.ok(
itemService.search(query, pageable).map(this::toResponse)
);
}
}
Tech Stack Reference
Versions track the reference repo — check build.gradle before assuming.
| Technology | Recommended Version |
|---|
| Java | 25+ (OpenJDK) |
| Spring Boot | 4.0+ |
| gRPC | 1.60+ |
| Apache Felix | 7.0+ |
| MySQL | 8.0+ |
| Redis | 7.0+ |
| Gradle | 9.2+ |