원클릭으로
command-query
Create a new CQRS command or query with handler. Use when adding new business operations (state changes) or data retrieval logic.
Codex 또는 Claude로 설치 이 Prompt를 복사해 Codex, Claude 또는 다른 어시스턴트에 붙여 넣으면 Skill 페이지를 검토하고 설치를 진행할 수 있습니다.
메뉴
Create a new CQRS command or query with handler. Use when adding new business operations (state changes) or data retrieval logic.
Codex 또는 Claude로 설치 이 Prompt를 복사해 Codex, Claude 또는 다른 어시스턴트에 붙여 넣으면 Skill 페이지를 검토하고 설치를 진행할 수 있습니다.
SOC 직업 분류 기준
Create a new HTMX form page with Thymeleaf template, UI handler, routes, and search. Use when adding a new create/edit form, list page, or CRUD flow for a domain entity.
Release a Helm chart (epistola or epistola-grafana) to a new version. Use when the user wants to release, publish, or cut a new Helm chart version.
Create a new release. Use when the user wants to release a new version, cut a release, or publish a version.
Review a branch/PR against epistola's architecture conventions and recorded preferences. Use when reviewing code changes; complements /code-review (which finds correctness bugs).
Create a Playwright UI test for browser-based testing. Use when testing HTMX interactions, form submissions, page navigation, or JavaScript behavior in the browser.
Make changes to REST API contracts and update the implementing controllers. Use when adding, modifying, or removing API endpoints, DTOs, or when the external epistola-contract needs updating.
| name | command-query |
| description | Create a new CQRS command or query with handler. Use when adding new business operations (state changes) or data retrieval logic. |
Create a new CQRS command or query in the epistola-core module.
Input: The operation name, which domain it belongs to, and what it does.
Ask the user (if not already specified):
inTransaction) or is a single statement enough?All commands live in modules/epistola-core/src/main/kotlin/app/epistola/suite/<domain>/commands/.
Queries live in .../queries/. Sub-packages are used for deeper nesting (e.g., commands/variants/, queries/versions/).
Reference: DeleteEnvironment.kt — modules/epistola-core/.../environments/commands/DeleteEnvironment.kt
data class CreateEntity(
val id: EntityId,
val tenantId: TenantId,
val name: String,
) : Command<Entity> {
init {
validate("name", name.isNotBlank()) { "Name is required" }
validate("name", name.length <= 100) { "Name must be 100 characters or less" }
}
}
@Component
class CreateEntityHandler(private val jdbi: Jdbi) : CommandHandler<CreateEntity, Entity> {
override fun handle(command: CreateEntity): Entity =
executeOrThrowDuplicate("entity", command.id.value) {
jdbi.withHandle<Entity, Exception> { handle ->
handle.createQuery("""
INSERT INTO entities (id, tenant_key, name, created_at)
VALUES (:id, :tenantId, :name, NOW())
RETURNING *
""")
.bind("id", command.id)
.bind("tenantId", command.tenantId)
.bind("name", command.name)
.mapTo<Entity>()
.one()
}
}
}
executeOrThrowDuplicate: Wraps INSERT statements only. Catches unique constraint violations and throws DuplicateIdException. Do NOT use for UPDATE/DELETE.
Reference: CreateDocumentTemplate.kt — modules/epistola-core/.../templates/commands/CreateDocumentTemplate.kt
Use jdbi.inTransaction when the command performs multiple database operations that must succeed or fail together:
override fun handle(command: CreateDocumentTemplate): DocumentTemplate =
executeOrThrowDuplicate("template", command.id.value) {
jdbi.inTransaction<DocumentTemplate, Exception> { handle ->
// 1. Insert the template
val template = handle.createQuery("INSERT INTO ... RETURNING *")...
// 2. Create default variant
handle.createUpdate("INSERT INTO ...").execute()
// 3. Create draft version
handle.createUpdate("INSERT INTO ...").execute()
template
}
}
Command<R?>)Reference: UpdateEnvironment.kt — modules/epistola-core/.../environments/commands/UpdateEnvironment.kt
Return nullable when the entity might not exist:
data class UpdateEntity(...) : Command<Entity?>
override fun handle(command: UpdateEntity): Entity? =
jdbi.withHandle<Entity?, Exception> { handle ->
handle.createQuery("UPDATE ... RETURNING *")
...
.mapTo<Entity>()
.findOne()
.orElse(null)
}
Reference: DeleteEnvironment.kt — modules/epistola-core/.../environments/commands/DeleteEnvironment.kt
data class DeleteEntity(...) : Command<Boolean>
override fun handle(command: DeleteEntity): Boolean =
jdbi.withHandle<Boolean, Exception> { handle ->
val rowsAffected = handle.createUpdate("DELETE FROM ... WHERE ...")
.bind(...)
.execute()
rowsAffected > 0
}
Reference: PublishToEnvironment.kt — modules/epistola-core/.../templates/commands/versions/PublishToEnvironment.kt
When a command returns multiple related objects:
data class PublishResult(
val version: TemplateVersion,
val activation: EnvironmentActivation,
val newDraft: TemplateVersion? = null,
)
data class PublishToEnvironment(...) : Command<PublishResult?>
data class ListEntities(
val tenantId: TenantId,
val searchTerm: String? = null,
) : Query<List<Entity>>
data class GetEntity(
val tenantId: TenantId,
val id: EntityId,
) : Query<Entity?>
There are 4 validation approaches, used in different places:
validate() in init block — Input validation (field format, required fields). Throws ValidationException.
init {
validate("name", name.isNotBlank()) { "Name is required" }
}
require() in handler — Precondition checks (domain invariants).
override fun handle(command: Cmd): Result {
require(command.versionId.value > 0) { "Invalid version" }
}
Handler body validation — Business rule checks that need DB lookups.
val existing = handle.createQuery("SELECT ...").findOne().orElse(null)
?: throw SomeDomainException("Not found")
Cross-cutting — executeOrThrowDuplicate for INSERT uniqueness.
Models live in modules/epistola-core/src/main/kotlin/app/epistola/suite/<domain>/. A model/ subdirectory is used for larger domains (e.g., templates/model/).
data class Entity(
val id: EntityId,
val tenantId: TenantId,
val name: String,
val createdAt: Instant,
)
JDBI maps database columns to Kotlin data class properties automatically via mapTo<>(). Column tenant_key maps to tenantId (snake_case → camelCase).
Create in apps/epistola/src/main/resources/db/migration/V<next>__<description>.sql:
CREATE TABLE entity_name (
id entity_slug PRIMARY KEY,
tenant_key tenant_slug NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
COMMENT ON TABLE entity_name IS 'Description of what this table stores';
COMMENT ON COLUMN entity_name.id IS 'Unique slug identifier';
COMMENT ON COLUMN entity_name.tenant_key IS 'Owning tenant';
Important: COMMENT ON must be separate statements (not inside CREATE TABLE). Use domain types from existing migrations (e.g., tenant_slug, template_slug, environment_slug).
When a command handler needs another service (not just JDBI):
@Component
class ComplexCommandHandler(
private val jdbi: Jdbi,
private val someService: SomeService,
) : CommandHandler<ComplexCommand, Result> { ... }
Commands and queries are dispatched via extension functions:
CreateSomething(id = ..., tenantId = ...).execute() // Command
ListSomething(tenantId = tenantId).query() // Query
GetSomething(tenantId = tenantId, id = id).query() // Query (nullable)
These require MediatorContext to be bound (automatic in handlers, explicit in tests via withMediator { }).
modules/epistola-core/.../unit-test skill)./gradlew ktlintFormat./gradlew integrationTestorg.jdbi.v3.core.kotlin.mapTo for the reified mapTo<>() extensioncreateQuery (not createUpdate) for statements with RETURNINGexecuteOrThrowDuplicate is for INSERTs only — don't wrap UPDATE/DELETEvalidate() is from app.epistola.suite.validation.validatetools.jackson.*) is used, not com.fasterxml.jackson