Skip to main content

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 ClassPathResourceone 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

  1. The YAML literal default. Usually harmless: the Java field normally carries the same value.
  2. 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_SECRET matched imaging.viewer.token-secret, so it kept working. PAYSTACK_SECRET_KEY did not — relaxed binding wanted BILLING_GATEWAYS_PAYSTACK_SECRET_KEY — so setting the name printed in .env.example did 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's application.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
1command line --property=value
2real OS environment variables and system properties
3.env / .env.local (imported by spring.config.import)
4the edition's application.yml — its Liquibase master and its module flags
5dist/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

FileHolds
dist/edition-base/…/edition-base.ymlwhat 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.ymlwhat makes an edition that edition: its Liquibase master and its platform.module.<key>.enabled flags. Nothing else belongs here.
<module>/…/application.ymlwhat 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.ymlthe module's own namespace, read in both.

Adding configuration to a module

  1. Put the key in the owning module's uhp-module-defaults.yml, under that module's namespace, with a ${DOCUMENTED_NAME:default} placeholder.
  2. Document the variable in .env.example under the module's section.
  3. 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.yml files 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_URL and 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's META-INF/spring.factories; lose it — or migrate it to Boot 4's …EnvironmentPostProcessor.imports form, 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 real SpringApplication and 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 Map or a Set. billing.gateways is exactly that shape. So the test binds the real DocumentStorageProperties and PaymentGatewayProperties and 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.path and springdoc.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.