Where a module's configuration lives (CONF-002)
Every Maven module is independently runnable and carries its own
src/main/resources/application.yml. The thing we actually deploy is not any of them: it is
platform-app, a single jar that boots core plus every domain module in one Spring context.
Those two facts collided, and for a long time the collision was silent.
What went wrong
Spring Boot's default config location is optional:classpath:/, which resolves through
ClassPathResource — one resource, the first on the classpath. There is no classpath*:
wildcard and no merge. The shipped jar contains eight copies:
[0] BOOT-INF/classes/application.yml ← the only one that loads
[1] BOOT-INF/lib/core-*.jar!/application.yml
[2] BOOT-INF/lib/workforce-*.jar!/application.yml
[3] BOOT-INF/lib/imaging-*.jar!/application.yml
[4] BOOT-INF/lib/hl7-*.jar!/application.yml
[5] BOOT-INF/lib/fhir-*.jar!/application.yml
[6] BOOT-INF/lib/lab-*.jar!/application.yml
[7] BOOT-INF/lib/pharmacy-*.jar!/application.yml
BOOT-INF/classes precedes every BOOT-INF/lib entry, so app's file won and the other seven rode
along as inert bytes. 72 keys were declared by a bundled module and reached the running jar from
nowhere.
Nothing failed at boot — that is what made it expensive. Each orphaned key fell back to its
@ConfigurationProperties Java field default, so the app started, looked healthy, and quietly ran on
values no operator had chosen. And a module's own test run put that module's target/classes first,
so every default read back exactly as written there. Green tests, wrong production.
Two separate losses, and the second is the one that hurt
- The YAML literal default. Usually harmless: the Java field normally carries the same value.
- The
${ENV_VAR:…}indirection itself. This is the real damage. A documented variable only reached its property while its spelling happened to match Spring's relaxed-binding form of the key.IMAGING_VIEWER_TOKEN_SECRETmatchedimaging.viewer.token-secret, so it kept working.PAYSTACK_SECRET_KEYdid not — relaxed binding wantedBILLING_GATEWAYS_PAYSTACK_SECRET_KEY— so setting the name printed in.env.exampledid nothing at all.
.env was worse off than a real OS environment variable. It is imported as a properties source keyed
by the literal variable name, so it can only ever reach a property through a ${…} placeholder; with
the placeholder gone, .env could not reach any of the 72 by any spelling.
The two worst cases: patient document storage (uhp.documents.*) was not overridable at all, so
the jar stored documents with cloud-provider=local, bucket=uhp-documents and no endpoint whatever
an operator set; and all 21 payment-gateway keys, including live secrets, were unreachable.
The rule now
A module's own configuration goes in
<module>/src/main/resources/uhp-module-defaults.yml. Never copy a module block into an edition'sapplication.yml.
ModuleDefaultsEnvironmentPostProcessor (in common, registered once in its
META-INF/spring.factories) loads every uhp-module-defaults.yml on the classpath and appends
each with addLast.
The one line that matters is ClassLoader.getResources(…) instead of ClassPathResource: it returns
every match, so six modules contribute six property sources instead of one winning and five
vanishing. That is the exact inverse of the mechanism above, and it is why a shared file name is safe
here where it is fatal for application.yml.
Precedence
addLast puts module defaults at the very bottom. Highest wins:
| Source | |
|---|---|
| 1 | command line --property=value |
| 2 | real OS environment variables and system properties |
| 3 | .env / .env.local (imported by spring.config.import) |
| 4 | the edition's application.yml — its Liquibase master and its module flags |
| 5 | dist/edition-base/…/edition-base.yml — everything every edition shares, imported by it |
| 6 | <module>/uhp-module-defaults.yml — module defaults |
⚠ Rows 4 and 5 were one file until DEPLOY-002. They split because there is now more than one
deployable: platform-app (hospital), pharmacy-edition and lab-edition. Keeping the shared bulk in
one imported file is what stops three editions drifting apart — a key added to one edition's
application.yml reaches only that edition, with nothing to say so. See
Editions.
So nothing that overrode a value before overrides it any less now, and .env works for module keys
for the first time.
What still belongs in application.yml
| File | Holds |
|---|---|
dist/edition-base/…/edition-base.yml | what every deployable decides the same way: the port, the datasource, the public default schema, and the loopback Feign URLs. Imported by name, never called application.yml — a second one on the classpath would make which the jar reads depend on jar order. |
<edition>/…/application.yml | what makes an edition that edition: its Liquibase master and its platform.module.<key>.enabled flags. Nothing else belongs here. |
<module>/…/application.yml | what the module needs to run alone: its own port, its own datasource and schema, its own Liquibase master. Read only in a standalone run — the jar never sees it. |
<module>/…/uhp-module-defaults.yml | the module's own namespace, read in both. |
Adding configuration to a module
- Put the key in the owning module's
uhp-module-defaults.yml, under that module's namespace, with a${DOCUMENTED_NAME:default}placeholder. - Document the variable in
.env.exampleunder the module's section. - That is all. There is no class to write and no registration to remember — a new module is correct the moment it ships the file.
A module defaults file must declare only keys in its own namespace. Two modules claiming one key would be resolved by classpath order, which nothing pins; the build fails if that ever happens.
⚠ One consequence of the fix worth knowing: because these variables really do bind now, an empty
entry in .env overrides a non-empty default rather than being ignored. Comment the line out instead
of leaving it blank. IMAGING_VIEWER_TOKEN_SECRET in .env.example is commented for exactly this
reason.
What the build checks
app/src/test/java/…/ModuleConfigShadowingTest.java is the guard, and it runs on the one classpath
where every module is present at once:
- many
application.ymlfiles exist, and Spring turns exactly one of them into a property source — the mechanism, pinned so nobody re-derives it; - no module declares a default the jar drops without it being recorded in
KNOWN_ORPHANED_CONF_002. This is a subset assertion: striking a key off as it becomes reachable keeps the build green, while a new orphan fails it and is named — together with the environment variable that goes inert with it; - every module's defaults file is loaded (six sources, not one), and every key in them is reachable;
- the documented environment variables —
DOCUMENTS_BUCKET,S3_ENDPOINT_URL,PAYSTACK_SECRET_KEY,HL7_OUTBOUND_HOST,FHIR_SERVER_BASE_URLand the rest — each land on the property they name. This is the half a passing test could never see before: a literal default surviving is not the same as the variable behind it surviving; - module defaults sit below
app's own config, so every existing override still wins; - no two modules declare the same key.
ModuleDefaultsEnvironmentPostProcessorTest in common covers the mechanism itself against a
synthetic two-module classpath.
ModuleDefaultsBootstrapTest, also in app, covers the two things that list cannot reach:
- that Spring Boot actually runs the post-processor. Every other test constructs it by hand, which
proves the class works but not that anything calls it. The whole mechanism hangs on one line in
common'sMETA-INF/spring.factories; lose it — or migrate it to Boot 4's…EnvironmentPostProcessor.importsform, which Boot 3.5 does not read — and every module default goes inert in the deployable while the rest of the suite stays green. That is the CONF-002 failure shape one level up from where CONF-002 found it. This test starts a realSpringApplicationand reads what Boot hands it, so the registration is exercised rather than assumed. - that the values bind. A property can be present in the environment and never arrive on the
object that reads it — a prefix typo, a missing setter, or a relaxed binding that does not survive
the hop into a
Mapor aSet.billing.gatewaysis exactly that shape. So the test binds the realDocumentStoragePropertiesandPaymentGatewayPropertiesand reads the values back off them.
It costs no database: the run is abandoned at ApplicationEnvironmentPreparedEvent, which fires
during prepareEnvironment, long before a datasource exists.
Verifying the assembled jar by hand
Everything above still runs on app's Maven test classpath — a very good stand-in for the fat
jar, but a stand-in. To check the artefact itself, run a probe inside it through PropertiesLauncher
and read the environment at ApplicationEnvironmentPreparedEvent. Done on 2026-08-17 against
platform-app-0.0.1-SNAPSHOT.jar, it reports one property source per module, addressed by the
jar:nested: URLs Boot 3.2+ uses for nested entries:
uhp-module-defaults [jar:nested:…/platform-app-0.0.1-SNAPSHOT.jar/!BOOT-INF/lib/core-1.0-SNAPSHOT.jar!/uhp-module-defaults.yml]
with DOCUMENTS_BUCKET, S3_ENDPOINT_URL, DOCUMENTS_CLOUD_PROVIDER, PAYSTACK_SECRET_KEY,
BILLING_GATEWAY_NGN and PAYPAL_CLIENT_SECRET all reaching the properties they name.
⚠ /actuator/env cannot answer this question, and should not be made to: it is not in the exposed
set and the security chain returns 403 for it. Do not open it to check configuration — use the probe,
or read /actuator/env only through an authenticated admin session.
What is still deliberately orphaned
24 springdoc.* keys, all cosmetic, and both groups on purpose:
springdoc.api-docs.pathandspringdoc.swagger-ui.path— declared by six modules for their standalone runs; springdoc's own Java defaults are those same two values.springdoc.swagger-ui.urls[*]— core's explicit eleven-entry engine dropdown. Making it reachable would be a regression: in the bundle springdoc derives the dropdown from every registered group, so pinning core's list would hide the workforce, imaging, hl7, lab, pharmacy and fhir API docs.
Related
- Secure defaults and the production posture assertion — why a default is advice and a startup assertion is enforcement.
- Scheduled jobs —
notification.dispatch.*andhl7.adt.reconciliation.*are reachable now, but both jobs still need theschedulerprofile.