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/structus-io/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 objectsabstractclassEntity<ID> {
abstractval id:ID// equals/hashCode based on ID
}
// Value Object: Attribute-based immutable objectsinterfaceValueObject// Aggregate Root: Consistency boundary with event management and lifecycleabstractclassAggregateRoot<ID> : Entity<ID>() {
val domainEvents:List<DomainEvent>
protectedfunrecordEvent(event:DomainEvent)
funclearEvents()
// Lifecycle managementinternalfunmarkAsCreated(by:String, at: kotlin.time.Instant = Clock.System.now())
internalfunmarkAsUpdated(by:String, at: kotlin.time.Instant = Clock.System.now())
funsoftDelete(by:String, at: kotlin.time.Instant = Clock.System.now())
funrestore(by:String, at: kotlin.time.Instant = Clock.System.now())
funisDeleted(): BooleanfunisActive(): BooleaninternalfunincrementVersion()
}
// Repository: Persistence contractinterfaceRepository// Domain Event: Something that happenedinterfaceDomainEvent {
val eventId:Stringval occurredAt: kotlin.time.Instant// Uses Kotlin multiplatform time APIval aggregateId:String
}
// Transactional Outbox PatterninterfaceMessageOutboxRepository : Repository {
suspendfunsave(event:DomainEvent)
suspendfunfindUnpublished(limit:Int): List<OutboxMessage>
suspendfunmarkAsPublished(messageId:String)
suspendfunincrementRetryCount(messageId:String)
}// Command: Intent to change stateinterfaceCommand// Command Handler: Executes business logic (uses invoke operator)interfaceCommandHandler<inC:Command, outR> {
suspendoperatorfuninvoke(command:C): R
}
// Command Bus: Dispatches commands to handlersinterfaceCommandBus {
fun <C:Command, R> register(commandClass:KClass<C>, handler:CommandHandler<C, R>)
suspendfun <C:Command, R> dispatch(command:C): R
}// Query: Request for datainterfaceQuery// Query Handler: Retrieves data (uses invoke operator)interfaceQueryHandler<inQ:Query, outR> {
suspendoperatorfuninvoke(query:Q): R
}// Domain Event Publisher: Publishes events to external systemsinterfaceDomainEventPublisher {
suspendfunpublish(event:DomainEvent)
suspendfunpublishBatch(events:List<DomainEvent>)
}// Value Objectdata classEmail(valvalue:String) : ValueObject {
init {
require(value.matches(EMAIL_REGEX)) { "Invalid email format" }
}
companionobject {
privatevalEMAIL_REGEX="^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+$".toRegex()
}
}
// Entity IDdata classUserId(valvalue:String) : ValueObject
// Aggregate RootclassUser(
overridevalid:UserId,
varemail:Email,
varname:String,
varstatus:UserStatus
) : AggregateRoot<UserId>() {
funregister(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()
))
}
companionobject {
funcreate(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 Eventdata classUserRegisteredEvent(
overridevaleventId:String = UUID.randomUUID().toString(),
overridevaloccurredAt: kotlin.time.Instant = kotlin.time.Clock.System.now(),
overridevalaggregateId:String,
valuserId:String,
valemail:String,
valregisteredAt: kotlin.time.Instant
) : DomainEvent
// Repository InterfaceinterfaceUserRepository : Repository {
suspendfunfindById(id:UserId): User?suspendfunfindByEmail(email:Email): User?suspendfunsave(user:User)
suspendfunexistsByEmail(email:Email): Boolean
}// Commanddata classRegisterUserCommand(
valemail:String,
valname:String
) : Command {
init {
require(email.isNotBlank()) { "Email cannot be blank" }
require(name.isNotBlank()) { "Name cannot be blank" }
}
}
// Command HandlerclassRegisterUserCommandHandler(
privatevaluserRepository:UserRepository,
privatevaloutboxRepository:MessageOutboxRepository
) : CommandHandler<RegisterUserCommand, Result<UserId>> {
overridesuspendoperatorfuninvoke(command:RegisterUserCommand): Result<UserId> {
return runCatching {
// Check if email already existsif (userRepository.existsByEmail(Email(command.email))) {
throwIllegalStateException("Email already exists")
}
// Create userval 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
}
}
}// Querydata classGetUserByIdQuery(
valuserId:String
) : Query
// DTOdata classUserDto(
valid:String,
valemail:String,
valname:String,
valstatus:String
)
// Query HandlerclassGetUserByIdQueryHandler(
privatevaluserRepository:UserRepository
) : QueryHandler<GetUserByIdQuery, UserDto?> {
overridesuspendoperatorfuninvoke(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/endpointclassUserController(
privatevalcommandBus:CommandBus,
privatevalgetUserByIdHandler:GetUserByIdQueryHandler
) {
suspendfunregisterUser(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 }
)
}
suspendfungetUser(userId:String): UserDto? {
val query =GetUserByIdQuery(userId)
return getUserByIdHandler(query) // Invoke operator
}
}- 📚 Official Documentation Website - Complete guides, tutorials, and API reference
- 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
suspendfuninvoke(command:CreateOrderCommand): Result<OrderId> {
return runCatching {
withTransaction {
// 1. Execute domain logicval 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 eventsclassOutboxPublisher(
privatevaloutboxRepository:MessageOutboxRepository,
privatevaleventPublisher:DomainEventPublisher
) {
suspendfunpublishPendingEvents() {
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 modelclassCreateUserHandler : CommandHandler<CreateUserCommand, Result<UserId>> {
overridesuspendoperatorfuninvoke(command:CreateUserCommand): Result<UserId> {
return runCatching {
val user =User.create(command.email, command.name)
userRepository.save(user)
user.id
}
}
}
// Read side: Use optimized read modelclassGetUserHandler : QueryHandler<GetUserQuery, UserDto?> {
overridesuspendoperatorfuninvoke(query:GetUserQuery): UserDto? {
// Direct database query, bypassing domain modelreturn 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
