A pure Kotlin JVM library providing the foundational building blocks for implementing Explicit Architectureβa synthesis of Domain-Driven Design (DDD), Command/Query Separation (CQS), and Event-Driven Architecture (EDA).
This library serves as a shared kernel for large-scale projects, defining the interfaces and base classes for all core business concepts and architectural patterns. It enforces clean architecture principles while remaining completely framework-agnostic.
- π Pure Kotlin: No framework dependencies (Spring, Ktor, Micronaut, etc.)
- π Coroutine-Ready: All I/O operations use suspend functions
- π¦ Minimal Dependencies: Only Kotlin stdlib + kotlinx-coroutines-core
- π Comprehensive Documentation: Every component includes KDoc and examples
- ποΈ Framework-Agnostic: Works with any framework or pure Kotlin
- π¨ Clean Architecture: Enforces proper layer separation and dependencies
Since the library is not yet published to a public repository, you'll need to build and install it locally:
git clone https://github.com/melsardes/structus-kotlin.git
cd structus-kotlin
./gradlew build publishToMavenLocalThis will install the library to your local Maven repository (~/.m2/repository).
Gradle (Kotlin DSL)
repositories {
mavenLocal() // Add local Maven repository
mavenCentral()
}
dependencies {
implementation("com.melsardes.libraries:structus-kotlin:0.1.0")
}Gradle (Groovy)
repositories {
mavenLocal() // Add local Maven repository
mavenCentral()
}
dependencies {
implementation 'com.melsardes.libraries:structus-kotlin:0.1.0'
}Maven
<dependency>
<groupId>com.melsardes.libraries</groupId>
<artifactId>structus-kotlin</artifactId>
<version>0.1.0</version>
</dependency>Note: Maven automatically checks the local repository (
~/.m2/repository) before remote repositories.
// Entity: Identity-based domain objects
abstract class Entity<ID> {
abstract val id: ID
// equals/hashCode based on ID
}
// Value Object: Attribute-based immutable objects
interface ValueObject
// Aggregate Root: Consistency boundary with event management and lifecycle
abstract class AggregateRoot<ID> : Entity<ID>() {
val domainEvents: List<DomainEvent>
protected fun recordEvent(event: DomainEvent)
fun clearEvents()
// Lifecycle management
internal fun markAsCreated(by: String, at: kotlin.time.Instant = Clock.System.now())
internal fun markAsUpdated(by: String, at: kotlin.time.Instant = Clock.System.now())
fun softDelete(by: String, at: kotlin.time.Instant = Clock.System.now())
fun restore(by: String, at: kotlin.time.Instant = Clock.System.now())
fun isDeleted(): Boolean
fun isActive(): Boolean
internal fun incrementVersion()
}
// Repository: Persistence contract
interface Repository// Domain Event: Something that happened
interface DomainEvent {
val eventId: String
val occurredAt: kotlin.time.Instant // Uses Kotlin multiplatform time API
val aggregateId: String
}
// Transactional Outbox Pattern
interface MessageOutboxRepository : Repository {
suspend fun save(event: DomainEvent)
suspend fun findUnpublished(limit: Int): List<OutboxMessage>
suspend fun markAsPublished(messageId: String)
suspend fun incrementRetryCount(messageId: String)
}// Command: Intent to change state
interface Command
// Command Handler: Executes business logic (uses invoke operator)
interface CommandHandler<in C : Command, out R> {
suspend operator fun invoke(command: C): R
}
// Command Bus: Dispatches commands to handlers
interface CommandBus {
fun <C : Command, R> register(commandClass: KClass<C>, handler: CommandHandler<C, R>)
suspend fun <C : Command, R> dispatch(command: C): R
}// Query: Request for data
interface Query
// Query Handler: Retrieves data (uses invoke operator)
interface QueryHandler<in Q : Query, out R> {
suspend operator fun invoke(query: Q): R
}// Domain Event Publisher: Publishes events to external systems
interface DomainEventPublisher {
suspend fun publish(event: DomainEvent)
suspend fun publishBatch(events: List<DomainEvent>)
}// Value Object
data class Email(val value: String) : ValueObject {
init {
require(value.matches(EMAIL_REGEX)) { "Invalid email format" }
}
companion object {
private val EMAIL_REGEX = "^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+$".toRegex()
}
}
// Entity ID
data class UserId(val value: String) : ValueObject
// Aggregate Root
class User(
override val id: UserId,
var email: Email,
var name: String,
var status: UserStatus
) : AggregateRoot<UserId>() {
fun register(email: Email, name: String) {
this.email = email
this.name = name
this.status = UserStatus.ACTIVE
recordEvent(UserRegisteredEvent(
aggregateId = id.value,
userId = id.value,
email = email.value,
registeredAt = kotlin.time.Clock.System.now()
))
}
companion object {
fun create(email: Email, name: String): User {
val user = User(
id = UserId(UUID.randomUUID().toString()),
email = email,
name = name,
status = UserStatus.PENDING
)
user.register(email, name)
return user
}
}
}
// Domain Event
data class UserRegisteredEvent(
override val eventId: String = UUID.randomUUID().toString(),
override val occurredAt: kotlin.time.Instant = kotlin.time.Clock.System.now(),
override val aggregateId: String,
val userId: String,
val email: String,
val registeredAt: kotlin.time.Instant
) : DomainEvent
// Repository Interface
interface UserRepository : Repository {
suspend fun findById(id: UserId): User?
suspend fun findByEmail(email: Email): User?
suspend fun save(user: User)
suspend fun existsByEmail(email: Email): Boolean
}// Command
data class RegisterUserCommand(
val email: String,
val name: String
) : Command {
init {
require(email.isNotBlank()) { "Email cannot be blank" }
require(name.isNotBlank()) { "Name cannot be blank" }
}
}
// Command Handler
class RegisterUserCommandHandler(
private val userRepository: UserRepository,
private val outboxRepository: MessageOutboxRepository
) : CommandHandler<RegisterUserCommand, Result<UserId>> {
override suspend operator fun invoke(command: RegisterUserCommand): Result<UserId> {
return runCatching {
// Check if email already exists
if (userRepository.existsByEmail(Email(command.email))) {
throw IllegalStateException("Email already exists")
}
// Create user
val user = User.create(
email = Email(command.email),
name = command.name
)
// Save user
userRepository.save(user)
// Save events to outbox (Transactional Outbox Pattern)
user.domainEvents.forEach { event ->
outboxRepository.save(event)
}
// Clear events
user.clearEvents()
user.id
}
}
}// Query
data class GetUserByIdQuery(
val userId: String
) : Query
// DTO
data class UserDto(
val id: String,
val email: String,
val name: String,
val status: String
)
// Query Handler
class GetUserByIdQueryHandler(
private val userRepository: UserRepository
) : QueryHandler<GetUserByIdQuery, UserDto?> {
override suspend operator fun invoke(query: GetUserByIdQuery): UserDto? {
val user = userRepository.findById(UserId(query.userId))
return user?.let {
UserDto(
id = it.id.value,
email = it.email.value,
name = it.name,
status = it.status.name
)
}
}
}// In your controller/endpoint
class UserController(
private val commandBus: CommandBus,
private val getUserByIdHandler: GetUserByIdQueryHandler
) {
suspend fun registerUser(request: RegisterUserRequest): UserResponse {
val command = RegisterUserCommand(
email = request.email,
name = request.name
)
val result = commandBus.dispatch(command)
return result.fold(
onSuccess = { userId -> UserResponse(userId = userId.value) },
onFailure = { throw it }
)
}
suspend fun getUser(userId: String): UserDto? {
val query = GetUserByIdQuery(userId)
return getUserByIdHandler(query) // Invoke operator
}
}- GUIDE.md: Comprehensive guide on project structure and conventions
- ASSESSMENT.md: Implementation checklist and improvement suggestions
- API Documentation: Generated KDoc available in the library
lib/src/main/kotlin/com/melsardes/libraries/structuskotlin/
βββ domain/
β βββ Entity.kt # Base entity class
β βββ ValueObject.kt # Value object marker
β βββ AggregateRoot.kt # Aggregate root with events & lifecycle
β βββ Repository.kt # Repository marker
β βββ MessageOutboxRepository.kt # Outbox pattern support
β βββ Result.kt # Result type for error handling
β βββ events/
β βββ DomainEvent.kt # Domain event interface
β βββ BaseDomainEvent.kt # Base event implementation
βββ application/
β βββ commands/
β β βββ Command.kt # Command marker
β β βββ CommandHandler.kt # Command handler (invoke operator)
β β βββ CommandBus.kt # Command bus interface
β βββ queries/
β β βββ Query.kt # Query marker
β β βββ QueryHandler.kt # Query handler (invoke operator)
β βββ events/
β βββ DomainEventPublisher.kt # Event publisher interface
β βββ DomainEventHandler.kt # Event handler interface
Layers can only depend on layers beneath them:
- Domain β Nothing (pure business logic)
- Application β Domain
- Infrastructure β Domain + Application
- Presentation β Application
The library has no framework dependencies, making it usable with:
- Spring Boot
- Ktor
- Micronaut
- Quarkus
- Pure Kotlin applications
All interfaces enable easy testing through:
- Mock implementations
- In-memory implementations
- Test doubles
- No magic or hidden behavior
- Clear contracts through interfaces
- Explicit error handling
suspend fun invoke(command: CreateOrderCommand): Result<OrderId> {
return runCatching {
withTransaction {
// 1. Execute domain logic
val order = Order.create(command.customerId, command.items)
// 2. Save aggregate
orderRepository.save(order)
// 3. Save events to outbox (same transaction)
order.domainEvents.forEach { event ->
outboxRepository.save(event)
}
// 4. Clear events
order.clearEvents()
order.id
}
}
}
// Separate process publishes events
class OutboxPublisher(
private val outboxRepository: MessageOutboxRepository,
private val eventPublisher: DomainEventPublisher
) {
suspend fun publishPendingEvents() {
val messages = outboxRepository.findUnpublished(limit = 100)
messages.forEach { message ->
try {
eventPublisher.publish(message.event)
outboxRepository.markAsPublished(message.id)
} catch (e: Exception) {
outboxRepository.incrementRetryCount(message.id)
}
}
}
}// Write side: Use domain model
class CreateUserHandler : CommandHandler<CreateUserCommand, Result<UserId>> {
override suspend operator fun invoke(command: CreateUserCommand): Result<UserId> {
return runCatching {
val user = User.create(command.email, command.name)
userRepository.save(user)
user.id
}
}
}
// Read side: Use optimized read model
class GetUserHandler : QueryHandler<GetUserQuery, UserDto?> {
override suspend operator fun invoke(query: GetUserQuery): UserDto? {
// Direct database query, bypassing domain model
return jdbcTemplate.queryForObject(
"SELECT id, email, name FROM users WHERE id = ?",
UserDto::class.java,
query.userId
)
}
}Structus is AI-agent-friendly! We provide comprehensive resources to help AI coding assistants (GitHub Copilot, Cursor, Claude, ChatGPT, etc.) understand and properly use this library.
Point your AI assistant to the .ai/ directory for:
- Library Overview - Core concepts and architecture
- Usage Patterns - Correct patterns and anti-patterns
- Code Templates - Ready-to-use code templates
- Prompt Templates - Pre-written prompts for common tasks
I'm using the Structus library (com.melsardes.libraries.structuskotlin) to build an e-commerce platform.
Please read these files to understand the architecture:
1. .ai/library-overview.md - Core concepts
2. .ai/usage-patterns.md - Implementation patterns
3. .ai/code-templates.md - Code templates
Then help me create a new Order aggregate with the following requirements:
[describe your requirements here]To maximize AI assistance when using Structus:
- Share Context: Reference
.ai/files when asking AI for help - Use Prompt Templates: Copy from
.ai/prompts/and customize - Follow Patterns: AI agents trained on
.ai/usage-patterns.mdwill generate better code - Leverage Templates: Point AI to
.ai/code-templates.mdfor boilerplate
See .ai/README.md for complete documentation.
Contributions are welcome! Please feel free to submit a Pull Request.
This project is licensed under the MIT License - see the LICENSE file for details.
This library is inspired by:
- Explicit Architecture by Herberto GraΓ§a
- Domain-Driven Design by Eric Evans
- Implementing Domain-Driven Design by Vaughn Vernon
- Clean Architecture by Robert C. Martin
- CQRS by Greg Young
- Event Sourcing patterns
- Issues: GitHub Issues
- Discussions: GitHub Discussions
- Documentation: Getting Started Guide
Made with β€οΈ for the Kotlin community
