Imported from mucsi96/skeleton-app (
AGENTS.md). Install upstream withnpx skills add mucsi96/skeleton-app. Copyright stays with the author.
Skeleton App - Development Guidelines
General Code Style
- Avoid fallbacks, prefer failing fast
- Prefer functional programming patterns
- Prefer immutable data structures
Java Style
- Use Lombok annotations (@Data, @Builder, @RequiredArgsConstructor)
- Constructor injection (via @RequiredArgsConstructor)
- Use Stream API for collections
- Use records for DTOs/responses
TypeScript Style
- Use
constby default - Prefer spread operator for object/array operations
- Use functional array methods (map, filter, reduce)
- Use string literals over enums
Testing Style
- Write tests from user perspective
- Use role-based selectors (getByRole)
- Use semantic selectors (getByText, getByLabel)
- E2E tests with Playwright
Angular Style
- Use Angular Material components
- Follow
@mucsi96/angular-material-theme's documented button-color API: usebt-color="primary|success|warn|error"onmat-flat-button,mat-raised-button,mat-fab(including extended FABs), andmat-mini-fab, including anchor buttons. Use[attr.bt-color]for dynamic tones; no directive import is needed. - Solid buttons default to primary. Use
errorfor destructive actions,warnfor caution, andsuccessfor positive outcomes. Material's legacycolor="warn"means error/red, not the theme's orangebt-color="warn"; migrate solid buttons accordingly. - For an arbitrary solid-button color, set only
--bt-button-bgwithoutbt-color. Let the theme derive label contrast and hover colors; do not override Material container/label/state-layer color tokens,--mat-sys-primary, or buttonbackground,color, and:hoverseparately. Keep the intended button variant; raised buttons support the same API and retain elevation. - The
bt-colorAPI does not cover text, outlined, icon, or menu buttons. Use their documented Material APIs instead; do not apply solid-button color workarounds to them. Check the installed theme README when upgrading or changing component styles. - Use signals and resources (not rxjs where possible)
- Use string literals over enums
- Standalone components
Design
- Material UI dark theme
- Skeleton loaders for loading states
Project Overview
Reference application demonstrating patterns for:
- CI/CD pipeline (GitHub Actions)
- Deployment (Docker images published to registry)
- Client (Angular 21 with Material UI)
- Server (Spring Boot 4 with Java 21)
- Authentication (Azure AD / MSAL)
- Configuration (Azure Key Vault, Spring profiles)
- AI integration (Anthropic Claude via Spring AI)
- AI mocking (Express mock server)
- Database (PostgreSQL with JPA)
- Testing (Playwright E2E)
Architecture
- client/ - Angular 21 SPA with Material UI, MSAL authentication
- server/ - Spring Boot 4 REST API with PostgreSQL, Spring AI
- mock_anthropic_server/ - Express mock for Claude API
- test/ - Playwright E2E tests
- scripts/ - Build and deployment scripts
- .github/workflows/ - CI/CD pipelines
Key Technologies
- Spring Boot 4, Java 21 (built into a GraalVM native image)
- Angular 21.2.0
- PostgreSQL 17
- Spring AI 2.0.0-M2 (Anthropic)
- Azure AD (MSAL) authentication
- Azure Key Vault for secrets
- Traefik reverse proxy
- Docker multi-stage builds
- Playwright for E2E testing
Development Commands
Frontend
cd client && npm start # Start dev server
cd client && npm run build # Production build
Backend
cd server && mvn spring-boot:run -Dspring-boot.run.profiles=local # Start with local profile
Local development still runs on a plain JVM. The native image is built only by the container build - see Native image and the baked-in Spring profile below.
Podman Development
scripts/pod_up.sh # Build images and start test pod
scripts/pod_down.sh # Stop and clean up test pod
scripts/dev_db_up.sh # Start development PostgreSQL database
scripts/dev_db_down.sh # Stop development database
Testing
cd test && npm test # Run E2E tests
cd test && npx playwright test --ui # Interactive test runner
API Routes
GET /api/environment- Client configuration (public)GET /api/greeting- AI-powered greeting (authenticated)
Data Model
- greetings - Stores name and message used for AI greeting generation
Configuration Patterns
Spring Profiles
- prod - Production with Azure Key Vault and AAD
- local - Local development with Podman DB
- test - Testing with disabled auth and mock AI services
Native image and the baked-in Spring profile
The server is compiled ahead of time into a GraalVM native executable linked against musl, so there is no JRE in the runtime image and startup is in the tens of milliseconds rather than seconds.
Ahead-of-time processing resolves bean definitions at build time, which means
the active Spring profile is decided by the build, not by the environment:
Spring AOT emits an EnvironmentPostProcessor that activates the profile the
image was built with. SPRING_PROFILES_ACTIVE is no longer read at runtime, and
test/test-pod.yaml no longer sets it. Build one image per profile with the
SPRING_PROFILE build argument - test for the e2e pod, prod for the image
published to Docker Hub:
podman build --build-arg SPRING_PROFILE=test \
-t localhost/skeleton-app-server:test server
Build-time details that live in server/pom.xml and are easy to trip over:
- AOT processing refreshes the application context, so every placeholder an
auto-configuration condition reads has to resolve during the build. The
process-aotexecution supplies build-time stand-ins for them and turns the Key Vault property source off, so the build never reaches out to Azure. The stand-ins are not baked into the image; they only have to make the same conditions match as the real values do at runtime. A new required environment placeholder read by a condition means adding it there too. Placeholders that are only read while creating beans (${db-url},${claude-api-key}) are resolved at runtime as before and need nothing. - Spring AOT generates bean-definition classes into the packages of the
configuration classes it processes, including the signed Spring Cloud Azure
jars. Mixing generated (unsigned) and signed classes in one package makes the
native-image builder throw
SecurityException: ... signer information does not match, so the builder is pointed atserver/native-image.security, which disables jar signature verification. - Jars can ship a
META-INF/native-image/.../native-image.propertiesthat forces classes to build-time initialization. When such a class holds on to objects of types that are still initialized at run time, the builder fails withUnsupportedFeatureException: An object of type ... was found in the image heap.--initialize-at-build-timein thenative-maven-pluginconfig covers the Jackson core classesazure-coreleaves behind that way. Note that a build cannot undo such a directive:exclude-configdoes not apply tonative-image.properties, andinitialize-at-run-timefor the same class is rejected outright. That is whyazure-coreis pinned ahead of the version the Azure BOM selects - the BOM's 1.58.0 forces SLF4J and logback to build-time initialization, which is irreconcilable with Spring Boot setting logging up at run time. Check this again when the Azure BOM moves. - The Azure SDK's
ExpandableStringEnumconstants are built by instantiating the subclass reflectively, andfromStringreturnsnullrather than failing when it cannot. Missing reflection metadata therefore surfaces as every constant of a class beingnulland aNullPointerExceptionfar from the cause.AzureNativeHintsregisters the subclasses azure-identity does not ship metadata for. - azure-core decides how to read a response body by asking the model class
whether it declares the
fromXml/fromJsonpair azure-xml and azure-json generate, and it asks withClass.getDeclaredMethods(). In a native image that returns nothing for a class with no reachability metadata, so the answer is silently "no" and azure-core falls back to Jackson - for XML that means anXmlMapper, and jackson-dataformat-xml is not on the classpath, so the call dies with aNoClassDefFoundError. The SDK ships metadata for most of its models but not all.AzureNativeHintsscanscom.azureand registers everyXmlSerializable,JsonSerializableandHttpResponseExceptioninstead of naming the ones missing today, so an SDK upgrade cannot reintroduce this. - The Key Vault property source is configured by an
EnvironmentPostProcessorthat runs before there is an application context and reads its own settings with a plainBinderoverAzureKeyVaultSecretProperties. Nothing in the framework infers that, and the auto-configuration that would otherwise contribute the binding metadata for that type never matches here - it is conditional onspring.cloud.azure.keyvault[.secret].endpoint, while this application configures the endpoint under...secret.property-sources[0]. With no members in the image the binder binds nothing, and an absent binding is indistinguishable from an empty configuration, so the post-processor quietly concludes there is no property source to add. Nothing fails at that point: the image starts and then dies much later on the first secret-backed placeholder.KeyVaultPropertySourceNativeHintssupplies the metadata. Only the prod profile reads secrets from Key Vault, so no test covers this - after changing anything about the Key Vault configuration, check that the generatedtarget/spring-aot/main/resources/META-INF/native-image/**/reachability-metadata.jsonstill carriesAzureKeyVaultSecretPropertiesandAzureKeyVaultPropertySourcePropertieswith their accessors.
Spring Cloud Azure needs one workaround in application code:
AzureGlobalPropertiesConfiguration re-declares the AzureGlobalProperties
bean. Spring Cloud Azure registers it from an ImportBeanDefinitionRegistrar
using a lambda instance supplier, which AOT cannot turn into generated code, so
it drops the bean and the image fails to start with "required a bean of type
AzureGlobalProperties that could not be found". See the class comment for why it
uses its own bean name. That workaround turns on Spring Cloud Azure's
registration order, which is not a public contract, so smoke-test the image
whenever spring-cloud-azure-dependencies moves - a change there could drop the
bean again with no compile-time signal.
The image is deliberately not built with --static. A fully static binary links
but then segfaults the moment it starts in the container - before GraalVM
installs its own segfault handler, so with no output whatsoever, which looks
exactly like a container that silently never starts.
Reproducing AOT problems without a native build
Most AOT problems reproduce without waiting for a native compile (which takes several minutes). Run the AOT-processed application on a normal JVM:
cd server
mvn -Pnative package -DskipTests -Dapp.profile=test
java -Dspring.aot.enabled=true -jar target/skeleton-0.0.1-SNAPSHOT.jar
That exercises the generated context - missing bean definitions, profile and
condition mismatches - in seconds. Only class-initialization and reflection
problems need the real mvn -Pnative native:compile.
Types that are only ever bound reflectively need explicit hints. Controller
request/response types, JPA entities and Spring Data repositories are covered by
the framework's own AOT processing and need nothing. Types read with a plain
ObjectMapper want @RegisterReflectionForBinding; types bound by a Binder
rather than Jackson want BindableRuntimeHintsRegistrar, which registers exactly
what JavaBeanBinder looks for over the whole class hierarchy - see
KeyVaultPropertySourceNativeHints.
Release and image publishing
publish-server and publish-client each ask mucsi96/get-next-version for a
version. It answers from the newest server-N / client-N tag: no changes under
the component's directory since that tag means no version, and every publish step
is skipped. The release step must therefore tag the commit its image was built
from - target_commitish: ${{ github.sha }} - because the action otherwise tags
whatever the default branch points at when the release is created, and the
server's native build takes long enough that another push can land first. A tag
left on a commit that was never built makes the next run believe that commit is
already released, so nothing is published for it. That is silent: deploy
resolves the newest tag on Docker Hub by last_updated and succeeds, deploying
the previous commit's image, so a fix can look deployed while the running image
predates it. When a change does not reach production, check that a release tag
exists on the commit and that publish-server did not skip its build steps.
Environment Config
- Server exposes
/api/environmentendpoint - Client fetches config before bootstrap
- Conditionally enables MSAL based on
mockAuthflag