Skip to content

Commit 6cedfef

Browse files
jeffjensenclaude
andcommitted
feat(arch): Per-module Spring dependencies and conditional app wiring
* Bump generated Spring Boot to 4.1.0 * Replace blanket spring-context with role-specific Spring Boot starters per module; common-domain carries the core starter that plain modules inherit transitively * Presentation modules: spring-boot-starter-web (rest), spring-boot-starter-graphql (graphql) * Integration modules: spring-boot-starter-data-jpa (database), spring-boot-starter-restclient (rest client), spring-boot-starter-graphql (graphql) * Make app's web/data-jpa starters and AppServletInitializer/@EnableTransactionManagement conditional on a rest presentation / database integration Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01McmUDzyMrkYpbyeU5UWrot
1 parent e64ca7a commit 6cedfef

8 files changed

Lines changed: 156 additions & 41 deletions

File tree

src/main/resources/META-INF/archetype-post-generate.groovy

Lines changed: 83 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -201,16 +201,60 @@ def projectDir = new File(request.outputDirectory)
201201
def subDir = new File(projectDir, artifactId)
202202
if (subDir.isDirectory()) projectDir = subDir
203203

204+
// ─── Spring dependency selection ──────────────────────────────────────────────
205+
// Each composed module declares the most specific Spring Boot starter that is useful
206+
// for its role rather than a blanket low-level dependency. common-domain (which every
207+
// other module depends on) carries the core spring-boot-starter, so the "plain" modules
208+
// — domain-* and the service modules — inherit the @Configuration / @ComponentScan API
209+
// transitively and declare no Spring dependency of their own. Presentation (server-side)
210+
// and integration (client-side) modules add a role-specific starter on top: a 'rest'
211+
// presentation serves endpoints via spring-boot-starter-web, while a 'rest' integration
212+
// consumes an external API via spring-boot-starter-restclient (no servlet web stack). All
213+
// gated on includeSpring; with Spring off no module references Spring at all.
214+
215+
def STARTER = [groupId: 'org.springframework.boot', artifactId: 'spring-boot-starter']
216+
def STARTER_WEB = [groupId: 'org.springframework.boot', artifactId: 'spring-boot-starter-web']
217+
def STARTER_DATA_JPA = [groupId: 'org.springframework.boot', artifactId: 'spring-boot-starter-data-jpa']
218+
def STARTER_GRAPHQL = [groupId: 'org.springframework.boot', artifactId: 'spring-boot-starter-graphql']
219+
def STARTER_RESTCLIENT = [groupId: 'org.springframework.boot', artifactId: 'spring-boot-starter-restclient']
220+
221+
/** Role-specific starter for an integration (client-side) module, or null when none fits
222+
* (those modules rely on the core starter inherited transitively via common-domain). */
223+
def integrationStarter = { String type ->
224+
switch (type?.trim()?.toLowerCase()) {
225+
case 'database': return STARTER_DATA_JPA
226+
case 'rest': return STARTER_RESTCLIENT // consumes an external REST API; no servlet web stack
227+
case 'graphql': return STARTER_GRAPHQL
228+
default: return null
229+
}
230+
}
231+
232+
/** Role-specific starter for a presentation (server-side) module, or null when none fits. */
233+
def presentationStarter = { String type ->
234+
switch (type?.trim()?.toLowerCase()) {
235+
case 'rest': return STARTER_WEB // serves REST endpoints over servlet MVC
236+
case 'graphql': return STARTER_GRAPHQL
237+
default: return null
238+
}
239+
}
240+
241+
/** Wraps a resolved starter into a module deps list: a one-element list when Spring is on
242+
* and a starter was resolved, otherwise empty. */
243+
def starterDeps = { starter ->
244+
(includeSpring && starter) ? [starter] : []
245+
}
246+
247+
// common-domain's own Spring dependency: the core starter, which provides the
248+
// @Configuration API to every dependent module transitively (empty when Spring is off).
249+
def commonSpring = includeSpring ? [STARTER] : []
250+
204251
// ─── Spring configuration class generator ─────────────────────────────────────
205252
// When includeSpring is true, every module the app composes carries a @Configuration
206-
// class in its `.config` sub-package (the app's Application class @Imports them all)
207-
// and declares spring-context. The module root package also gets a marker
208-
// "{Module}ComponentScan" interface that the @Configuration targets via
209-
// @ComponentScan(basePackageClasses=…) — a type-safe anchor for scanning the whole
210-
// module. Everything here is skipped when includeSpring is false.
211-
212-
def SPRING_CONTEXT = [groupId: 'org.springframework', artifactId: 'spring-context']
213-
def springCtx = includeSpring ? [SPRING_CONTEXT] : [] // appended to module deps only when Spring is enabled
253+
// class in its `.config` sub-package (the app's Application class @Imports them all).
254+
// The module root package also gets a marker "{Module}ComponentScan" interface that the
255+
// @Configuration targets via @ComponentScan(basePackageClasses=…) — a type-safe anchor
256+
// for scanning the whole module. Skipped entirely when includeSpring is false.
257+
214258
def configFqns = []
215259

