Skip to content

Eliminates boilerplate and enforces best practices for building scalable, maintainable applications. Implements DDD patterns, CQRS with type-safe handlers, and event-driven architecture with outbox pattern. AI-agent friendly with comprehensive documentation and code templates. Framework-agnostic design. Minimal dependencies, maximum flexibility.

License

Notifications You must be signed in to change notification settings

MelSardes/structus-kotlin

Repository files navigation

Structus Logo

Structus - Kotlin Architecture Toolkit

Kotlin License Version AI Agent Friendly

Structus Banner

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).

🎯 Purpose

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.

✨ Key Features

  • πŸš€ 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

πŸ“¦ Installation

Building from Source

Since the library is not yet published to a public repository, you'll need to build and install it locally:

1. Clone and Build

git clone https://github.com/melsardes/structus-kotlin.git
cd structus-kotlin
./gradlew build publishToMavenLocal

This will install the library to your local Maven repository (~/.m2/repository).

2. Add to Your Project

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.

πŸ›οΈ Architecture Components

Domain Layer (com.melsardes.libraries.structuskotlin.domain)

Core Building Blocks

// 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

Events

// 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)
}

Application Layer - Commands (com.melsardes.libraries.structuskotlin.application.commands)

// 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
}

Application Layer - Queries (com.melsardes.libraries.structuskotlin.application.queries)

// 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
}

Application Layer - Events (com.melsardes.libraries.structuskotlin.application.events)

// Domain Event Publisher: Publishes events to external systems
interface DomainEventPublisher {
    suspend fun publish(event: DomainEvent)
    suspend fun publishBatch(events: List<DomainEvent>)
}

πŸš€ Quick Start

1. Define Your Domain Model

// 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
}

2. Define Commands and Handlers

// 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
        }
    }
}

3. Define Queries and Handlers

// 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
            )
        }
    }
}

4. Use in Your Application

// 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
    }
}

πŸ“– Documentation

  • GUIDE.md: Comprehensive guide on project structure and conventions
  • ASSESSMENT.md: Implementation checklist and improvement suggestions
  • API Documentation: Generated KDoc available in the library

πŸ—οΈ Project Structure

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

🎯 Design Principles

1. Dependency Rule

Layers can only depend on layers beneath them:

  • Domain β†’ Nothing (pure business logic)
  • Application β†’ Domain
  • Infrastructure β†’ Domain + Application
  • Presentation β†’ Application

2. Framework Independence

The library has no framework dependencies, making it usable with:

  • Spring Boot
  • Ktor
  • Micronaut
  • Quarkus
  • Pure Kotlin applications

3. Testability

All interfaces enable easy testing through:

  • Mock implementations
  • In-memory implementations
  • Test doubles

4. Explicit Over Implicit

  • No magic or hidden behavior
  • Clear contracts through interfaces
  • Explicit error handling

πŸ”§ Advanced Patterns

Transactional Outbox Pattern

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)
            }
        }
    }
}

CQRS with Separate Read Models

// 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
        )
    }
}

πŸ€– AI Agent Support

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.

Quick Start for AI Agents

Point your AI assistant to the .ai/ directory for:

Example AI Prompt

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]

For Developers

To maximize AI assistance when using Structus:

  1. Share Context: Reference .ai/ files when asking AI for help
  2. Use Prompt Templates: Copy from .ai/prompts/ and customize
  3. Follow Patterns: AI agents trained on .ai/usage-patterns.md will generate better code
  4. Leverage Templates: Point AI to .ai/code-templates.md for boilerplate

See .ai/README.md for complete documentation.

🀝 Contributing

Contributions are welcome! Please feel free to submit a Pull Request.

πŸ“„ License

This project is licensed under the MIT License - see the LICENSE file for details.

πŸ™ Acknowledgments

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

πŸ“ž Support


Made with ❀️ for the Kotlin community

About

Eliminates boilerplate and enforces best practices for building scalable, maintainable applications. Implements DDD patterns, CQRS with type-safe handlers, and event-driven architecture with outbox pattern. AI-agent friendly with comprehensive documentation and code templates. Framework-agnostic design. Minimal dependencies, maximum flexibility.

Topics

Resources

License

Stars

Watchers

Forks

Packages

No packages published

Contributors 2

  •  
  •  

Languages