Common Login Portal

Hexagonal architecture you can verify with grep

Ashish Bhagat · 13 Aug 2026

"Hexagonal architecture" shows up in a lot of README files that don't actually have one. The claim is cheap to make and usually impossible to check without reading the whole codebase. CLP's backend — about 383 Java files, roughly 21,000 lines, 19 controllers, 83 REST endpoints — makes the claim checkable in one command:

grep -rn "org.springframework\|javax.persistence\|jakarta.persistence" src/main/java/.../domain/
# (no matches)

If that command returns nothing, the domain package genuinely has zero dependency on Spring, JPA, or any framework. That's the actual test of whether hexagonal architecture is a real design decision or just a diagram somebody drew once and never enforced. It passes here, and it's worth being specific about what that buys you, because "the domain doesn't import Spring" sounds like a stylistic preference until you see what breaks when it isn't true.

What's actually in domain/

It's worth saying plainly why this matters more here than it might on a smaller project: CLP touches a government PKI identity provider, two hand-rolled SOAP clients, an encrypted payment gateway, and downstream applications with their own integration quirks — every one of those is a source of infrastructure-specific mess that has, at some point in this series, needed a workaround, a retry, a parser swap, or a rewritten redirect. A domain layer with zero framework dependency is what keeps that mess from spreading into the rules that actually define what a valid registration, a valid payment, or a valid user looks like. The messier the integrations get, the more that boundary is worth having.

The domain/ package holds pure domain logic: models, value objects, domain events, and the interfaces — the ports — that define what the domain needs from the outside world, like "a way to look up a user" or "a way to check labour status," without specifying how those needs get fulfilled. No Spring annotations, no JPA entities, no SOAP client details. A domain object that represents a user doesn't know or care whether that user came from SQL Server, a mock, or a test fixture. It just knows what a user is and what rules govern it.

Everything else is an adapter

None of this is free. Every port needs at least one real implementation, often two once mock adapters enter the picture, which is objectively more code than calling Spring Data or a SOAP client directly from wherever it's convenient. The payoff isn't measured on day one — it shows up every time an integration-specific fix, like a JDK certificate parser or a bank's redirect quirk, lands in exactly one adapter and never touches anything the domain layer depends on.

Every other package exists to plug something concrete into one of those ports. application/ holds the use cases — the commands and queries that orchestrate domain logic to actually do something. dataprovider/ is where JPA entities, Spring Data repositories, and the hand-rolled SOAP clients live — the concrete implementations of the domain's repository and API ports. web/ is the inbound adapter: controllers, DTOs, mappers, the JWT filter, the SAML handler — everything that turns an HTTP request into a call against the application layer, and everything specific to a JDK version's certificate-parsing quirks or a payment gateway's redirect behavior. factory/ builds domain objects; exception/ and common/ handle cross-cutting concerns without leaking into the domain itself.

The pattern only means something because of where things are not allowed to live. The BouncyCastle certificate parser from earlier in this series lives in web/, next to the SAML handler, because a JDK-version workaround is exactly the kind of infrastructure detail an adapter is supposed to absorb — not something the domain layer should ever need an opinion about.

factory/ is a small package that's easy to overlook and worth naming anyway, because it points at a real design decision: constructing a domain object correctly — generating an ID in the right format, assembling a new user with all its required invariants satisfied from the start — is its own responsibility, separate from the domain model just describing what a valid object looks like once it exists. Keeping construction logic in its own package, still with no framework dependency, means the rules for "how do you legally create one of these" don't get scattered across whichever command happens to need a new instance first.

CQRS, not just in one place

Inside application/, every feature follows the same paired shape: an XxxCommand and XxxCommandImpl for anything that writes, wrapped in a real transaction, and an XxxQuery and XxxQueryImpl for anything that reads, wrapped in a read-only transaction. This isn't CQRS as a one-off pattern applied to a single hot path — it's applied to every feature, consistently, which is what keeps read and write concerns from tangling together as the number of features grows into the dozens. A query never accidentally mutates state through a shared code path, because there is no shared code path between a command and a query for the same feature — they're separate classes from the start.

A state machine instead of a pile of flags

The clearest example of what living entirely inside domain/ buys you is the user lifecycle: PENDING_VERIFICATION → ACTIVE → LOCKED/INACTIVE, implemented with the State pattern rather than a status column and a set of scattered if checks. Each state validates its own legal transitions. Trying to activate an account that's already locked fails inside the domain layer itself — not because a controller happened to check for it, but because the domain object representing that account in a locked state simply doesn't expose an operation that lets you activate it incorrectly. Move that validation logic into a controller or a service class that also imports Spring, and it becomes one more place a future change can quietly skip the check. Keep it in a framework-free domain object, and skipping it isn't an option — there's nothing to skip around.

Why the grep test is the real one

Anyone can draw a hexagon in a design doc and label the middle "domain." What actually keeps a codebase honest about that claim, six months and dozens of features later, is whether a new controller can still reach into the domain and call something Spring-flavored without anyone noticing — and in a codebase where the domain package has zero framework imports by construction, that mistake doesn't compile, let alone get merged.

What I'd do differently

Right now, "the domain package has zero framework imports" is a fact I can prove with a grep command, not a rule the build enforces on its own. That's fine for a solo project where I'm the only one who might violate it, but it's not a durable guarantee — nothing stops a future import from slipping in during a rushed change. If I were setting this up again, I'd add an architecture test, something like ArchUnit's package-dependency rules, that fails the build the moment anything in domain/ imports Spring or JPA, instead of relying on a command I have to remember to run.


More from this series:

AVA not a sequence: a Java 17 SAML cert bug · Spring Data JPA's null-version trap