From welld-dev
Kotlin and Spring Boot development. Trigger this skill for ANY task involving Kotlin, Spring Boot, JVM, Maven, jOOQ, Liquibase, database migrations, REST APIs, or backend services. Covers welld-style best practices including functional error handling with Result/DomainError, Spring Modulith module boundaries, repository patterns, and Spring configuration. Use for writing code, reviewing code, setting up new services, defining migrations, configuring dependencies, scaffolding modules, or debugging Spring applications. Always trigger for partial tasks like "add a repository", "create a migration", "set up a new module", "add an endpoint", or "fix a Spring Boot issue".
How this skill is triggered — by the user, by Claude, or both
Slash command
/welld-dev:kotlin-springboot-welldThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Opinionated guide for welld Spring Boot / Kotlin projects.
Opinionated guide for welld Spring Boot / Kotlin projects.
For deep reference on a specific area, read the matching file in references/:
| Topic | File |
|---|---|
| Maven POM, plugins & dependencies | references/maven-setup.md |
| jOOQ patterns & repository style | references/jooq-patterns.md |
| Liquibase migrations (dual-DBMS) | references/liquibase-migrations.md |
| Spring Modulith module structure | references/modulith-structure.md |
| Error handling (Result / DomainError) | references/error-handling.md |
spring-boot-starter-jooq.Result<T>, chain with flatMap/map, never use imperative try-catch or if-else for control flow.KotlinLogging.logger {}) declared above the class, not inside it.Read references/maven-setup.md for the complete POM skeleton including:
jooq-codegen-maven plugin wired to Liquibase + H2kotlin-maven-plugin, liquibase-maven-plugin, spring-boot-maven-pluginKey versions to align:
<properties>
<kotlin.version>2.1.20</kotlin.version>
<jooq.version>3.19.18</jooq.version>
<liquibase.version>4.29.2</liquibase.version>
<java.version>21</java.version>
</properties>
Read references/modulith-structure.md for full package layout and package-info.java rules.
Each module is split into three sub-packages by layer:
src/main/kotlin/ch/welld/<service>/
├── Application.kt ← @SpringBootApplication
├── common/ ← @ApplicationModule(open = true) via package-info.java
│ ├── model/Result.kt
│ ├── model/DomainError.kt
│ └── config/...
└── <module>/ ← @ApplicationModule via package-info.java
├── api/ ← PUBLIC: named interface — only package other modules import
│ ├── ModuleMetadata.kt ← @PackageInfo @NamedInterface("api") — always required
│ ├── <Module>Api.kt
│ └── dto/
├── dal/ ← PRIVATE: jOOQ repositories only
│ └── <Module>Repository.kt
├── services/ ← PRIVATE: business logic
│ └── <Module>Service.kt
├── presentation/ ← PRIVATE: REST controllers
│ └── <Module>Controller.kt
└── <Module>Configuration.kt
Package responsibilities:
| Sub-package | Contains | Access |
|---|---|---|
api/ | Interfaces + DTOs crossing module boundaries | Public — other modules may depend on this |
dal/ | jOOQ @Repository classes, DSLContext usage | Private — never imported outside this module |
services/ | @Service business logic, Result<T> operations | Private — never imported outside this module |
presentation/ | @RestController, request/response models | Private — never imported outside this module |
Rules — enforced, no exceptions:
api/. If it is not in api/, it is private to this module and may never be imported elsewhere.api/ package MUST contain ModuleMetadata.kt annotated with @PackageInfo and @NamedInterface("api"). Without it, Spring Modulith does not recognise api/ as a named interface and the ModularityTest will not enforce its boundary.api/ package — never through dal/, services/, or presentation/.api/ contains only interfaces and their DTOs. No concrete classes, no @Service, no @Repository, no @RestController.DSLContext table access.common module is open to all; everything else is encapsulated.When creating a new module, always generate these files in this order:
src/main/java/.../module/package-info.java — @ApplicationModulesrc/main/kotlin/.../module/api/ModuleMetadata.kt — @PackageInfo @NamedInterface("api")src/main/kotlin/.../module/api/<Module>Api.kt — the interface(s)src/main/kotlin/.../module/api/dto/ — DTOs used by the interfacesrc/main/kotlin/.../module/services/<Module>Service.kt — implementation of the interfacesrc/main/kotlin/.../module/dal/<Module>Repository.kt — jOOQ accesssrc/main/kotlin/.../module/presentation/<Module>Controller.kt — HTTP endpointssrc/main/kotlin/.../module/<Module>Configuration.kt — Spring @ConfigurationRead references/jooq-patterns.md and references/liquibase-migrations.md.
Some SQL is Postgres-specific (e.g., TEXT, SERIAL, jsonb, ON CONFLICT).
When this happens, create two changesets:
db/changelog/
├── db.changelog-master.yaml
└── changes/
├── 001-create-users-postgres.sql ← dbms: postgresql
└── 001-create-users-h2.sql ← dbms: h2 (jOOQ codegen only)
Each changeset header must declare dbms:
--liquibase formatted sql
--changeset author:001-create-users dbms:postgresql
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
email TEXT NOT NULL UNIQUE
);
// <module>/dal/UserRepository.kt
private val logger = KotlinLogging.logger {}
@Repository
class UserRepository(private val dsl: DSLContext) {
fun findById(id: Long): Result<UserRecord?> =
Result.catching {
dsl.selectFrom(USERS)
.where(USERS.ID.eq(id))
.fetchOne()
}
fun save(record: UserRecord): Result<UserRecord> =
Result.catching {
dsl.insertInto(USERS)
.set(record)
.returning()
.fetchOne()!!
}
}
Read references/error-handling.md for full Result<T> and DomainError usage.
All business logic MUST use monadic composition with Result<T>. Never use imperative try-catch, if-else, or nullable chains. Extract reusable operations into pure functions that return Result<T>.
Pattern: Extract → Compose → Handle
// ✅ CORRECT — Pure functional style with Result composition
override fun doFilterInternal(
request: HttpServletRequest,
response: HttpServletResponse,
filterChain: FilterChain,
) {
extractToken(request)
.flatMap { token -> authenticateToken(token, request) }
.onSuccess { auth -> SecurityContextHolder.getContext().authentication = auth }
.onFailure { error -> logger.debug { "JWT validation failed for ${request.requestURI}: ${error.message}" } }
filterChain.doFilter(request, response)
}
private fun extractToken(request: HttpServletRequest): Result<String> =
Result
.success(Unit)
.flatMap {
request.cookies
?.firstOrNull { it.name == JWT_COOKIE_NAME }
?.value
.takeIf { SecurityContextHolder.getContext().authentication == null }
?.let { token -> Result.success(token) }
?: Result.failure(DomainError.ValidationError("No valid token found"))
}
private fun authenticateToken(
token: String,
request: HttpServletRequest,
): Result<UsernamePasswordAuthenticationToken> =
Result
.catching { jwtUtils.validateTokenAndGetEmail(token) }
.flatMap { email -> Result.catching { userDetailsService.loadUserByUsername(email) } }
.map { userDetails ->
UsernamePasswordAuthenticationToken(userDetails, null, userDetails.authorities).apply {
details = WebAuthenticationDetailsSource().buildDetails(request)
}
}
// ❌ WRONG — Imperative style with try-catch and if-else
override fun doFilterInternal(
request: HttpServletRequest,
response: HttpServletResponse,
filterChain: FilterChain,
) {
val token = request.cookies?.firstOrNull { it.name == JWT_COOKIE_NAME }?.value
if (token != null && SecurityContextHolder.getContext().authentication == null) {
try {
val email = jwtUtils.validateTokenAndGetEmail(token)
val userDetails = userDetailsService.loadUserByUsername(email)
val auth = UsernamePasswordAuthenticationToken(userDetails, null, userDetails.authorities)
auth.details = WebAuthenticationDetailsSource().buildDetails(request)
SecurityContextHolder.getContext().authentication = auth
} catch (ex: Exception) {
logger.debug { "JWT validation failed: ${ex.message}" }
}
}
filterChain.doFilter(request, response)
}
// services/ — always return Result
fun createUser(command: CreateUserCommand): Result<UserDto> =
validate(command)
.flatMap { repo.findByEmail(it.email) }
.flatMap { existing ->
if (existing != null)
Result.failure(DomainError.ValidationError("Email already in use"))
else
repo.save(it.toRecord())
}
.map { it.toDto() }
// presentation/ — fold into HTTP response
fun create(@RequestBody @Valid body: CreateUserRequest): ResponseEntity<*> =
service.createUser(body.toCommand()).fold(
onSuccess = { ResponseEntity.ok(it) },
onFailure = { err -> err.toResponse() }
)
Key principles:
Result<T> — never inline complex logicflatMap, map, onSuccess, onFailure — never use try-catch or if-else for control flowResult.catching { } to wrap throwable operations — never bare try-catchtakeIf / takeUnless for conditional logic — never use if statements for control flow or validation
// ✅ CORRECT
user.takeIf { it.isActive }
?.let { Result.success(it) }
?: Result.failure(DomainError.ValidationError("User is not active"))
// ❌ WRONG
if (user.isActive) {
Result.success(user)
} else {
Result.failure(DomainError.ValidationError("User is not active"))
}
Always declare the logger above the class declaration (not inside a companion object).
import io.github.oshai.kotlinlogging.KotlinLogging
private val logger = KotlinLogging.logger {}
@Service
class OrderService(...) {
fun process(id: Long): Result<OrderDto> {
logger.info { "Processing order $id" }
// ...
}
}
@DataJooqTest for repository tests in dal/@SpringBootTest
@Testcontainers
class UserRepositoryTest {
@Container
val postgres = PostgreSQLContainer("postgres:16")
@Test
fun `should return NotFoundError when user does not exist`() {
val result = repo.findById(-1L)
result.isSuccess shouldBe true
result.getOrNull() shouldBe null
}
}
Place compose.yaml in project root:
services:
postgres:
image: postgres:16
environment:
POSTGRES_DB: myservice
POSTGRES_USER: myservice
POSTGRES_PASSWORD: secret
ports:
- "5432:5432"
Spring Boot auto-starts Docker Compose on ./mvnw spring-boot:run in dev profile.
CRITICAL: Comments are documentation. Never delete existing comments. Always preserve and update them.
fun in api/ package) — explain purpose, parameters, return valuesResult chains — explain the transformation pipeline@Bean and its role/**
* Creates a new user account with the provided details.
*
* Validates email uniqueness before persisting. Returns ValidationError
* if email is already registered.
*
* @param command User creation details including email and profile
* @return Result containing UserDto on success, DomainError on failure
*/
fun createUser(command: CreateUserCommand): Result<UserDto>
// Extract token from cookie only if no authentication is already present
// to avoid re-processing on subsequent filter invocations
private fun extractToken(request: HttpServletRequest): Result<String> =
Result.success(Unit)
.flatMap {
request.cookies
?.firstOrNull { it.name == JWT_COOKIE_NAME }
?.value
.takeIf { SecurityContextHolder.getContext().authentication == null }
?.let { token -> Result.success(token) }
?: Result.failure(DomainError.ValidationError("No valid token found"))
}
// ✅ CORRECT — Comment updated to reflect new validation
/**
* Creates a new user account with the provided details.
*
* Validates email uniqueness and password strength before persisting.
* Returns ValidationError if email is already registered or password
* does not meet complexity requirements.
*
* @param command User creation details including email, password, and profile
* @return Result containing UserDto on success, DomainError on failure
*/
fun createUser(command: CreateUserCommand): Result<UserDto>
// ❌ WRONG — Comment removed entirely
fun createUser(command: CreateUserCommand): Result<UserDto>
Result chainsWhen modifying code:
val everywhere possible; var only when mutation is unavoidable.?) on domain model fields unless the absence has explicit business meaning.lateinit var — use constructor injection.companion object for logger — use top-level private val.when exhaustively on sealed classes (never else on Result or DomainError).takeIf / takeUnless / let / run for conditional expressions — if statements are forbidden for control flow and validation.npx claudepluginhub matteocodogno/wellforge --plugin welld-devCreates bite-sized, testable implementation plans from specs or requirements, with file structure and task decomposition. Activates before coding multi-step tasks.