216260
/** Writes a {Module}ComponentScan marker interface into the module root package and a
@@ -279,7 +323,7 @@ def parentInherit = includeSpring ? """\
279323
<parent>
280324
<groupId>org.springframework.boot</groupId>
281325
<artifactId>spring-boot-starter-parent</artifactId>
282-
<version>4.0.6</version>
326+
<version>4.1.0</version>
283327
<relativePath/>
284328
</parent>
285329
@@ -485,7 +529,7 @@ writeFile(projectDir, 'common-domain/pom.xml',
485529
modulePom(groupId, artifactId, version,
486530
'common-domain',
487531
'Domain classes common to all modules.',
488-
springCtx))
532+
commonSpring))
489533
srcTree(projectDir, 'common-domain', pkgPath,
490534
['common/domain'], ['common/domain'],
491535
'Domain classes common to all modules.')
@@ -515,7 +559,7 @@ integrations.each { intg ->
515559
modulePom(groupId, artifactId, version,
516560
domMod,
517561
"Domain classes for the ${intg.name} ${disp} data source.",
518-
['common-domain'] + springCtx))
562+
['common-domain']))
519563
srcTree(projectDir, domMod, pkgPath,
520564
[domSub], [domSub],
521565
"Domain classes for the ${intg.name} ${disp} integration.")
@@ -525,7 +569,7 @@ integrations.each { intg ->
525569
modulePom(groupId, artifactId, version,
526570
implMod,
527571
"Integration classes (DAOs, repositories) for the ${intg.name} ${disp} data source.",
528-
['common-domain', domMod] + springCtx,
572+
['common-domain', domMod] + starterDeps(integrationStarter(intg.rawType)),
529573
failsafeSection))
530574
srcTree(projectDir, implMod, pkgPath,
531575
[implSub], [implSub],
@@ -551,15 +595,15 @@ serviceModules.each { svcMod ->
551595
writeFile(projectDir, "${domMod}/pom.xml",
552596
modulePom(groupId, artifactId, version,
553597
domMod, domDesc,
554-
['common-domain'] + springCtx))
598+
['common-domain']))
555599
srcTree(projectDir, domMod, pkgPath, [domPkg], [domPkg], domDesc)
556600
configClass(domMod, domPkg)
557601

558602
// Business logic, depending on its own service-area domain module.
559603
writeFile(projectDir, "${svcMod}/pom.xml",
560604
modulePom(groupId, artifactId, version,
561605
svcMod, svcDesc,
562-
['common-domain', domMod] + springCtx))
606+
['common-domain', domMod]))
563607
srcTree(projectDir, svcMod, pkgPath, [svcPkg], [svcPkg], svcDesc)
564608
configClass(svcMod, svcPkg)
565609
}
@@ -575,7 +619,7 @@ presentations.each { pType ->
575619
modulePom(groupId, artifactId, version,
576620
domMod,
577621
"Domain classes for the ${disp} presentation tier.",
578-
['common-domain'] + springCtx))
622+
['common-domain']))
579623
srcTree(projectDir, domMod, pkgPath,
580624
["domain/${pType}"], ["domain/${pType}"],
581625
"Domain classes for the ${disp} presentation tier.")
@@ -585,7 +629,7 @@ presentations.each { pType ->
585629
modulePom(groupId, artifactId, version,
586630
implMod,
587631
"Request-handling classes (controllers) for the ${disp} presentation tier.",
588-
['common-domain', domMod] + springCtx))
632+
['common-domain', domMod] + starterDeps(presentationStarter(pType))))
589633
srcTree(projectDir, implMod, pkgPath,
590634
["presentation/${pType}"], ["presentation/${pType}"],
591635
"Request-handling classes for the ${disp} presentation tier.")
@@ -594,10 +638,19 @@ presentations.each { pType ->
594638

595639
// ─── app ─────────────────────────────────────────────────────────────────────
596640

597-
def appStarters = includeSpring ? [
598-
[groupId: 'org.springframework.boot', artifactId: 'spring-boot-starter-web'],
599-
[groupId: 'org.springframework.boot', artifactId: 'spring-boot-starter-data-jpa'],
600-
] : []
641+
// The app declares the web / data-jpa starters only when its composition warrants them, and
642+
// inherits whatever else its composed modules bring. A 'rest' presentation makes it a servlet
643+
// web app (so it also gets the WAR-deployment AppServletInitializer); a 'database' integration
644+
// gives it JPA + transactions (so Application carries @EnableTransactionManagement, which needs
645+
// spring-tx from spring-boot-starter-data-jpa). All gated on includeSpring.
646+
def hasRestPresentation = presentations.contains('rest')
647+
def hasDatabaseIntegration = integrations.any { it.rawType == 'database' }
648+
649+
def appStarters = []
650+
if (includeSpring) {
651+
if (hasRestPresentation) appStarters << STARTER_WEB
652+
if (hasDatabaseIntegration) appStarters << STARTER_DATA_JPA
653+
}
601654
writeFile(projectDir, 'app/pom.xml',
602655
modulePom(groupId, artifactId, version,
603656
'app',
@@ -611,22 +664,24 @@ if (includeSpring) {
611664
// module's @Configuration class so the whole tree is assembled into one context.
612665
def appConfigPkg = (pkgPath + '/app/config').replace('/', '.')
613666
def importsBlock = configFqns.sort().collect { " ${it}.class," }.join('\n')
667+
// @EnableTransactionManagement requires spring-tx (pulled in by spring-boot-starter-data-jpa),
668+
// so it is only emitted when a database integration puts that starter on the app's classpath.
669+
def txImport = hasDatabaseIntegration ? 'import org.springframework.transaction.annotation.EnableTransactionManagement;\n' : ''
670+
def txAnnotation = hasDatabaseIntegration ? '@EnableTransactionManagement\n' : ''
614671
writeFile(projectDir, "app/src/main/java/${pkgPath}/app/config/Application.java", """\
615672
package ${appConfigPkg};
616673
617674
import org.springframework.beans.factory.annotation.Value;
618675
import org.springframework.boot.SpringApplication;
619676
import org.springframework.boot.autoconfigure.SpringBootApplication;
620677
import org.springframework.context.annotation.Import;
621-
import org.springframework.transaction.annotation.EnableTransactionManagement;
622-
678+
${txImport}
623679
/**
624680
* Spring Boot main application class for running standalone in Boot configured embedded container. Not used with WAR
625681
* deployment.
626682
*/
627683
@SpringBootApplication
628-
@EnableTransactionManagement
629-
@Import({
684+
${txAnnotation}@Import({
630685
${importsBlock}
631686
})
632687
public class Application {
@@ -635,6 +690,9 @@ public class Application {
635690
}
636691
}
637692
""")
693+
// AppServletInitializer extends SpringBootServletInitializer (a servlet web stack), so it is
694+
// only generated when a 'rest' presentation makes the app a servlet web application.
695+
if (hasRestPresentation) {
638696
writeFile(projectDir, "app/src/main/java/${pkgPath}/app/config/AppServletInitializer.java", """\
639697
package ${appConfigPkg};
640698
@@ -657,6 +715,7 @@ public class AppServletInitializer extends SpringBootServletInitializer {
657715
}
658716
""")
659717
}
718+
}
660719

661720
// ─── acceptance-tests ─────────────────────────────────────────────────────────
662721

src/main/resources/META-INF/maven/archetype-metadata.xml

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,11 @@
6060
<defaultValue>true</defaultValue>
6161
<!-- true (default): generate the Spring Boot wiring — parent inherits
6262
spring-boot-starter-parent, every app-composed module gets a @Configuration
63-
class and spring-context, and app gets the Application / AppServletInitializer
64-
classes plus the web and data-jpa starters.
63+
class plus the Spring Boot starter suited to its role (common-domain carries
64+
the core spring-boot-starter that the plain modules inherit transitively), and
65+
app gets the Application class plus the role starters and entry-point classes its
66+
composition calls for (web + AppServletInitializer for a rest presentation,
67+
data-jpa + @EnableTransactionManagement for a database integration).
6568
false: omit all Spring artifacts (configs, classes, and pom items). -->
6669
</requiredProperty>
6770

src/site/asciidoc/modules.adoc

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,10 @@ _Depends on_: `common-domain`
4141

4242
`app`::
4343
Application assembly — produces the runnable artifact (e.g. Spring Boot über-jar).
44-
Contains the Spring Boot `Application` (`@SpringBootApplication`) and `AppServletInitializer`
45-
(WAR deployment) classes in `{base.package}.app.config`, and adds the
46-
`spring-boot-starter-web` and `spring-boot-starter-data-jpa` dependencies.
44+
Contains the Spring Boot `Application` (`@SpringBootApplication`) class in
45+
`{base.package}.app.config`, and adds the role starters its composition calls for — a `rest`
46+
presentation adds `spring-boot-starter-web` and an `AppServletInitializer` (WAR deployment)
47+
class; a `database` integration adds `spring-boot-starter-data-jpa` and `@EnableTransactionManagement`.
4748
+
4849
_Package_: `{base.package}.app`, `{base.package}.app.config` +
4950
_Depends on_: all non-test modules (`common-domain`, all domain and integration modules,
@@ -101,7 +102,7 @@ There is no `pom.xml` at the project root — `parent/` is a sibling of all othe
101102

102103
It contains:
103104

104-
* `<parent>` — inherits `spring-boot-starter-parent` (4.0.6) via an empty `<relativePath/>`, supplying Spring dependency management and plugin defaults
105+
* `<parent>` — inherits `spring-boot-starter-parent` (4.1.0) via an empty `<relativePath/>`, supplying Spring dependency management and plugin defaults
105106
* `<packaging>pom</packaging>`
106107
* `<modules>` — all sibling modules referenced as `../module` relative paths, sorted alphabetically
107108
* `<dependencyManagement>` — all modules pinned to `${project.version}`, sorted alphabetically by `artifactId`

src/site/asciidoc/requirements.adoc

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -48,9 +48,12 @@ Prompt for a comma-separated list of presentation-tier types
4848
Whether to generate the Spring Boot wiring.
4949

5050
* `true` (default): the `parent/` POM inherits `spring-boot-starter-parent`, every
51-
app-composed module carries a `@Configuration` class and `spring-context`, and `app`
52-
gets the `Application` / `AppServletInitializer` classes plus the
53-
`spring-boot-starter-web` and `spring-boot-starter-data-jpa` dependencies.
51+
app-composed module carries a `@Configuration` class, and Spring Boot starters are
52+
assigned per module by role — `common-domain` carries the core `spring-boot-starter`
53+
that the plain `domain-*` and service modules inherit transitively. The `app` module
54+
carries the `Application` class and adds the role starters its composition calls for: a
55+
`rest` presentation adds `spring-boot-starter-web` and an `AppServletInitializer`; a
56+
`database` integration adds `spring-boot-starter-data-jpa` and `@EnableTransactionManagement`.
5457
* `false`: omit all Spring artifacts — configs, classes, and POM items — producing a
5558
plain multi-module Java project with the same module structure.
5659

@@ -66,7 +69,7 @@ Do not create a `pom.xml` at the project root.
6669
sorted alphabetically.
6770
* List every sibling module in `<dependencyManagement>` pinned to
6871
`${project.version}`, sorted alphabetically by `artifactId`.
69-
* Inherit `spring-boot-starter-parent` (version 4.0.6) as the parent POM — via `<parent>`
72+
* Inherit `spring-boot-starter-parent` (version 4.1.0) as the parent POM — via `<parent>`
7073
with an empty `<relativePath/>`, not a BOM import — so Spring artifact versions and
7174
sensible plugin defaults are managed centrally.
7275
* Properties: Java 21 via `maven.compiler.release`; UTF-8 source encoding via
@@ -134,11 +137,14 @@ For each presentation type, generate two modules
134137

135138
Assembles the runnable artifact (e.g. Spring Boot uber jar).
136139
Depend on all generated modules except `acceptance-tests`, `common-testing`,
137-
and `app` itself, plus `spring-boot-starter-web` and `spring-boot-starter-data-jpa`.
138-
Contains the Spring Boot `Application` (`@SpringBootApplication`,
139-
`@EnableTransactionManagement`) and `AppServletInitializer` (WAR deployment)
140-
classes in the `.config` sub-package. `Application` `@Import`s the `@Configuration`
141-
class that every app-composed module carries in its own `.config` sub-package.
140+
and `app` itself. Add the role starters its composition calls for: `spring-boot-starter-web`
141+
when a `rest` presentation is present, `spring-boot-starter-data-jpa` when a `database`
142+
integration is present.
143+
Contains the Spring Boot `Application` (`@SpringBootApplication`) class in the `.config`
144+
sub-package, which `@Import`s the `@Configuration` class that every app-composed module
145+
carries in its own `.config` sub-package. `Application` additionally carries
146+
`@EnableTransactionManagement` when a `database` integration is present, and an
147+
`AppServletInitializer` (WAR deployment) class is generated when a `rest` presentation is present.
142148

143149
=== `acceptance-tests`
144150

src/test/resources/projects/full/verify.groovy

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,9 +234,31 @@ assert cdConfig.contains("import ${p.replace('/', '.')}.common.domain.CommonDoma
234234

235235
// parent imports the Spring Boot BOM; app pulls the starters
236236
assert parentPom.contains('<artifactId>spring-boot-starter-parent</artifactId>') : 'parent must inherit spring-boot-starter-parent'
237+
assert parentPom.contains('<version>4.1.0</version>') : 'parent must pin Spring Boot 4.1.0'
237238
assert appPom.contains('<artifactId>spring-boot-starter-web</artifactId>') : 'app must depend on spring-boot-starter-web'
238239
assert appPom.contains('<artifactId>spring-boot-starter-data-jpa</artifactId>') : 'app must depend on spring-boot-starter-data-jpa'
239240

241+
// ── per-module Spring dependencies (#3): the blanket spring-context is gone; common-domain
242+
// carries the core starter (so "plain" modules inherit @Configuration support transitively)
243+
// and presentation/integration modules each declare a role-specific starter.
244+
modules.each { m ->
245+
assert !text("${m}/pom.xml").contains('spring-context') : "${m} must not declare spring-context"
246+
}
247+
assert text('common-domain/pom.xml').contains('<artifactId>spring-boot-starter</artifactId>') \
248+
: 'common-domain must carry the core spring-boot-starter (provides the @Configuration API transitively)'
249+
assert text('presentation-rest/pom.xml').contains('<artifactId>spring-boot-starter-web</artifactId>') \
250+
: 'presentation-rest must declare spring-boot-starter-web'
251+
assert text('presentation-graphql/pom.xml').contains('<artifactId>spring-boot-starter-graphql</artifactId>') \
252+
: 'presentation-graphql must declare spring-boot-starter-graphql (#2)'
253+
assert text('integration-db-users/pom.xml').contains('<artifactId>spring-boot-starter-data-jpa</artifactId>') \
254+
: 'database integration must declare spring-boot-starter-data-jpa'
255+
assert text('integration-graphql-catalog/pom.xml').contains('<artifactId>spring-boot-starter-graphql</artifactId>') \
256+
: 'graphql integration must declare spring-boot-starter-graphql'
257+
// plain domain/service modules declare no Spring dependency directly (inherited via common-domain)
258+
['domain-rest', 'domain-service-orders', 'service-orders'].each { m ->
259+
assert !text("${m}/pom.xml").contains('org.springframework') : "${m} must not declare a Spring dependency directly"
260+
}
261+
240262
// ── package-info Javadoc conventions (#2 test wording, #6 infrastructure, #7 GraphQL) ──
241263

242264
assert text("common-domain/src/test/java/${p}/common/domain/package-info.java").contains('Tests for') \

0 commit comments

Comments
 (0)