Skip to content

Commit e1e9402

Browse files
jeffjensenclaude
andcommitted
feat(arch): Wire generated projects as a Spring Boot application
Generated projects now assemble into a Spring context on Spring Boot 4.0.6: - parent/pom.xml inherits spring-boot-starter-parent (4.0.6) via <parent> with an empty <relativePath/>, providing Spring dependency management and plugin defaults - every app-composed module carries a @configuration class in its .config subpackage and declares spring-context - app holds the Spring Boot Application (@SpringBootApplication, @EnableTransactionManagement) and AppServletInitializer (WAR) classes in {package}.app.config; Application @imports every module's @configuration - app adds spring-boot-starter-web and spring-boot-starter-data-jpa The integration tests assert this wiring and confirm each generated project still builds (mvn verify) with the Spring stack on the classpath. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FTaQTM9wYTXLvvgCXSwhRm
1 parent f444d95 commit e1e9402

12 files changed

Lines changed: 309 additions & 18 deletions

File tree

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

Lines changed: 124 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -75,16 +75,20 @@ def failsafeSection = """\
7575
</build>
7676
"""
7777

78-
/** Builds a child-module pom.xml string (2-space indent). */
78+
/** Builds a child-module pom.xml string (2-space indent).
79+
* A dependency entry is either a String (an internal module artifactId, inheriting this project's
80+
* groupId and BOM-managed version) or a Map [groupId:…, artifactId:…] for an external dependency. */
7981
def modulePom = { String gId, String parentAId, String ver,
80-
String aId, String desc, List<String> deps = [], String buildSection = '' ->
82+
String aId, String desc, List deps = [], String buildSection = '' ->
8183
def depsSection = ''
8284
if (deps) {
8385
def lines = deps.collect { dep ->
86+
def dGroup = (dep instanceof Map) ? dep.groupId : gId
87+
def dArtifact = (dep instanceof Map) ? dep.artifactId : dep
8488
"""\
8589
<dependency>
86-
<groupId>${gId}</groupId>
87-
<artifactId>${dep}</artifactId>
90+
<groupId>${dGroup}</groupId>
91+
<artifactId>${dArtifact}</artifactId>
8892
</dependency>"""
8993
}.join('\n')
9094
depsSection = "\n <dependencies>\n${lines}\n </dependencies>\n"
@@ -124,6 +128,7 @@ def pkgPath = pkg.replace('.', '/')
124128
def integrationsInput = (props.getProperty('integrations') ?: '').trim()
125129
def serviceAreasInput = (props.getProperty('serviceAreas') ?: '').trim()
126130
def presentationTypesInput = (props.getProperty('presentationTypes') ?: 'rest').trim()
131+
def includeSpring = ((props.getProperty('includeSpring') ?: 'true').trim().toLowerCase() != 'false')
127132

128133
// ─── Parse Integrations ───────────────────────────────────────────────────────
129134
// Input format: "type:name[,type:name,...]" e.g. "database:users,rest:orders"
@@ -185,6 +190,38 @@ def projectDir = new File(request.outputDirectory)
185190
def subDir = new File(projectDir, artifactId)
186191
if (subDir.isDirectory()) projectDir = subDir
187192

193+
// ─── Spring configuration class generator ─────────────────────────────────────
194+
// When includeSpring is true, every module the app composes carries a @Configuration
195+
// class in its `.config` sub-package (the app's Application class @Imports them all)
196+
// and declares spring-context. Everything here is skipped when includeSpring is false.
197+
198+
def SPRING_CONTEXT = [groupId: 'org.springframework', artifactId: 'spring-context']
199+
def springCtx = includeSpring ? [SPRING_CONTEXT] : [] // appended to module deps only when Spring is enabled
200+
def configFqns = []
201+
202+
/** Writes a @Configuration class into {module}/…/{mainSub}/config and records its FQN; no-op when Spring is off. */
203+
def configClass = { String module, String mainSub ->
204+
if (!includeSpring) return null
205+
def className = module.split('-').collect { it.capitalize() }.join('') + 'Configuration'
206+
def pkgFq = (pkgPath + '/' + mainSub + '/config').replace('/', '.')
207+
def dir = "${module}/src/main/java/${pkgPath}/${mainSub}/config"
208+
writeFile(projectDir, "${dir}/${className}.java", """\
209+
package ${pkgFq};
210+
211+
import org.springframework.context.annotation.Configuration;
212+
213+
/**
214+
* Spring configuration for the ${module} module.
215+
*/
216+
@Configuration
217+
public class ${className} {
218+
}
219+
""")
220+
def fqn = "${pkgFq}.${className}".toString()
221+
configFqns << fqn
222+
return fqn
223+
}
224+
188225
// ─── parent/pom.xml ───────────────────────────────────────────────────────────
189226
// Modules listed with "../" relative path; child modules use relativePath back here.
190227
// The placeholder pom.xml at the project root (generated by the archetype engine)
@@ -201,6 +238,16 @@ def dmXml = allModules.collect { mod ->
201238
</dependency>"""
202239
}.join('\n')
203240

241+
def parentInherit = includeSpring ? """\
242+
<parent>
243+
<groupId>org.springframework.boot</groupId>
244+
<artifactId>spring-boot-starter-parent</artifactId>
245+
<version>4.0.6</version>
246+
<relativePath/>
247+
</parent>
248+
249+
""" : ''
250+
204251
def parentPom = """\
205252
<?xml version="1.0" encoding="UTF-8"?>
206253
<project xmlns="http://maven.apache.org/POM/4.0.0"
@@ -209,7 +256,7 @@ def parentPom = """\
209256
https://maven.apache.org/xsd/maven-4.0.0.xsd">
210257
<modelVersion>4.0.0</modelVersion>
211258
212-
<groupId>${groupId}</groupId>
259+
${parentInherit} <groupId>${groupId}</groupId>
213260
<artifactId>${artifactId}</artifactId>
214261
<version>${version}</version>
215262
<packaging>pom</packaging>
@@ -400,10 +447,12 @@ try {
400447
writeFile(projectDir, 'common-domain/pom.xml',
401448
modulePom(groupId, artifactId, version,
402449
'common-domain',
403-
'Domain classes common to all modules.'))
450+
'Domain classes common to all modules.',
451+
springCtx))
404452
srcTree(projectDir, 'common-domain', pkgPath,
405453
['common/domain'], ['common/domain'],
406454
'Domain classes common to all modules.')
455+
configClass('common-domain', 'common/domain')
407456

408457
// ─── common-testing ───────────────────────────────────────────────────────────
409458

@@ -429,20 +478,22 @@ integrations.each { intg ->
429478
modulePom(groupId, artifactId, version,
430479
domMod,
431480
"Domain classes for the ${intg.name} ${disp} data source.",
432-
['common-domain']))
481+
['common-domain'] + springCtx))
433482
srcTree(projectDir, domMod, pkgPath,
434483
[domSub], [domSub],
435484
"Domain classes for the ${intg.name} ${disp} integration.")
485+
configClass(domMod, domSub)
436486

437487
writeFile(projectDir, "${implMod}/pom.xml",
438488
modulePom(groupId, artifactId, version,
439489
implMod,
440490
"Integration classes (DAOs, repositories) for the ${intg.name} ${disp} data source.",
441-
['common-domain', domMod],
491+
['common-domain', domMod] + springCtx,
442492
failsafeSection))
443493
srcTree(projectDir, implMod, pkgPath,
444494
[implSub], [implSub],
445495
"Integration classes for the ${intg.name} ${disp} data source.")
496+
configClass(implMod, implSub)
446497
}
447498

448499
// ─── Service modules ──────────────────────────────────────────────────────────
@@ -463,15 +514,17 @@ serviceModules.each { svcMod ->
463514
writeFile(projectDir, "${domMod}/pom.xml",
464515
modulePom(groupId, artifactId, version,
465516
domMod, domDesc,
466-
['common-domain']))
517+
['common-domain'] + springCtx))
467518
srcTree(projectDir, domMod, pkgPath, [domPkg], [domPkg], domDesc)
519+
configClass(domMod, domPkg)
468520

469521
// Business logic, depending on its own service-area domain module.
470522
writeFile(projectDir, "${svcMod}/pom.xml",
471523
modulePom(groupId, artifactId, version,
472524
svcMod, svcDesc,
473-
['common-domain', domMod]))
525+
['common-domain', domMod] + springCtx))
474526
srcTree(projectDir, svcMod, pkgPath, [svcPkg], [svcPkg], svcDesc)
527+
configClass(svcMod, svcPkg)
475528
}
476529

477530
// ─── Presentation: domain-{type} and presentation-{type} ──────────────────────
@@ -485,31 +538,89 @@ presentations.each { pType ->
485538
modulePom(groupId, artifactId, version,
486539
domMod,
487540
"Domain classes for the ${disp} presentation tier.",
488-
['common-domain']))
541+
['common-domain'] + springCtx))
489542
srcTree(projectDir, domMod, pkgPath,
490543
["domain/${pType}"], ["domain/${pType}"],
491544
"Domain classes for the ${disp} presentation tier.")
545+
configClass(domMod, "domain/${pType}")
492546

493547
writeFile(projectDir, "${implMod}/pom.xml",
494548
modulePom(groupId, artifactId, version,
495549
implMod,
496550
"Request-handling classes (controllers) for the ${disp} presentation tier.",
497-
['common-domain', domMod]))
551+
['common-domain', domMod] + springCtx))
498552
srcTree(projectDir, implMod, pkgPath,
499553
["presentation/${pType}"], ["presentation/${pType}"],
500554
"Request-handling classes for the ${disp} presentation tier.")
555+
configClass(implMod, "presentation/${pType}")
501556
}
502557

503558
// ─── app ─────────────────────────────────────────────────────────────────────
504559

560+
def appStarters = includeSpring ? [
561+
[groupId: 'org.springframework.boot', artifactId: 'spring-boot-starter-web'],
562+
[groupId: 'org.springframework.boot', artifactId: 'spring-boot-starter-data-jpa'],
563+
] : []
505564
writeFile(projectDir, 'app/pom.xml',
506565
modulePom(groupId, artifactId, version,
507566
'app',
508567
'Application assembly — produces the runnable artifact.',
509-
appDeps))
568+
appDeps + appStarters))
510569
srcTree(projectDir, 'app', pkgPath, ['app'], ['app'],
511570
'Application assembly and entry point.')
512571

572+
if (includeSpring) {
573+
// Spring Boot entry-point classes live in the app's .config package and @Import every
574+
// module's @Configuration class so the whole tree is assembled into one context.
575+
def appConfigPkg = (pkgPath + '/app/config').replace('/', '.')
576+
def importsBlock = configFqns.sort().collect { " ${it}.class," }.join('\n')
577+
writeFile(projectDir, "app/src/main/java/${pkgPath}/app/config/Application.java", """\
578+
package ${appConfigPkg};
579+
580+
import org.springframework.beans.factory.annotation.Value;
581+
import org.springframework.boot.SpringApplication;
582+
import org.springframework.boot.autoconfigure.SpringBootApplication;
583+
import org.springframework.context.annotation.Import;
584+
import org.springframework.transaction.annotation.EnableTransactionManagement;
585+
586+
/**
587+
* Spring Boot main application class for running standalone in Boot configured embedded container. Not used with WAR
588+
* deployment.
589+
*/
590+
@SpringBootApplication
591+
@EnableTransactionManagement
592+
@Import({
593+
${importsBlock}
594+
})
595+
public class Application {
596+
public static void main(final String[] args) {
597+
SpringApplication.run(Application.class, args);
598+
}
599+
}
600+
""")
601+
writeFile(projectDir, "app/src/main/java/${pkgPath}/app/config/AppServletInitializer.java", """\
602+
package ${appConfigPkg};
603+
604+
import org.slf4j.Logger;
605+
import org.slf4j.LoggerFactory;
606+
import org.springframework.boot.builder.SpringApplicationBuilder;
607+
import org.springframework.boot.web.servlet.support.SpringBootServletInitializer;
608+
609+
/**
610+
* web.xml configuration replacement for WAR deployment.
611+
*/
612+
public class AppServletInitializer extends SpringBootServletInitializer {
613+
private final Logger log = LoggerFactory.getLogger(getClass());
614+
615+
@Override
616+
protected SpringApplicationBuilder configure(final SpringApplicationBuilder builder) {
617+
log.info("Starting application");
618+
return builder.sources(Application.class);
619+
}
620+
}
621+
""")
622+
}
623+
513624
// ─── acceptance-tests ─────────────────────────────────────────────────────────
514625

515626
writeFile(projectDir, 'acceptance-tests/pom.xml',

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,15 @@
5656
<!-- Comma-separated types, e.g. rest,graphql -->
5757
</requiredProperty>
5858

59+
<requiredProperty key="includeSpring">
60+
<defaultValue>true</defaultValue>
61+
<!-- true (default): generate the Spring Boot wiring — parent inherits
62+
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.
65+
false: omit all Spring artifacts (configs, classes, and pom items). -->
66+
</requiredProperty>
67+
5968
</requiredProperties>
6069

6170
<!--

src/site/asciidoc/modules.adoc

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ Every module contains a full `src/` scaffold
88
with a `package-info.java` in each module's primary leaf package under both
99
`src/main/java` and `src/test/java`.
1010

11+
Every module that the `app` composes also carries a Spring `@Configuration` class in a
12+
`.config` sub-package; the `app` module's `Application` class `@Import`s them all so the
13+
whole tree assembles into a single Spring context.
14+
1115
== Fixed Modules
1216

1317
These modules are always generated regardless of input.
@@ -37,8 +41,11 @@ _Depends on_: `common-domain`
3741

3842
`app`::
3943
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.
4047
+
41-
_Package_: `{base.package}.app` +
48+
_Package_: `{base.package}.app`, `{base.package}.app.config` +
4249
_Depends on_: all non-test modules (`common-domain`, all domain and integration modules,
4350
all service modules and their `domain-service` modules, all presentation modules)
4451

@@ -94,6 +101,7 @@ There is no `pom.xml` at the project root — `parent/` is a sibling of all othe
94101

95102
It contains:
96103

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

src/site/asciidoc/requirements.adoc

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,17 @@ Prompt for a comma-separated list of presentation-tier types
4343
* Leave blank (or provide only blank entries) to generate a project with no
4444
presentation modules.
4545

46+
=== `includeSpring` (default: `true`)
47+
48+
Whether to generate the Spring Boot wiring.
49+
50+
* `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.
54+
* `false`: omit all Spring artifacts — configs, classes, and POM items — producing a
55+
plain multi-module Java project with the same module structure.
56+
4657
== Module Structure
4758

4859
Generate all modules as siblings of a `parent/` directory.
@@ -55,6 +66,9 @@ Do not create a `pom.xml` at the project root.
5566
sorted alphabetically.
5667
* List every sibling module in `<dependencyManagement>` pinned to
5768
`${project.version}`, sorted alphabetically by `artifactId`.
69+
* Inherit `spring-boot-starter-parent` (version 4.0.6) as the parent POM — via `<parent>`
70+
with an empty `<relativePath/>`, not a BOM import — so Spring artifact versions and
71+
sensible plugin defaults are managed centrally.
5872
* Properties: Java 21 via `maven.compiler.release`; UTF-8 source encoding via
5973
`project.build.sourceEncoding`.
6074
* `<pluginManagement>` — declare pinned versions for `maven-compiler-plugin`,
@@ -120,7 +134,11 @@ For each presentation type, generate two modules
120134

121135
Assembles the runnable artifact (e.g. Spring Boot uber jar).
122136
Depend on all generated modules except `acceptance-tests`, `common-testing`,
123-
and `app` itself.
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.
124142

125143
=== `acceptance-tests`
126144

@@ -138,7 +156,8 @@ Functional tests that exercise the application end-to-end.
138156
Each module's source root is the package derived from the base package prompt.
139157
Place a `package-info.java` in each module's primary leaf package under both
140158
`src/main/java` and `src/test/java`, with a Javadoc comment describing the package's
141-
purpose; the test-side comment is prefixed with "Tests for ...".
159+
purpose; the test-side comment is prefixed with "Tests for ...". Each app-composed
160+
module additionally carries a `@Configuration` class in a `.config` sub-package.
142161
The package for each module is:
143162

144163
[cols="1,1"]

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -240,11 +240,15 @@ def psScript = text('parent/mvn-update-properties-versions.ps1')
240240
assert psScript.contains('mvn versions:update-properties') : 'PowerShell script must run mvn versions:update-properties'
241241
assert psScript.contains('\r\n') : 'PowerShell script must use CRLF line endings'
242242

243-
// ── #8 service-area domain module ────────────────────────────────────────────
243+
// ── #8 service-area domain module + #4 Spring Boot wiring ────────────────────
244244

245245
check('domain-service/pom.xml')
246246
assert text('domain-service/pom.xml').contains('<artifactId>common-domain</artifactId>') : 'domain-service missing common-domain dep'
247247
assert text('service/pom.xml').contains('<artifactId>domain-service</artifactId>') : 'service must depend on domain-service'
248+
check("app/src/main/java/${p}/app/config/Application.java")
249+
check("app/src/main/java/${p}/app/config/AppServletInitializer.java")
250+
assert parentPom.contains('<artifactId>spring-boot-starter-parent</artifactId>') : 'parent must inherit spring-boot-starter-parent'
251+
assert appPom.contains('<artifactId>spring-boot-starter-web</artifactId>') : 'app must depend on spring-boot-starter-web'
248252

249253
_buildLog.append("\n=== PASSED ===\n")
250254
true

src/test/resources/projects/full/archetype.properties

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@ package=com.example.full
55
integrations=database:users,graphql:catalog
66
serviceAreas=orders,notifications
77
presentationTypes=rest,graphql
8+
includeSpring=true

0 commit comments

Comments
 (0)