Hexagonal architecture with Spring Boot and Kotlin — a certificate service built from swappable modules
Ports and adapters in Kotlin — a Spring Boot certificate issuance engine where the core is framework-free and every adapter, and every certificate type, is a swappable module.
Spring Boot makes it easy to start a service and easy to regret it a year later. Controllers grow business rules, JPA entities quietly become the domain model, and the one use case that actually matters cannot run without a database and two HTTP mocks. This article builds a government certificate issuance engine in Kotlin using hexagonal architecture — ports and adapters — where every adapter, and every certificate type, lives in its own Gradle module that you can swap without touching the core.
You should be comfortable with Spring Boot and Gradle; no prior exposure to ports and adapters is assumed. By the end you should be able to answer two questions for any new concern — does this belong in the domain, a port, or an adapter? and which module do I swap to change this behavior? Examples target Spring Boot 3.5.x, Kotlin 2.1.x, and JVM 21 with Gradle Kotlin DSL; treat versions as representative rather than prescriptive. Every code block in this article belongs to the same multi-module build — it compiles, the use-case test passes, and the architecture rule runs green. Infrastructure details like retries, security, and migrations are noted where they matter rather than built out in full.
What the engine has to survive
The running example is an income certificate — one of the most-issued documents on Indian service-delivery platforms like e-District. A citizen (or an assisted-service operator) applies, the engine checks the application against the rules for that certificate type, and records a decision that can be explained months later. Behind that one flow sits a list of pressures that punish naive layering:
- Multiple entry points. A citizen-facing REST API; applications arriving from Common Service Centres over a message queue; a scheduled job that escalates applications breaching their delivery deadline.
- Legally binding timelines. Right to Service legislation in several states mandates delivery within a fixed number of days, so SLA tracking is a domain concern, not a cron afterthought.
- Several external systems. A citizen registry for demographics, a relational database for applications, and an event broker for downstream notifications.
- Many certificate types. Income, domicile, caste, birth — each with its own rules, and new ones arrive as policy, not as rewrites.
- Audit obligations. “Why was this application rejected, under which rules, at what time?” is a product requirement — the answer may be shown to the applicant.
A conventional Spring Boot service drifts into a dependency chain like this:
Controller -> Service -> JPA Repository -> PostgreSQL -> HTTP Client -> Citizen RegistryThe acceptance decision is now entangled with controllers, entities, REST clients, and framework annotations. Swapping the registry client for a stub during local development, or adding a second certificate type, means editing code that was supposed to be stable. Testing the decision means Spring context startup and a database for what is, at heart, a rule evaluation over a citizen’s attributes.
Hexagonal architecture reverses the important dependency direction: adapters depend on the core; the core describes the capabilities it needs and knows nothing about the technologies that provide them.
The idea behind ports and adapters
Alistair Cockburn introduced the pattern in a 2005 technical report — his own name for it is ports and adapters — with a practical goal: an application that can work without either a UI or a database, drivable equally by a human user, an automated regression suite, a batch script, or another program, and testable without its eventual runtime infrastructure. “Hexagonal” is just the shape he drew, and he is explicit that the number six means nothing — the hexagon simply gives the application room for as many ports as it needs, unconstrained by a one-dimensional layer stack.
The drawing is deliberately asymmetric, and this is the part most summaries skip. On one side sit driving actors — the user, the test harness, the scheduler, the upstream producer — anything that starts the conversation. On the other side sit driven actors — the database, the registry, the broker — anything the application calls. Ports face both directions: inbound ports declare what the application offers its drivers; outbound ports declare what it needs from the driven side. You will also meet Cockburn’s vocabulary — primary and secondary actors — and the driving/driven pair; they map to inbound and outbound respectively.
The edges labeled “adapter” are literal: an adapter is the piece that converts a driver’s protocol into an inbound-port call, or an outbound-port call into a driven technology’s protocol. Notice the test harness on the driving side — Cockburn’s testability goal means a test is just another adapter, which is why the test strategy later in this article falls out of the design rather than fighting it.
The mechanism that makes the driven side replaceable is dependency inversion applied at the module boundary. The outbound port is an interface owned by the core — written in the use case’s vocabulary — and the adapter implements it. Both sides point inward:
That is the entire trick. The persistence adapter depends on a core interface, so PostgreSQL is a detail of the adapter module; the core could run against a stub or a different engine without recompilation. Contrast with conventional layering, where Service → Repository → PostgreSQL points every dependency at the database — the port flips the arrow by making the consumer own the contract.
The same dependency rule appears under other names — Jeffrey Palermo’s Onion Architecture and Robert C. Martin’s Clean Architecture share the “dependencies point inward” core with different ring vocabularies — so if you have met one, you have met the idea. What the pattern does not prescribe matters as much: nothing about modules, bounded contexts, or deployment topology. The Gradle layout in this article is one way to enforce the rule at compile time; the rule itself is the only non-negotiable part.
Ports and adapters, concretely
Applied to this engine, the four roles look like this:
- The domain models business meaning and invariants — applications, certificate types, citizen facts, decisions, reasons. It imports no Spring MVC, JPA, Kafka, or database classes.
- An inbound port is a use case the system offers:
SubmitCertificateApplication,IssueCertificate,GetApplicationStatus. A REST controller and a Kafka consumer can invoke the same one. - An outbound port is a capability the use case needs from outside:
LoadCitizenProfilePort,SaveCertificateApplicationPort,CertificateEventPublisherPort. The name describes a capability, never a technology —PostgresApplicationRepositoryis an adapter, not a port. - An adapter translates between a technology and a port: a Spring MVC controller inbound, a JPA persistence adapter or WebClient-based registry client outbound.
Follow the flow from the portals inward: callers reach the core only through inbound ports, and the core reaches the world only through outbound ports. The hexagon itself is a metaphor — nothing requires six sides. What matters is that the core can be driven by any adapter and can obtain any capability through a replaceable adapter.
Carving the build into modules
You can practice ports and adapters in a single package, but the discipline lasts longer when the build file enforces it — and module boundaries are what make adapters physically swappable. A Gradle multi-project build gives each boundary a home:
certificate-engine/├── domain/├── application/├── policy-income-certificate/├── adapter-in-rest/├── adapter-in-messaging/├── adapter-in-batch/├── adapter-out-persistence/├── adapter-out-registry/├── adapter-out-registry-stub/├── adapter-out-events/├── bootstrap/├── build.gradle.kts└── settings.gradle.ktsThe module names carry the role, not the language — everything here is Kotlin. Two module families deserve a second look: policy-income-certificate is a functionality module — adding a certificate type means adding a module, not editing the core — and adapter-out-registry-stub is the swap partner for adapter-out-registry, standing in for the citizen registry during local development.
rootProject.name = "certificate-engine"
include( "domain", "application", "policy-income-certificate", "adapter-in-rest", "adapter-in-messaging", "adapter-in-batch", "adapter-out-persistence", "adapter-out-registry", "adapter-out-registry-stub", "adapter-out-events", "bootstrap",)The dependency rule is the entire architecture in one line: dependencies point toward the core, and the core never depends on an adapter.
Read the arrows as “is depended upon by”: adapter-out-persistence depends on application so it can implement the outbound ports, but application has no compile-time knowledge that PostgreSQL or JPA exists. Gradle makes this literal — the module simply has no dependency that could supply a JPA import. adapter-out-registry and adapter-out-registry-stub implement the same outbound port, which is what makes them interchangeable.
The root build file declares plugin versions once; each subproject applies what it needs. There is no Spring dependency-management plugin: modules import the Spring Boot BOM through Gradle’s native platform(), which keeps one mechanism consistent across the build:
plugins { kotlin("jvm") version "2.1.21" apply false kotlin("plugin.spring") version "2.1.21" apply false kotlin("plugin.jpa") version "2.1.21" apply false id("org.springframework.boot") version "3.5.0" apply false}
allprojects { group = "in.o612.eng.blogs" version = "0.1.0-SNAPSHOT" repositories { mavenCentral() }}Two module build files show the pattern everything else follows. The application module has a single dependency — the domain — and its only framework is the JDK:
plugins { kotlin("jvm")}
kotlin { jvmToolchain(21)}
dependencies { api(project(":domain"))
testImplementation(platform("org.springframework.boot:spring-boot-dependencies:3.5.0")) testImplementation("org.junit.jupiter:junit-jupiter") testRuntimeOnly("org.junit.platform:junit-platform-launcher")}
tasks.test { useJUnitPlatform()}An outbound adapter applies the Kotlin Spring and JPA plugins, depends on application for the ports it implements, and pulls in its own infrastructure stack:
plugins { kotlin("jvm") kotlin("plugin.spring") kotlin("plugin.jpa")}
kotlin { jvmToolchain(21)}
dependencies { implementation(project(":application")) implementation(platform("org.springframework.boot:spring-boot-dependencies:3.5.0")) implementation("org.springframework.boot:spring-boot-starter-data-jpa") runtimeOnly("org.postgresql:postgresql")}The kotlin("plugin.jpa") plugin generates the no-arg constructor JPA requires for entities, and kotlin("plugin.spring") opens classes that Spring proxies — both confined to the adapter module where they belong. The remaining adapters follow the same shape: depend on application, add the stack they translate.
The domain module knows nothing about Spring
domain holds the business model. Data classes and a value class carry most of it:
package `in`.o612.eng.blogs.certificates.domain
@JvmInlinevalue class ServiceCode(val value: String) { init { require(value.isNotBlank()) { "Service code must not be blank" } }}package `in`.o612.eng.blogs.certificates.domain
data class CitizenProfile( val citizenId: String, val resident: Boolean, val residenceYears: Int, val ageYears: Int, val annualHouseholdIncome: Long,)package `in`.o612.eng.blogs.certificates.domain
import java.time.Instantimport java.util.UUID
data class CertificateApplication( val applicationId: UUID, val serviceCode: ServiceCode, val citizenId: String, val status: ApplicationStatus, val reasons: List<String>, val policyVersion: String, val appliedAt: Instant,)ServiceCode is an inline value class — it validates at construction and costs no allocation at runtime. ApplicationStatus is an enum of ACCEPTED, REJECTED, and MANUAL_REVIEW; the third value exists because “the registry disagrees with the application” must never silently become REJECTED. Two plain exceptions round out the vocabulary:
package `in`.o612.eng.blogs.certificates.domain
class CitizenNotFoundException(citizenId: String) : RuntimeException("Citizen not found: $citizenId")
class UnknownServiceException(serviceCode: ServiceCode) : RuntimeException("Unknown service: ${serviceCode.value}")One package detail is worth knowing because it appears in every Kotlin file in this project: in is a Kotlin keyword, so the in.o612.eng.blogs package escapes it with backticks in package and import statements. The compiled package name is still in.o612.eng.blogs.
CertificateApplication carries policyVersion: a decision you cannot trace back to the rules that produced it is not auditable, so the version is part of the model, not metadata added later.
Two remaining domain types define the seams everything else plugs into. ApplicationFacts gathers the evidence a policy evaluates — the citizen’s registry profile, what they declared, and whether a duplicate application is already in progress:
package `in`.o612.eng.blogs.certificates.domain
data class ApplicationFacts( val citizen: CitizenProfile, val declaredAnnualIncome: Long, val pendingApplicationExists: Boolean,)And ServicePolicy is the contract each certificate-type module implements — including slaDays, because the legal delivery window differs per service:
package `in`.o612.eng.blogs.certificates.domain
interface ServicePolicy { val serviceCode: ServiceCode val policyVersion: String val slaDays: Int
fun evaluate(facts: ApplicationFacts): PolicyOutcome}
data class PolicyOutcome( val status: ApplicationStatus, val reasons: List<String>,)The engine also emits domain events. A sealed interface keeps the set closed and lets adapters and consumers handle each event type exhaustively:
package `in`.o612.eng.blogs.certificates.domain
sealed interface CertificateEvent { val application: CertificateApplication}
data class ApplicationDecided( override val application: CertificateApplication,) : CertificateEvent
data class ApplicationEscalated( override val application: CertificateApplication,) : CertificateEventServicePolicy sits in the domain because it is business vocabulary: “a certificate service can evaluate an application and explain itself.” The concrete rules live in certificate-type modules that plug into this contract — the engine works with every ServicePolicy on the classpath and knows none of them by name.
Ports describe capabilities, not technologies
application defines the use cases and the capabilities they need. The inbound port is what any caller — the citizen API, the CSC channel, a future staff portal — invokes:
package `in`.o612.eng.blogs.certificates.application.port.inbound
import `in`.o612.eng.blogs.certificates.domain.CertificateApplicationimport `in`.o612.eng.blogs.certificates.domain.ServiceCode
interface SubmitCertificateApplication { fun submit(command: SubmitApplicationCommand): CertificateApplication
data class SubmitApplicationCommand( val serviceCode: ServiceCode, val citizenId: String, val declaredAnnualIncome: Long, )}The command is a domain-typed input, not a request DTO — declaredAnnualIncome is the applicant’s own declaration, which the policy will compare against registry data. The outbound ports are capabilities named after what the use case needs, declared as fun interface so tests and stubs can satisfy them with a lambda:
package `in`.o612.eng.blogs.certificates.application.port.outbound
import `in`.o612.eng.blogs.certificates.domain.CitizenProfile
fun interface LoadCitizenProfilePort { fun loadByCitizenId(citizenId: String): CitizenProfile}package `in`.o612.eng.blogs.certificates.application.port.outbound
import `in`.o612.eng.blogs.certificates.domain.CertificateApplication
fun interface SaveCertificateApplicationPort { fun save(application: CertificateApplication)}package `in`.o612.eng.blogs.certificates.application.port.outbound
import `in`.o612.eng.blogs.certificates.domain.ServiceCode
fun interface ExistsPendingApplicationPort { fun existsPendingFor(serviceCode: ServiceCode, citizenId: String): Boolean}package `in`.o612.eng.blogs.certificates.application.port.outbound
import `in`.o612.eng.blogs.certificates.domain.CertificateEvent
fun interface CertificateEventPublisherPort { fun publish(event: CertificateEvent)}Nothing in these signatures says PostgreSQL, REST, or Kafka. That is the point — and also the naming test: if a port’s name contains a technology, it is an adapter wearing the wrong clothes.
The use case orchestrates; the domain decides
The application service is the seam between the two. It gathers the facts through ports, hands them to whichever policy module owns the service, and records the outcome — again through ports:
package `in`.o612.eng.blogs.certificates.application
import `in`.o612.eng.blogs.certificates.application.port.inbound.SubmitCertificateApplicationimport `in`.o612.eng.blogs.certificates.application.port.inbound.SubmitCertificateApplication.SubmitApplicationCommandimport `in`.o612.eng.blogs.certificates.application.port.outbound.CertificateEventPublisherPortimport `in`.o612.eng.blogs.certificates.application.port.outbound.ExistsPendingApplicationPortimport `in`.o612.eng.blogs.certificates.application.port.outbound.LoadCitizenProfilePortimport `in`.o612.eng.blogs.certificates.application.port.outbound.SaveCertificateApplicationPortimport `in`.o612.eng.blogs.certificates.domain.ApplicationDecidedimport `in`.o612.eng.blogs.certificates.domain.ApplicationFactsimport `in`.o612.eng.blogs.certificates.domain.CertificateApplicationimport `in`.o612.eng.blogs.certificates.domain.ServiceCodeimport `in`.o612.eng.blogs.certificates.domain.ServicePolicyimport `in`.o612.eng.blogs.certificates.domain.UnknownServiceExceptionimport java.time.Clockimport java.time.Instantimport java.util.UUID
class CertificateApplicationService( private val citizenProfiles: LoadCitizenProfilePort, private val applications: SaveCertificateApplicationPort, private val pendingApplications: ExistsPendingApplicationPort, private val events: CertificateEventPublisherPort, policies: List<ServicePolicy>, private val clock: Clock,) : SubmitCertificateApplication {
private val policiesByService: Map<ServiceCode, ServicePolicy> = policies.associateBy { it.serviceCode }
override fun submit(command: SubmitApplicationCommand): CertificateApplication { val policy = policiesByService[command.serviceCode] ?: throw UnknownServiceException(command.serviceCode) val citizen = citizenProfiles.loadByCitizenId(command.citizenId) val pending = pendingApplications.existsPendingFor(command.serviceCode, command.citizenId) val outcome = policy.evaluate( ApplicationFacts(citizen, command.declaredAnnualIncome, pending), )
val application = CertificateApplication( applicationId = UUID.randomUUID(), serviceCode = command.serviceCode, citizenId = command.citizenId, status = outcome.status, reasons = outcome.reasons, policyVersion = policy.policyVersion, appliedAt = Instant.now(clock), ) applications.save(application) events.publish(ApplicationDecided(application)) return application }}Read submit top to bottom and you have the whole use case: resolve the service’s policy, load the citizen’s profile, check for a duplicate pending application, evaluate the facts, stamp the decision with the policy version and an injected Clock, persist it, announce it. There is no @Service annotation — the class does not need Spring’s permission to exist.
The List<ServicePolicy> constructor parameter is where the plugin mechanism lives. Spring injects every registered ServicePolicy bean, and the service indexes them by service code. Add a module containing a new ServicePolicy bean and the use case serves a new certificate type — CertificateApplicationService never learns that anything changed.
A functionality module: one certificate type, one module
policy-income-certificate is the first service implementation. The policy is annotation-free Kotlin, and it distinguishes two kinds of failure: hard rejections (non-resident, insufficient residence history, underage, duplicate application) and a reviewable discrepancy — declared income that disagrees with the registry gets MANUAL_REVIEW, because an honest applicant may have had an income change the registry has not caught up with:
package `in`.o612.eng.blogs.certificates.policy.income
import `in`.o612.eng.blogs.certificates.domain.ApplicationFactsimport `in`.o612.eng.blogs.certificates.domain.ApplicationStatusimport `in`.o612.eng.blogs.certificates.domain.PolicyOutcomeimport `in`.o612.eng.blogs.certificates.domain.ServiceCodeimport `in`.o612.eng.blogs.certificates.domain.ServicePolicyimport kotlin.math.abs
class IncomeCertificatePolicy : ServicePolicy {
override val serviceCode = ServiceCode("income-certificate") override val policyVersion = "income-certificate/2026.1" override val slaDays = 15
override fun evaluate(facts: ApplicationFacts): PolicyOutcome { val rejections = buildList { if (!facts.citizen.resident) { add("Applicant is not a resident of the issuing jurisdiction") } if (facts.citizen.residenceYears < MIN_RESIDENCE_YEARS) { add("Minimum $MIN_RESIDENCE_YEARS years of continuous residence required") } if (facts.citizen.ageYears < MIN_AGE_YEARS) { add("Applicant must be at least $MIN_AGE_YEARS years old") } if (facts.pendingApplicationExists) { add("An application for this service is already in progress") } } if (rejections.isNotEmpty()) { return PolicyOutcome(ApplicationStatus.REJECTED, rejections) }
val incomeMismatch = abs(facts.declaredAnnualIncome - facts.citizen.annualHouseholdIncome) > INCOME_TOLERANCE return if (incomeMismatch) { PolicyOutcome( ApplicationStatus.MANUAL_REVIEW, listOf("Declared income does not match registry records"), ) } else { PolicyOutcome(ApplicationStatus.ACCEPTED, emptyList()) } }
companion object { const val MIN_RESIDENCE_YEARS = 3 const val MIN_AGE_YEARS = 18 const val INCOME_TOLERANCE = 10_000L }}Returning every violated reason rather than the first is deliberate — a rejection the citizen cannot understand is a grievance waiting to be filed. The module contributes its bean through a small configuration class; because its package sits under in.o612.eng.blogs.certificates, the bootstrap component scan picks it up as soon as the module is on the classpath:
package `in`.o612.eng.blogs.certificates.policy.income
import `in`.o612.eng.blogs.certificates.domain.ServicePolicyimport org.springframework.context.annotation.Beanimport org.springframework.context.annotation.Configuration
@Configurationclass IncomeCertificateModule {
@Bean fun incomeCertificatePolicy(): ServicePolicy = IncomeCertificatePolicy()}plugins { kotlin("jvm")}
kotlin { jvmToolchain(21)}
dependencies { implementation(project(":domain")) implementation(platform("org.springframework.boot:spring-boot-dependencies:3.5.0")) implementation("org.springframework:spring-context")}This is “swap functionality by module” in its concrete form: a second service — a domicile certificate, a caste certificate — is a new directory implementing ServicePolicy, one line in settings.gradle.kts, one line in the bootstrap dependencies. The domain, the use case, and every adapter stay untouched.
Production note — Thresholds like
MIN_RESIDENCE_YEARSare constants here for readability; a real platform versions them externally or loads them per policy version. The same applies to money: aLongof whole rupees keeps the example readable, but aMoneyvalue type with currency and scale belongs in the domain before amounts start flowing between systems.
Adapters translate; they do not decide
With the core and its policy seam in place, each adapter answers one narrow question: how do I convert my protocol into a port call, or a port call into my protocol?
The REST adapter is a translator
package `in`.o612.eng.blogs.certificates.rest
import `in`.o612.eng.blogs.certificates.application.port.inbound.SubmitCertificateApplicationimport `in`.o612.eng.blogs.certificates.application.port.inbound.SubmitCertificateApplication.SubmitApplicationCommandimport `in`.o612.eng.blogs.certificates.domain.CertificateApplicationimport `in`.o612.eng.blogs.certificates.domain.ServiceCodeimport jakarta.validation.Validimport jakarta.validation.constraints.NotBlankimport jakarta.validation.constraints.PositiveOrZeroimport org.springframework.http.HttpStatusimport org.springframework.web.bind.annotation.PathVariableimport org.springframework.web.bind.annotation.PostMappingimport org.springframework.web.bind.annotation.RequestBodyimport org.springframework.web.bind.annotation.RequestMappingimport org.springframework.web.bind.annotation.ResponseStatusimport org.springframework.web.bind.annotation.RestController
@RestController@RequestMapping("/api/v1/services")class CertificateApplicationController( private val submitApplication: SubmitCertificateApplication,) {
@PostMapping("/{serviceCode}/applications") @ResponseStatus(HttpStatus.CREATED) fun submit( @PathVariable serviceCode: String, @Valid @RequestBody request: SubmitApplicationRequest, ): CertificateApplicationResponse { val command = SubmitApplicationCommand( ServiceCode(serviceCode), request.citizenId, request.declaredAnnualIncome, ) return CertificateApplicationResponse.from(submitApplication.submit(command)) }}
data class SubmitApplicationRequest( @field:NotBlank val citizenId: String, @field:PositiveOrZero val declaredAnnualIncome: Long,)
data class CertificateApplicationResponse( val applicationId: String, val serviceCode: String, val citizenId: String, val status: String, val reasons: List<String>, val policyVersion: String, val appliedAt: String,) { companion object { fun from(application: CertificateApplication) = CertificateApplicationResponse( applicationId = application.applicationId.toString(), serviceCode = application.serviceCode.value, citizenId = application.citizenId, status = application.status.name, reasons = application.reasons, policyVersion = application.policyVersion, appliedAt = application.appliedAt.toString(), ) }}The controller does exactly three things: accept HTTP-shaped input, build a use-case command, and map the result back to HTTP. It contains no acceptance logic, which is what makes it safe to review quickly and cheap to change. @field:NotBlank and @field:PositiveOrZero require spring-boot-starter-validation in this module’s dependencies — they turn a missing citizenId or a negative income into a 400 rather than a registry lookup for garbage.
Domain errors get translated at the same boundary:
package `in`.o612.eng.blogs.certificates.rest
import `in`.o612.eng.blogs.certificates.domain.CitizenNotFoundExceptionimport `in`.o612.eng.blogs.certificates.domain.UnknownServiceExceptionimport org.springframework.http.HttpStatusimport org.springframework.web.bind.annotation.ExceptionHandlerimport org.springframework.web.bind.annotation.ResponseStatusimport org.springframework.web.bind.annotation.RestControllerAdvice
@RestControllerAdviceclass ApiExceptionHandler {
@ExceptionHandler(CitizenNotFoundException::class) @ResponseStatus(HttpStatus.NOT_FOUND) fun citizenNotFound(ex: CitizenNotFoundException) = mapOf("error" to ex.message)
@ExceptionHandler(UnknownServiceException::class) @ResponseStatus(HttpStatus.NOT_FOUND) fun unknownService(ex: UnknownServiceException) = mapOf("error" to ex.message)}The domain throws plain exceptions with no HTTP semantics; the adapter owns the status codes. When a second transport arrives, the exceptions keep their meaning and the new adapter picks its own mapping.
A second inbound adapter proves the design
Common Service Centre operators submit applications on behalf of citizens — on a real platform this traffic arrives over a queue, not the public API. The consumer calls the same inbound port:
package `in`.o612.eng.blogs.certificates.messaging
import `in`.o612.eng.blogs.certificates.application.port.inbound.SubmitCertificateApplicationimport `in`.o612.eng.blogs.certificates.application.port.inbound.SubmitCertificateApplication.SubmitApplicationCommandimport `in`.o612.eng.blogs.certificates.domain.ServiceCodeimport org.springframework.kafka.annotation.KafkaListenerimport org.springframework.stereotype.Component
@Componentclass CscApplicationConsumer( private val submitApplication: SubmitCertificateApplication,) {
@KafkaListener(topics = ["csc.certificate-applications"]) fun onCscApplication(message: CscApplicationMessage) { submitApplication.submit( SubmitApplicationCommand( ServiceCode(message.serviceCode), message.citizenId, message.declaredAnnualIncome, ), ) }}
data class CscApplicationMessage( val serviceCode: String, val citizenId: String, val declaredAnnualIncome: Long,)No acceptance logic was copied to get a second entry point — the consumer, the controller, and a future staff-portal adapter all converge on SubmitCertificateApplication. That is the entire sales pitch for inbound ports in one @KafkaListener method. (Message deserialization is configured in application.yaml; note the spring.json.trusted.packages setting, which restricts which classes the JSON deserializer may instantiate — keep it scoped to the messaging package.)
The batch adapter composes outbound ports directly
Not every adapter must funnel through an inbound port. Right to Service timelines create a different kind of job: scan for applications still pending past their service’s slaDays and escalate them. The job composes outbound ports — it reports on state rather than applying for anything — and because it iterates every registered ServicePolicy, a newly added certificate module gets SLA tracking for free:
package `in`.o612.eng.blogs.certificates.application.port.outbound
import `in`.o612.eng.blogs.certificates.domain.CertificateApplicationimport `in`.o612.eng.blogs.certificates.domain.ServiceCodeimport java.time.Instant
fun interface LoadOverdueApplicationsPort { fun pendingOlderThan(serviceCode: ServiceCode, cutoff: Instant): List<CertificateApplication>}package `in`.o612.eng.blogs.certificates.batch
import `in`.o612.eng.blogs.certificates.application.port.outbound.CertificateEventPublisherPortimport `in`.o612.eng.blogs.certificates.application.port.outbound.LoadOverdueApplicationsPortimport `in`.o612.eng.blogs.certificates.domain.ApplicationEscalatedimport `in`.o612.eng.blogs.certificates.domain.ServicePolicyimport org.springframework.scheduling.annotation.Scheduledimport org.springframework.stereotype.Componentimport java.time.Clockimport java.time.Durationimport java.time.Instant
@Componentclass SlaEscalationJob( private val overdueApplications: LoadOverdueApplicationsPort, private val policies: List<ServicePolicy>, private val events: CertificateEventPublisherPort, private val clock: Clock,) {
@Scheduled(cron = "0 0 6 * * *") fun escalateOverdueApplications() { for (policy in policies) { val cutoff = Instant.now(clock).minus(Duration.ofDays(policy.slaDays.toLong())) for (application in overdueApplications.pendingOlderThan(policy.serviceCode, cutoff)) { events.publish(ApplicationEscalated(application)) } } }}The registry adapter owns a protocol
package `in`.o612.eng.blogs.certificates.registry
import `in`.o612.eng.blogs.certificates.application.port.outbound.LoadCitizenProfilePortimport `in`.o612.eng.blogs.certificates.domain.CitizenNotFoundExceptionimport `in`.o612.eng.blogs.certificates.domain.CitizenProfileimport org.springframework.context.annotation.Profileimport org.springframework.stereotype.Componentimport org.springframework.web.reactive.function.client.WebClient
@Component@Profile("!local-dev")class CitizenRegistryAdapter( private val webClient: WebClient,) : LoadCitizenProfilePort {
override fun loadByCitizenId(citizenId: String): CitizenProfile { val response = webClient.get() .uri("/citizens/{id}", citizenId) .retrieve() .bodyToMono(CitizenRegistryResponse::class.java) .block() ?: throw CitizenNotFoundException(citizenId)
return CitizenProfile( citizenId = response.citizenId, resident = response.resident, residenceYears = response.residenceYears, ageYears = response.ageYears, annualHouseholdIncome = response.annualHouseholdIncome, ) }}
data class CitizenRegistryResponse( val citizenId: String, val resident: Boolean, val residenceYears: Int, val ageYears: Int, val annualHouseholdIncome: Long,)The registry’s response shape is a private DTO mapped into CitizenProfile at the boundary — the upstream schema can evolve without touching the domain. The WebClient bean lives in the same module, built from a WebClient.Builder and a base URL supplied through configuration:
package `in`.o612.eng.blogs.certificates.registry
import org.springframework.beans.factory.annotation.Valueimport org.springframework.context.annotation.Beanimport org.springframework.context.annotation.Configurationimport org.springframework.web.reactive.function.client.WebClient
@Configurationclass RegistryClientConfiguration {
@Bean fun citizenRegistryWebClient( builder: WebClient.Builder, @Value("\${citizen-registry.base-url}") baseUrl: String, ): WebClient = builder.baseUrl(baseUrl).build()}The @Profile("!local-dev") on the adapter is half of a swap mechanism — covered in “How modules actually get swapped”.
Production note —
.block()keeps the example linear, but a real adapter needs timeouts, retries with backoff, a circuit breaker, correlation IDs, and error translation that distinguishes “citizen does not exist” from “registry unreachable”. Those two outcomes must produce different results: the first can be a rejection input, the second is an operational failure that should surface asMANUAL_REVIEWor an error — never asREJECTED.
The persistence adapter keeps JPA outside the core
The storage model is a separate class on purpose. JPA annotations, table names, and column choices live on the entity; the domain data class stays clean:
package `in`.o612.eng.blogs.certificates.persistence
import `in`.o612.eng.blogs.certificates.domain.CertificateApplicationimport jakarta.persistence.CollectionTableimport jakarta.persistence.Columnimport jakarta.persistence.ElementCollectionimport jakarta.persistence.Entityimport jakarta.persistence.FetchTypeimport jakarta.persistence.Idimport jakarta.persistence.JoinColumnimport jakarta.persistence.Tableimport java.time.Instantimport java.util.UUID
@Entity@Table(name = "certificate_applications")class CertificateApplicationEntity( @Id var applicationId: UUID? = null, var serviceCode: String = "", var citizenId: String = "", var status: String = "", @ElementCollection(fetch = FetchType.EAGER) @CollectionTable( name = "certificate_application_reasons", joinColumns = [JoinColumn(name = "application_id")], ) @Column(name = "reason") var reasons: MutableList<String> = mutableListOf(), var policyVersion: String = "", var appliedAt: Instant = Instant.now(),) { companion object { fun fromDomain(application: CertificateApplication) = CertificateApplicationEntity( applicationId = application.applicationId, serviceCode = application.serviceCode.value, citizenId = application.citizenId, status = application.status.name, reasons = application.reasons.toMutableList(), policyVersion = application.policyVersion, appliedAt = application.appliedAt, ) }}package `in`.o612.eng.blogs.certificates.persistence
import org.springframework.data.jpa.repository.JpaRepositoryimport java.time.Instantimport java.util.UUID
interface SpringDataCertificateApplicationRepository : JpaRepository<CertificateApplicationEntity, UUID> {
fun existsByServiceCodeAndCitizenIdAndStatusIn( serviceCode: String, citizenId: String, statuses: Collection<String>, ): Boolean
fun findByServiceCodeAndStatusInAndAppliedAtBefore( serviceCode: String, statuses: Collection<String>, appliedAt: Instant, ): List<CertificateApplicationEntity>}The adapter then implements all three outbound ports that persistence serves — saving applications, detecting duplicates, and finding overdue ones:
package `in`.o612.eng.blogs.certificates.persistence
import `in`.o612.eng.blogs.certificates.application.port.outbound.ExistsPendingApplicationPortimport `in`.o612.eng.blogs.certificates.application.port.outbound.LoadOverdueApplicationsPortimport `in`.o612.eng.blogs.certificates.application.port.outbound.SaveCertificateApplicationPortimport `in`.o612.eng.blogs.certificates.domain.ApplicationStatusimport `in`.o612.eng.blogs.certificates.domain.CertificateApplicationimport `in`.o612.eng.blogs.certificates.domain.ServiceCodeimport org.springframework.stereotype.Componentimport java.time.Instant
@Componentclass CertificateApplicationPersistenceAdapter( private val repository: SpringDataCertificateApplicationRepository,) : SaveCertificateApplicationPort, ExistsPendingApplicationPort, LoadOverdueApplicationsPort {
override fun save(application: CertificateApplication) { repository.save(CertificateApplicationEntity.fromDomain(application)) }
override fun existsPendingFor(serviceCode: ServiceCode, citizenId: String) = repository.existsByServiceCodeAndCitizenIdAndStatusIn( serviceCode.value, citizenId, PENDING_STATUSES, )
override fun pendingOlderThan(serviceCode: ServiceCode, cutoff: Instant) = repository.findByServiceCodeAndStatusInAndAppliedAtBefore( serviceCode.value, PENDING_STATUSES, cutoff, ).map { it.toDomain() }
companion object { private val PENDING_STATUSES = listOf(ApplicationStatus.ACCEPTED.name, ApplicationStatus.MANUAL_REVIEW.name) }}
private fun CertificateApplicationEntity.toDomain() = CertificateApplication( applicationId = applicationId!!, serviceCode = ServiceCode(serviceCode), citizenId = citizenId, status = ApplicationStatus.valueOf(status), reasons = reasons.toList(), policyVersion = policyVersion, appliedAt = appliedAt,)One adapter implementing three ports is normal — ports are capabilities, and one technology can provide several. “Pending” means ACCEPTED or MANUAL_REVIEW: applications still moving toward issuance, which is exactly the set a duplicate check and an SLA scan should both see.
The events adapter announces outcomes
package `in`.o612.eng.blogs.certificates.events
import `in`.o612.eng.blogs.certificates.application.port.outbound.CertificateEventPublisherPortimport `in`.o612.eng.blogs.certificates.domain.ApplicationDecidedimport `in`.o612.eng.blogs.certificates.domain.ApplicationEscalatedimport `in`.o612.eng.blogs.certificates.domain.CertificateApplicationimport `in`.o612.eng.blogs.certificates.domain.CertificateEventimport org.springframework.kafka.core.KafkaTemplateimport org.springframework.stereotype.Component
@Componentclass KafkaCertificateEventPublisher( private val kafka: KafkaTemplate<String, ApplicationEventMessage>,) : CertificateEventPublisherPort {
override fun publish(event: CertificateEvent) { val type = when (event) { is ApplicationDecided -> "application.decided" is ApplicationEscalated -> "application.escalated" } kafka.send(TOPIC, event.application.applicationId.toString(), ApplicationEventMessage.from(type, event.application)) }
companion object { const val TOPIC = "certificate.application-events" }}
data class ApplicationEventMessage( val type: String, val applicationId: String, val serviceCode: String, val citizenId: String, val status: String, val policyVersion: String, val appliedAt: String,) { companion object { fun from(type: String, application: CertificateApplication) = ApplicationEventMessage( type = type, applicationId = application.applicationId.toString(), serviceCode = application.serviceCode.value, citizenId = application.citizenId, status = application.status.name, policyVersion = application.policyVersion, appliedAt = application.appliedAt.toString(), ) }}The when over the sealed CertificateEvent is exhaustive — add a third event to the domain and every when without an else fails to compile, which is the compiler doing boundary enforcement for you. ApplicationEventMessage is a data class local to this module: the event contract, like the HTTP and JPA shapes, is an adapter concern. Consumers downstream never see the domain type.
How modules actually get swapped
“Swappable” is a claim; here are the two mechanisms that make it true.
Classpath swap. The bootstrap module is the only place that knows which implementations exist, so exchanging a Gradle dependency there exchanges the adapter. In bootstrap/build.gradle.kts, swapping the registry for its stub is a one-line change — no recompilation of anything that depends on the port, because nothing outside the adapter module references it:
plugins { kotlin("jvm") kotlin("plugin.spring") id("org.springframework.boot")}
kotlin { jvmToolchain(21)}
dependencies { implementation(project(":application"))
// Functionality modules: add or remove a certificate service by adding or removing a module. implementation(project(":policy-income-certificate"))
// Inbound adapters. implementation(project(":adapter-in-rest")) implementation(project(":adapter-in-messaging")) implementation(project(":adapter-in-batch"))
// Outbound adapters: swap by exchanging the module dependency. implementation(project(":adapter-out-persistence")) implementation(project(":adapter-out-events")) implementation(project(":adapter-out-registry")) // implementation(project(":adapter-out-registry-stub"))
implementation(platform("org.springframework.boot:spring-boot-dependencies:3.5.0")) implementation("org.springframework.boot:spring-boot-starter")
testImplementation("com.tngtech.archunit:archunit-junit5:1.4.1") testImplementation("org.junit.jupiter:junit-jupiter") testRuntimeOnly("org.junit.platform:junit-platform-launcher")}
tasks.test { useJUnitPlatform()}Profile swap. When both modules stay on the classpath, Spring profiles choose at startup. The real adapter is marked @Profile("!local-dev"); the stub module — a single fun interface lambda bean — activates only under local-dev:
package `in`.o612.eng.blogs.certificates.registrystub
import `in`.o612.eng.blogs.certificates.application.port.outbound.LoadCitizenProfilePortimport `in`.o612.eng.blogs.certificates.domain.CitizenProfileimport org.springframework.context.annotation.Beanimport org.springframework.context.annotation.Configurationimport org.springframework.context.annotation.Profile
@Configuration@Profile("local-dev")class StubCitizenRegistryModule {
@Bean fun stubCitizenRegistry() = LoadCitizenProfilePort { citizenId -> CitizenProfile( citizenId = citizenId, resident = true, residenceYears = 6, ageYears = 34, annualHouseholdIncome = 120_000, ) }}With spring.profiles.active=local-dev, the engine runs without the citizen registry — useful for development, demos, and adapter tests that need the real module’s exact seam. @ConditionalOnProperty is the alternative when the choice belongs in configuration rather than profiles.
Whichever mechanism you use, three rules keep swaps safe:
- The port is the only contract. Nothing outside an adapter module may import its types — not for convenience, not “just this once.” The moment another module names
CitizenRegistryAdapter, the swap is broken. - Adapters never depend on each other. The persistence adapter must not call the registry adapter; shared needs go through ports or the domain.
- Every port needs exactly one bean per runtime. Two active implementations of
LoadCitizenProfilePortproduce an ambiguous-bean failure at startup — the@Profileguards exist to make the choice explicit, and the failure is loud rather than silently wrong.
The bootstrap module is the only place Spring assembles
bootstrap is the composition root: the application class, bean wiring, security, observability, and environment-specific configuration. It is the only module that sees the core, the policies, and every adapter:
package `in`.o612.eng.blogs.certificates
import org.springframework.boot.autoconfigure.SpringBootApplicationimport org.springframework.boot.runApplicationimport org.springframework.scheduling.annotation.EnableScheduling
@SpringBootApplication@EnableSchedulingclass CertificateEngineApplication
fun main(args: Array<String>) { runApplication<CertificateEngineApplication>(*args)}package `in`.o612.eng.blogs.certificates
import `in`.o612.eng.blogs.certificates.application.CertificateApplicationServiceimport `in`.o612.eng.blogs.certificates.application.port.outbound.CertificateEventPublisherPortimport `in`.o612.eng.blogs.certificates.application.port.outbound.ExistsPendingApplicationPortimport `in`.o612.eng.blogs.certificates.application.port.outbound.LoadCitizenProfilePortimport `in`.o612.eng.blogs.certificates.application.port.outbound.SaveCertificateApplicationPortimport `in`.o612.eng.blogs.certificates.domain.ServicePolicyimport org.springframework.context.annotation.Beanimport org.springframework.context.annotation.Configurationimport java.time.Clock
@Configurationclass BootstrapConfiguration {
@Bean fun certificateApplicationService( citizenProfiles: LoadCitizenProfilePort, applications: SaveCertificateApplicationPort, pendingApplications: ExistsPendingApplicationPort, events: CertificateEventPublisherPort, policies: List<ServicePolicy>, ) = CertificateApplicationService( citizenProfiles, applications, pendingApplications, events, policies, Clock.systemUTC(), )}The @Component and @Configuration adapters satisfy the port parameters by type; the List<ServicePolicy> collects every certificate module’s bean; the only bean defined by hand is the use case itself, which deliberately has no Spring annotation. Configuration keys — datasource URL, Kafka bootstrap servers, citizen-registry.base-url — arrive through application.yaml backed by environment variables, so a swapped adapter changes wiring, not code.
Following one application through the system
Every arrow crosses a boundary exactly once, and every boundary crossing goes through a port or a mapping function. If you can trace a new feature through this diagram without inventing a shortcut, the architecture is holding.
Testing each boundary at the speed it deserves
The architecture’s payoff is that each layer gets the fastest test that can falsify it:
Domain and policy tests exercise IncomeCertificatePolicy directly: residence just under the three-year minimum, a duplicate pending application, an income mismatch inside and outside the tolerance. No framework, no setup — construct ApplicationFacts and assert on the PolicyOutcome.
Use-case tests substitute lambdas for the outbound ports — the fun interface declarations are what make this a one-liner per port — and supply their own ServicePolicy, because application must not depend on a concrete certificate module. The whole orchestration is verifiable in plain JUnit:
package `in`.o612.eng.blogs.certificates.application
import `in`.o612.eng.blogs.certificates.application.port.inbound.SubmitCertificateApplication.SubmitApplicationCommandimport `in`.o612.eng.blogs.certificates.application.port.outbound.CertificateEventPublisherPortimport `in`.o612.eng.blogs.certificates.application.port.outbound.ExistsPendingApplicationPortimport `in`.o612.eng.blogs.certificates.application.port.outbound.LoadCitizenProfilePortimport `in`.o612.eng.blogs.certificates.application.port.outbound.SaveCertificateApplicationPortimport `in`.o612.eng.blogs.certificates.domain.ApplicationDecidedimport `in`.o612.eng.blogs.certificates.domain.ApplicationFactsimport `in`.o612.eng.blogs.certificates.domain.ApplicationStatusimport `in`.o612.eng.blogs.certificates.domain.CertificateApplicationimport `in`.o612.eng.blogs.certificates.domain.CertificateEventimport `in`.o612.eng.blogs.certificates.domain.CitizenProfileimport `in`.o612.eng.blogs.certificates.domain.PolicyOutcomeimport `in`.o612.eng.blogs.certificates.domain.ServiceCodeimport `in`.o612.eng.blogs.certificates.domain.ServicePolicyimport org.junit.jupiter.api.Assertions.assertEqualsimport org.junit.jupiter.api.Testimport java.time.Clockimport java.time.Instantimport java.time.ZoneOffset
class CertificateApplicationServiceTest {
private val testPolicy = object : ServicePolicy { override val serviceCode = ServiceCode("income-certificate") override val policyVersion = "income-certificate/test" override val slaDays = 15
override fun evaluate(facts: ApplicationFacts) = if (facts.citizen.resident && !facts.pendingApplicationExists) { PolicyOutcome(ApplicationStatus.ACCEPTED, emptyList()) } else { PolicyOutcome(ApplicationStatus.REJECTED, listOf("test rejection")) } }
@Test fun `saves auditable application when citizen qualifies`() { val saved = mutableListOf<CertificateApplication>() val published = mutableListOf<CertificateEvent>() val citizens = LoadCitizenProfilePort { CitizenProfile(it, resident = true, residenceYears = 6, ageYears = 34, annualHouseholdIncome = 180_000) }
val service = CertificateApplicationService( citizens, SaveCertificateApplicationPort { saved.add(it) }, ExistsPendingApplicationPort { _, _ -> false }, CertificateEventPublisherPort { published.add(it) }, listOf(testPolicy), Clock.fixed(Instant.parse("2026-09-24T00:00:00Z"), ZoneOffset.UTC), )
val application = service.submit( SubmitApplicationCommand(ServiceCode("income-certificate"), "C-1001", 180_000), )
assertEquals(ApplicationStatus.ACCEPTED, application.status) assertEquals("income-certificate/test", application.policyVersion) assertEquals(Instant.parse("2026-09-24T00:00:00Z"), application.appliedAt) assertEquals(1, saved.size) assertEquals(application, (published.single() as ApplicationDecided).application) }}This test starts no Spring context and opens no database connection — it runs at plain unit-test speed. That is not a style preference; it is what makes rule changes cheap to verify when a threshold moves the week before a deadline.
Adapter tests own their technology’s failure modes:
- REST: request validation, the 404 mappings for
CitizenNotFoundExceptionandUnknownServiceException, and response field mapping via@WebMvcTestorMockMvc. - Messaging: malformed payloads, deserialization boundaries, and redelivery behavior against an embedded broker or a mock consumer test.
- Persistence: entity mapping and the derived pending/overdue queries against real PostgreSQL through Testcontainers — an in-memory database will not catch dialect problems.
- Registry: timeout behavior, error translation, and upstream-schema tolerance against a mock HTTP server such as WireMock or MockWebServer.
- Events: topic, key, and payload shape for both
CertificateEventtypes, plus what a failedsenddoes to the use case — a questionKafkaCertificateEventPublisherleaves open deliberately.
Architecture tests turn the dependency rule into a build failure. ArchUnit lives in the bootstrap module, the one place whose test classpath sees every module:
package `in`.o612.eng.blogs.certificates
import com.tngtech.archunit.junit.AnalyzeClassesimport com.tngtech.archunit.junit.ArchTestimport com.tngtech.archunit.lang.ArchRuleimport com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses
@AnalyzeClasses(packages = ["in.o612.eng.blogs.certificates"])class ArchitectureTest {
@ArchTest val `core is framework free`: ArchRule = noClasses() .that().resideInAnyPackage("..domain..", "..application..") .should().dependOnClassesThat() .resideInAnyPackage("org.springframework..", "jakarta.persistence..", "org.apache.kafka..")}The first time someone imports EntityManager in the domain module — under deadline, with the best intentions — this test is the code review that never sleeps. A worthwhile second rule asserts that adapter packages never depend on each other.
What a production deployment adds
The example deliberately stops at the architecture. A real certificate service owes more:
- Issuance, not just acceptance. The flow shown decides whether an application proceeds; a production system adds officer approval, digital signing, secure storage, and delivery of the certificate itself.
- Explainability. Persist the input facts used, not only the outcome — when an applicant disputes a rejection, “the registry recorded X on this date” is the answer they are owed.
- Policy versioning. Decisions must link reproducibly to a policy version; the
policyVersionfield is the hook, and a versioned rules store is the next step when policy authors outnumber developers. - Privacy. Store the minimum personal data the decision requires; rejection reasons in API responses should name rules, not leak registry attributes.
- Idempotency. Retried submissions — from the portal or the CSC queue — must not create duplicate applications; the
pendingApplicationExistscheck is the seam, backed by a unique constraint on(serviceCode, citizenId)for pending statuses. - Human review.
MANUAL_REVIEWis a first-class outcome for discrepancies like the income mismatch, not an exception handler — and it needs a worklist UI in front of it. - Resilience. Registry outages are operational events, distinct from ineligibility — conflating them denies citizens services and falsifies your metrics at the same time.
- Rule complexity. Start with domain policy objects. Reach for a decision table or a DMN (Decision Model and Notation) engine only when policy change frequency justifies the machinery, not before.
Mistakes this design prevents — and the ones it does not
The module boundaries make some classic failures structurally impossible — a service cannot call a JPA repository it cannot see — but most hexagonal-codebase damage is self-inflicted and legal:
- Naming ports after infrastructure (
KafkaPublisher,JpaApplicationRepository), which smuggles the technology into the core’s vocabulary. - Making REST DTOs or JPA entities double as the domain model, which couples your business vocabulary to the first transport you happened to build.
- Referencing an adapter class by name from another module, which quietly welds the “swappable” module in place.
- Creating an interface for every class rather than at real boundaries — ports are for capabilities the use case consumes, not for ceremony.
- Adding Spring annotations to the core “just for convenience,” which is how the dependency arrow quietly reverses.
- Splitting every package into its own Gradle module; modules should protect meaningful boundaries, not multiply build files.
- Treating a failed registry call as a rejection.
- Persisting decisions without reasons, timestamps, or policy version — an audit trail that cannot explain itself is storage, not evidence.
When the structure earns its keep
Hexagonal architecture costs real things: an interface per capability, a mapping function per boundary, a composition root, a module count that can look like ceremony — eleven modules for one use case is a lot if nothing ever swaps — and a team that has to maintain the discipline on the hundredth change, not just the first. For a small CRUD service with one entry point and one database, a clean layered service is the honest choice.
It pays when several of these are true at once: the business rules carry weight, integrations change on someone else’s schedule, more than one entry point exists or is coming, functionality arrives as discrete units (a new certificate type, a new upstream registry), decisions must be explained later, and tests need to run without infrastructure. The certificate engine hits all six — service-delivery platforms are precisely the systems where channels, certificate types, and backend registries keep multiplying — and why the test suite, not the diagram, is the real deliverable.
Check the boundaries before calling it done
- Can
IncomeCertificatePolicyrun without Spring Boot or PostgreSQL on the classpath? - Does
CertificateApplicationServiceimport only domain types and ports? - Can a new inbound adapter call
SubmitCertificateApplicationwithout copying a single rule? - Can the registry adapter be swapped for the stub — by dependency or by profile — without touching the core or another adapter?
- Can a second certificate type ship as a new module implementing
ServicePolicy, with no changes inapplication? - Does every persisted application carry reasons, a timestamp, and the policy version?
- Does an ArchUnit rule fail the build when the dependency direction is violated?
If any answer is no, the problem is in the boundary, not the pattern.
Where to take it next
Two natural extensions exercise the seams this article built. IssueCertificate is a second inbound port: an officer approves a MANUAL_REVIEW application, the engine signs and stores the certificate, and publishes CertificateIssued. And a second service — a domicile certificate with different residence rules — is a new policy-* module that should require no changes anywhere except settings.gradle.kts and the bootstrap dependencies. If either forces you to edit the core, that is the signal the boundary needs attention.