Portfolio Manifest — Prepared 2026-09-19
Full-Stack Java/Gradle Engineer
A deliberate migration of Axon Framework 5's build system from Maven to Gradle — converted
one file at a time, each one individually inspected against the Maven source rather than run
through an automated converter. Build-logic conventions are done, and test-logging,
common, and update are all fully converted — every gap found along the way,
forty-three so far, is disclosed in this repo's own build log, not smoothed over. Still early — 11 of 14
reactor modules haven't started converting at all.
A real Maven→Gradle migration, verified rather than trusted
Pointing a conversion tool at a Maven reactor and committing whatever comes out would be faster. It would
also miss everything interesting: Gradle has no equivalent of Maven's parent-POM inheritance, so a shared
dependency block that's harmless in axon-parent becomes a literal circular dependency the
moment it's mechanically applied to the one module that dependency points at. A checkstyle rule copied
byte-for-byte looks complete right up until a second read catches the comment three lines above it
explaining why one entry is deliberately missing — caught before landing, not after, but wrong on the
first pass all the same.
Build-logic conventions done · test-logging published · common complete (144/144) · update complete (27/27) · 11 modules not started
Migration order is deliberate and bottom-up: the shared Gradle conventions every module will apply — Java
toolchain, checkstyle, publishing, signing — had to exist before any real module could be converted onto
them. test-logging was the first module through that pipeline, published for real
(org.axonframework:axon-test-logging) with its own genuine circular-dependency problem solved
along the way. common followed, converted file by file in dependency order (base exception
hierarchy first, then each utility class once whatever it references exists) — now fully done at 144 of 144
files, the first module beyond test-logging to reach that state. update followed,
also fully done at 27 of 27 files — the first real consumer-module dependency on common
anywhere in this build, which is how a genuine JSR-305 dependency-scoping gap got found and fixed along the
way. The rest of the reactor — conversion, messaging,
eventsourcing, modelling, and seven more — hasn't started; see
todo.md
for the honest current state rather than assuming more is done.
Every structural change gets a real gradle build or gradle :projects run
before being called done — including re-running it live when a fix's correctness was itself in
question, not just reasoning about what the fix should do.
No gradle/libs.versions.toml version catalog (dependency-drift risk accepted, not
overlooked) and integrationtests deliberately not publishing to Maven Central, diverging
from upstream's own current behavior — both written down in
todo.md, not silently absorbed.
Found by running a real build, reading a live API doc, or auditing already-converted files a second time — not by assuming the first pass was right
Four turned up in this fork's own Gradle translation of Maven's inheritance and publishing model. One
turned up in a third-party plugin's live interaction with a specific Gradle version, undocumented anywhere
public at the time. One turned up in this fork's own .gitignore. One turned up in upstream
Axon's own Maven config — real drift in a widely-used project, not a typo. One turned up in Axon's own HTML
hitting a stricter tool than upstream builds with. One turned up in the CI workflow's own first real run.
Seven turned up while converting common's own source files: a stale pre-Java-7 idiom carrying a
dead, unreachable catch block in two files, modernized without changing behavior; two real
inconsistencies caught by cross-checking new files against the interfaces and sibling classes they
implement rather than reading each file in isolation; a nullness-contract chain across four files that
needed the user's own judgment call to resolve, not just a mechanical fix; a nullness gap grounded in a
documented JDK API contract rather than a guess; and a deliberate rewrite whose first, plausible-looking
attempt turned out wrong, caught only by testing it against a real, disposable database instance. Six more
turned up finishing common's last few files: missing-@Nullable gaps, a
message-polarity bug, and a wrong exception type, all found by cross-checking a file against the interface
or sibling it implements rather than reading it in isolation. Seven more turned up from a
deliberate retroactive pass: once common reached 144 of 144 files, a second, independent audit
re-checked every one of them against a DRY/SOLID charter this fork adopted partway through — not because
any of them were wrong when first converted, but because the charter itself came later. All seven were the
same class of gap as everything above: a javadoc claim that didn't match its own implementation, a
copy-pasted error message with the polarity backwards, and five places where a value can genuinely be
null but the type didn't say so. Ten turned up converting the entire update
module, 27 of 27 files: a dead parameter silently discarding real vulnerability data, a duplicated
classpath scan next to a record built straight from an unchecked Properties lookup, a
singleton whose own field could be reassigned by any caller, an interface-wide nullness gap repeated across
three implementations, two more javadoc claims that didn't match their own implementation, and a
thread-safety gap confirmed identical in the real upstream source. The final two turned up from a second
comprehensive drift sweep of this fork as a whole, re-checking both modules together rather than trusting
either one's own individual completion as final: one more missing-@Nullable gap in
common, and one more copy-pasted error message, this time naming the wrong algorithm instead of
the wrong polarity.
axon-parent unconditionally adds axon-test-logging as a test
dependency to every inheriting module — upstream's own pom.xml dodges the resulting
self-reference by parenting that one module on the root aggregator instead. Gradle's convention-plugin
model has no equivalent "skip one level of inheritance" lever, and the shared
java-conventions plugin already had that dependency line, so applying it to
test-logging itself would create a literal self-dependency. Guarded with a one-line
if (project.name != "test-logging"), verified with a real
gradle :test-logging:dependencies run showing no self-reference in the resolved graph.
nmcpSettings { centralPortal { username = providers.environmentVariable(...) } }
fails Gradle 9.2's configuration cache with cannot serialize object of type ...
ValueSourceProvider. Reproduced directly by running the build, not inferred from a stack trace
read in isolation — searched publicly first and found nothing documented about it. Fixed with a
plain System.getenv(...) read instead of the lazy provider.
build/ directory does double duty: a real, intentionally-tracked module
directory (mirroring Maven's build/parent, build/checkstyle.xml) and
Gradle's default build-output location for the root project — the same path, two purposes. A blanket
.gitignore exception meant to protect the former was quietly letting Gradle's own
generated reports through as trackable files. Replaced with an explicit allow-list of just the real
paths, verified with git check-ignore -v against both cases before trusting it.
include(...) in settings.gradle.kts pointed at a module directory
that didn't exist yet, and Gradle refuses to configure a build at all under that condition — not just
skip the missing module. Confirmed with a real failing gradle run before deciding on a
fix. Fixed by commenting each include out individually, brought back one at a time as that module is
actually converted.
axon-todo, axon-legacy, axon-legacy-aggregate, and
axon-legacy-saga appear in upstream's maven-javadoc-plugin configuration but
match nothing in this Axon 5 reactor's actual module list — uncleaned drift from an Axon 4-era POM,
confirmed by cross-checking every real module name. Not carried forward.
axon-<name>, but Gradle's
base.archivesName defaults to the bare project directory name — and, confirmed by
generating the actual POM rather than assuming the jar filename being right meant the coordinate was
too, MavenPublication.artifactId doesn't inherit from archivesName either; a
second, separate default needed overriding. test-logging had already been generating a
POM with <artifactId>test-logging</artifactId> instead of
axon-test-logging since its own commit. Fixed once, centrally, for every module.
common got its first real source file, javadoc failed with
error: self-closing element not allowed on a <p/> tag. Not a source
bug to hand-fix: upstream's own root pom.xml already disables doclint entirely
(<doclint>none</doclint>) for exactly this reason — JDK 25's stricter
javadoc, not upstream's. Gradle's side had just never replicated that suppression. Fixed centrally,
confirmed with a clean gradle :common:build afterward.
gradle-version: '9.2' in the new build-verification workflow failed immediately with
Error: Gradle version 9.2 does not exist — gradle/actions/setup-gradle needs
the exact release string, not Gradle's own shorthand. Caught by watching the actual run, not assumed
green because the YAML looked right. Fixed to '9.2.0', verified with a second real run
that took 1m53s instead of 15 seconds — the difference between failing at setup and actually building.
maven-jar-plugin's addDefaultImplementationEntries derives
Implementation-Vendor from root pom.xml's <organization> —
carried over literally into the Gradle jar manifest during the convention-plugin translation, even
though every other piece of POM metadata (license, SCM, developers) had already been repointed at this
fork's own ownership. Caught on a routine re-read of the convention plugin, not by a build failure.
Fixed to this fork's actual owner.
digest.Digester.md5Hex() and io.IOUtils.UTF8 both used the pre-Java-7
getBytes("UTF-8") / Charset.forName("UTF-8") pattern.
getBytes(String) throws a checked UnsupportedEncodingException that can
never actually fire — UTF-8 support is guaranteed by the JLS — so upstream's own source carries a
dead catch block around it. Modernized both to java.nio.charset.StandardCharsets.UTF_8,
which let the unreachable catch in Digester be removed outright. No behavioral change —
same singleton Charset, identical bytes.
Cache interface's own default method throws IllegalArgumentException
when a supplier produces null — WeakReferenceCache's override of the same
method threw IllegalStateException instead, for the identical condition. Not caught by
reading the file alone; caught by cross-checking it against the interface it implements, a technique
adopted specifically because a first pass in isolation had already missed it once.
Assert.notNull(...) (→ IllegalArgumentException) where every other
class converted in the same package by that point used Objects.requireNonNull(...)
(→ NullPointerException) for the same kind of constructor validation. Both are
individually defensible; only one matches what its neighbors already do. Standardized on the
sibling pattern, found the same way as gap #11 — by comparing against already-converted files in the
same package rather than reviewing each file only against its own Maven source.
AbstractMethodPropertyAccessStrategy.propertyFor declared its property
parameter @Nullable, widening PropertyAccessStrategy's own non-null
abstract contract, then forwarded it unchecked into getterName(String property) —
non-null in the same class. BeanPropertyAccessStrategy's override of
getterName inherited that widening, but its body calls
property.charAt(0) unconditionally — a real NPE if the annotation were ever taken at
its word. The real call path never passes null, but upstream's own test file declares
@Nullable on its test-double overrides of the same method, so the widening wasn't
obviously an accident either — confirmed the fix with the user rather than deciding unilaterally, the
one judgment call this fork treated as genuinely ambiguous. Separately, the same investigation found
the return type had the opposite problem: every real implementation returns null
in some branch and the only caller loops on it, but neither the abstract method nor one of its two
overrides declared @Nullable Property<T> — fixed to match the one override that
already had it right.
ConnectionWrapperFactory.isEmpty and
.invokeMethodAndUnwrapNestedException both receive args from
InvocationHandler.invoke's own parameter, which the JDK documents as null
(not an empty array) when the invoked method takes no arguments. isEmpty already
null-checked it but didn't declare @Nullable; the other method forwarded the same
possibly-null value without declaring it either. Unlike gap #13, this one needed no judgment call —
the JDK's own contract settles it. Fixed both.
Oracle11Utils, a pre-12c workaround for Oracle lacking native
auto-increment: it retrofits one via CREATE SEQUENCE +
CREATE OR REPLACE TRIGGER. Nothing else in the Axon codebase calls it — confirmed by
grepping the whole Maven source — so rewriting it for Oracle 23ai, which has native identity
columns, breaks nothing else. First attempt: ALTER TABLE ... MODIFY (col GENERATED AS
IDENTITY ...) on the existing plain column — failed for real against a live, disposable
Oracle 23ai Free container with ORA-30673 ("column to be modified is not an identity
column"). Oracle's MODIFY identity clause only adjusts a column that's already
identity; it cannot convert a plain one. The actual fix — drop the column, re-add it declared
identity — was verified the same way (three inserts producing ids 1, 2, 3), and is itself a
real, documented trade-off: unlike the original's purely-additive trigger, this discards any
existing data in that column. A docker-compose.yml plus two shell scripts (modeled on
this user's other portfolio forks' verification tooling, scoped down to the one container this
repo actually needs) now exist specifically so this kind of claim never has to be taken on faith.
Component<C>.resolve(Configuration) was declared non-null, but
AbstractComponent — the only implementation base class that exists — overrides it
@Nullable. Found during a deliberately-requested post-closure health check, not a
routine file-by-file pass: this file was one of twelve converted under real compile-time pressure
(a circular closure spanning three packages), so the whole closure got a second, holistic look once
it landed rather than trusting each file's individual review alone. Traced whether it's actually
reachable today — both real subclasses always produce non-null, so it doesn't bite yet — but the
interface's own annotation didn't match its base implementation's real contract. Same class of gap
as WeakReferenceCache and the property-package chain earlier, this time
caught by reviewing a whole closure together rather than one file at a time.
javac resolved correctlyLifecycleRegistry's Consumer<Configuration> overloads of
onStart/onShutdown each forward to a block lambda that returns a
CompletableFuture<?> — value-compatible only with the LifecycleHandler
overload, not the void-returning Consumer one, so javac resolves it
correctly per JLS 15.27.3 with no cast needed. The IDE's language server resolved it to the wrong
overload anyway, flagging valid return statements as errors. Two sibling overloads a few
lines up in the very same file already disambiguate this exact shape with an explicit
(LifecycleHandler)/(Consumer<Configuration>) cast; these two just
hadn't followed their own file's own established pattern. Added the matching cast to both.
ComponentDecorator.decorate's delegate parameter was declared plain
non-null, but its one real call site — DecoratedComponent.doResolve — passes
delegate.resolve(configuration) straight through, which is genuinely
@Nullable per Component.resolve()'s own contract (gap #16 above). Same
class of mismatch as #16, found the same way: reading the new file against the interfaces it actually
calls, not just against its own Maven source. Fixed by marking the parameter @Nullable.
DefaultComponentRegistry.LocalConfiguration.fromParent's Class<C> and
TypeReference<C> overloads both declared their name parameter non-null,
but both are called from getOptionalComponent's own @Nullable String name
and forward it straight into Configuration.getOptionalComponent(..., @Nullable String).
Fixed both to @Nullable.
DefaultComponentRegistry.getModuleConfiguration's
Assert.nonEmpty(name, "The name must not be null.") only names the null case, but
nonEmpty also rejects an empty string — DecoratorDefinition.java's identical
call already said "must not be empty or null." Matched it.
BaseModule.componentRegistry's
requireNonNull(registryAction, "The registryAction must be null.") fires precisely when
the value is null, but the message claims the opposite requirement — a copy-paste error that
would mislead anyone reading the exception rather than the source. Fixed to "must not be null."
DefaultAxonApplication imported org.jspecify.annotations.NonNull, but
nothing in the file references it — only the unrelated Objects.requireNonNull static
import shares the substring, which is likely how it went unnoticed. Removed.
AxonConfigurationImpl.getParent() genuinely returns null for the root
configuration, but wasn't declared @Nullable — while Configuration.getParent()
itself is, and DefaultComponentRegistry's own LocalConfiguration.getParent()
already implements it correctly. Matched the interface's real contract.
DefaultAxonApplication.registerLifecycleHandler's "configuration already initialized"
check threw IllegalArgumentException, but nothing about an argument is wrong here — it's
a timing/state violation. BaseModule's identical "already been built" check correctly
uses IllegalStateException. Matched.
CollectionUtils.intersect's javadoc claims matched items are returned "in the order as
found in collection2," but the implementation seeds a lookup set from collection2 and
then iterates collection1, appending matches in that order — confirmed
byte-identical to the real upstream method, so this is genuine upstream drift, not something this
fork introduced. Found during a deliberate retroactive DRY/SOLID re-audit of all 144 finished
common files, not a build failure. Corrected the javadoc to match the actual, correct
behavior rather than changing a working algorithm to match a wrong sentence.
Components.postProcessComponents's
requireNonNull(processor, "The component post processor must be null.") has the identical
backwards polarity as the BaseModule fix above — confirmed present in the real upstream
source too. Fixed to "must not be null."
FutureUtils.joinAndUnwrap(future, timeout) can return null by its own
javadoc, yet only the 1-arg overload that just delegates to it carried @Nullable —
confirmed present in the real upstream source too. Added it to the 2-arg overload as well.
IOUtils.closeQuietly and closeQuietlyIfCloseable both say in their own
javadoc that the argument "may be null, in which case nothing happens" — neither parameter carried
@Nullable. Added it to both.
MavenArtifactVersionResolver.get() returns null explicitly when the
metadata file is missing, and implicitly via Properties#getProperty when the
version key isn't present — confirmed present in the real upstream source. Declared
@Nullable.
JdbcUtils's 3-arg nextAndExtract/extract overloads call their
4-arg siblings with a literal null defaultValue, and the 4-arg bodies can
return that null straight through — neither the parameter nor the return type on the
4-arg overloads declared @Nullable, confirmed present in the real upstream source.
Fixed both.
Cache.get(K) is declared @Nullable — a cache miss is the normal, documented
case — but none of its four implementations (WeakReferenceCache, JCacheAdapter,
EhCacheAdapter, NoCache) repeated the annotation on their override, despite
every one of them genuinely returning null on a miss. Fixed all four to match the
interface they implement.
vulnerabilities parameter was passed in from its one call site but
never referenced anywhere in the method body — only upgrades was actually used, meaning
any vulnerability data reaching this method was silently dropped rather than recorded. Found while
converting the update module's first real source file, not assumed correct just because
it compiled. Removed the dead parameter and confirmed the vulnerability path is populated correctly
by its own dedicated parseVulnerability method instead.
detectAxonModules()'s outer loop iterated over every known group ID calling
getResources("META-INF/maven/") — a fixed string that doesn't depend on the group ID at
all, so the identical classpath scan ran once per group ID, duplicating every URL before any
filtering happened. Separately, the method that maps a scanned entry to an Artifact (a
record whose groupId/artifactId/version fields are all
non-nullable) built one straight from Properties.getProperty(key), which returns
null on a missing key — silently constructing an invalid record instead of being caught
and skipped like every other malformed-input path in the same file. Pulled the scan outside the loop
entirely, and added a validation check that throws rather than building the record with a null field.
public static but not final, despite the class's own javadoc
describing it as "implemented as a singleton" — any external caller could reassign the field and
break that contract outright, not just a theoretical risk given the field's visibility. Added
final.
getDisabled()'s javadoc documents a null return, but the interface's own
signature was bare Boolean — and CommandLineUsagePropertyProvider,
EnvironmentVariableUsagePropertyProvider, and PropertyFileUsagePropertyProvider
all repeated the identical gap on their own getDisabled()/getUrl()
overrides. EnvironmentVariableUsagePropertyProvider had it a third time on its own
nested EnvironmentVariableSupplier.get() functional interface, and
PropertyFileUsagePropertyProvider had it twice more on fields that stay
null if the home directory can't be resolved or its property file fails to load. Same
class of gap as the four Cache implementations above (gap #31), just spread across a
different module's interface and its implementations instead of one interface's four adapters. Fixed
all of them.
null otherwise,
but carried no @Nullable under this package's own @NullMarked declaration —
confirmed present in the real upstream package-info.java too. Added it to both the field
and getFailureCause()'s return type.
volatile — a genuine visibility gap under the JVM memory model (low-severity here since
the detection logic it guards is idempotent and side-effect-free, but the same class of bug static
analyzers flag as an unsafe lazy-init pattern). The same field was also declared non-null while being
initialized to null. Fixed both, and — since the IDE's null-flow analysis doesn't narrow
fields across statements the way it does local variables, because a field could change from another
thread between a check and its use — refactored the accessor to read the volatile field into a local
variable once rather than re-reading it two or three times.
null, contradicting the class's own documented contract.
Fixed by falling back to a fresh UUID in the catch block if the field was never set.
.GET() — the first-request flag only ever sets an
X-First-Run header, never the HTTP method. Same class of gap as
CollectionUtils.intersect (gap #25): the GET-based implementation is clearly deliberate
and working, so the javadoc was corrected to describe reality rather than inventing a POST/PUT
implementation that was never actually written.
delayedTask was correctly made volatile during this file's original
conversion, with the reasoning for why its two siblings (firstRequest,
errorRetryBackoffFactor) didn't need it checked explicitly at the time rather than
applied blindly. That reasoning turned out wrong: a second, independent re-audit traced the actual
statement order and found both fields are written after the next virtual thread is already
started, with no happens-before guarantee the new thread would see them — confirmed identical in the
real upstream source, a pre-existing bug this fork's conversion didn't introduce. In the same file, a
log statement also passed the whole updateCheckResponse record where
.checkInterval() was clearly intended, since the very next line uses
.checkInterval() correctly. Fixed both, the same way gap #16 was found — on a second
pass, not the first.
common declares its JSR-305 dependency compileOnly, deliberately, so it
doesn't leak into either module's published artifact — but compileOnly is never
transitive, even through update's api(project(":common")). common's
compiled bytecode carries real @Nonnull/@Nullable annotations throughout,
so update's classpath was genuinely missing a type its own dependency's class files
reference. javac tolerates this; Eclipse's compiler doesn't. Confirmed this exact gap
exists in the real upstream Maven source too (common/pom.xml uses provided
scope, update/pom.xml never references it either) — update is simply the
first real consumer of common anywhere in this build. Fixed by adding the identical
compileOnly declaration to update directly — now a standing check for every
future module that depends on common.
null, and its body does return
null unconditionally, but the Void return type carried no
@Nullable — unlike the sibling joinAndUnwrap overloads (gap #27), which
already follow this convention. Found on a second, independent re-audit of already-converted
common files run alongside the update module's own re-audit, the same
technique that caught gap #16.
NoSuchAlgorithmException handler always reported "This environment doesn't support the
MD5 hashing algorithm" — even when called with a different algorithm entirely, since nothing outside
this fork ever exercises that path with anything but MD5. Fixed to report the actual algorithm that
was requested.
ComponentDescriptor implementations,
two near-identical private filter methods in Components, and JdbcSQLErrorCodesResolver
reimplementing a cause-chain walk ExceptionUtils already provides) and a batch of pure
formatting drift (eight IntelliJ-only //noinspection comments invisible to this project's
actual Eclipse-based tooling, sixteen doubled blank lines, one file still using tab indentation). All fixed
the same session, all verified the same way — but none of them change behavior, so none of them are counted
as a "gap," matching how the ComponentRegistry javadoc typo above wasn't either.
update also turned up a redundant double-call fixed twice (once in this module, once already
fixed in common), a renamed ALL_CAPS field that should never have been a
constant-style name, and several formatting/modifier-order nits — none of which change behavior, so none
are counted. A later, second comprehensive drift sweep of the whole fork also found two more fields that
compile-time analysis showed were safe to make final (ClassUtils's two static
fields, MachineId's single-assignment field) and one more redundant double-call
(ExceptionUtils.isExplicitlyNonTransient's duplicated getCause()) — same
reasoning, same exclusion.
gradlew,
gradlew.bat, a checksum-verified gradle-wrapper.jar) is committed as of
2026-08-25 — blocked until then by gap #04 above, which is why it took this long. A real gap caught before
committing it, not after: gradlew was staged non-executable (git file mode 100644),
which would have failed every Linux CI checkout with Permission denied. See
todo.md for the full story.
todo.md, README, the wiki, the diagrams, this page, the project board, the GitHub profile, and the landing page — checked, not assumed in sync
A direct review caught something a self-driven sweep hadn't: "Axon Framework 5" was hyperlinked to this
fork's own repository in three separate places — README.md, todo.md, and the
wiki's home page — which read as a project linking to itself under someone else's name. Fixed consistently
across all three the same session it was flagged, not left for the next person to notice.
todo.md gets updated at each real phase boundary — a module converted, a plugin written, a
bug fixed — rather than batched at the end of a session where drift is easy to lose track of.
Not every inconsistency gets self-caught before it ships. When one doesn't, the fix is disclosed the same way the original gaps are — including the fact that it took a second pair of eyes, not just the corrected result.
Where the real trade-offs got made, and written down
Not every decision has an obviously-correct answer, and pretending otherwise would just move the judgment
call somewhere less visible. This fork targets a Java 25 toolchain where upstream floors at 21 — a
deliberate choice that measurably raises the minimum JDK needed to consume this fork's published artifacts,
not a default left unexamined. Central Portal publishing (Sonatype's Maven Central replacement) has no
official Gradle plugin at all, so the community one used here (com.gradleup.nmcp) was verified
against its live documentation rather than assumed from training knowledge — which is exactly how gap #02
above got found in the first place. Both trade-offs are recorded in
todo.md
as deliberate choices, not oversights discovered later.