| name | kotlin-ddd-patterns |
| description | Follow Domain Driven Design, SOLID, and Clean Code principles when working with Kotlin projects. Implements Controller-Service-Repository pattern with clear module separation. Use when creating or modifying Kotlin code, especially in multi-module Spring Boot projects with shared business logic. |
Kotlin DDD Patterns & Architecture
Module Structure
This project follows a multi-module architecture where business logic is separated from presentation:
lpg-ehl/
├── lpg-ehl-api/ # API module (Controllers only)
│ └── controller/ # REST endpoints - THIN WRAPPER
│
├── lpg-ehl-service/ # Business logic + Database (CORE)
│ ├── service/ # Business services (TransactionService, PumpStateService)
│ ├── model/ # Domain entities (Transaction, PriceHistory)
│ ├── repository/ # JPA repositories (TransactionRepository)
│ ├── pump/ # Domain: Pump management
│ ├── transaction/ # Domain: Transaction management
│ ├── price/ # Domain: Price management
│ └── payment/ # Domain: Payment processing (interfaces)
│
├── lpg-ehl-app-headless/ # Headless app (shares service module)
└── lpg-ehl-core/ # Protocol layer (NO Spring dependencies)
Key Principle: The lpg-ehl-service module contains ALL business logic and database access. Both lpg-ehl-api (web app) and lpg-ehl-app-headless depend on the service module to share the same business logic.
Controller-Service-Repository Pattern
Layer Responsibilities
Controller (API module)
- ONLY handles HTTP requests/responses
- Maps DTOs to domain objects
- Delegates ALL business logic to services
- Returns appropriate HTTP status codes
- Example:
PumpController, TransactionController
Service (Service module)
- Contains ALL business logic
- Orchestrates domain operations
- Uses repositories for data access
- Implements domain rules and validations
- Example:
PumpStateService, TransactionService, PriceService
Repository (Service module)
- Data access layer (Spring Data JPA)
- Query methods for entities
- NO business logic
- Example:
TransactionRepository, PriceHistoryRepository
Example Structure
@RestController
@RequestMapping("/api/v1/pumps")
class PumpController(
private val pumpStateService: PumpStateService
) {
@PostMapping("/{address}/unblock")
fun unblockPump(@PathVariable address: Int): ResponseEntity<Map<String, Any>> {
val result = pumpStateService.unblock(address, withAuthorization = true)
return result.fold(
onSuccess = { status -> ResponseEntity.ok(mapOf("success" to true, "state" to status.state)) },
onFailure = { error -> ResponseEntity.status(409).body(mapOf("error" to error.message)) }
)
}
}
@Service
class PumpStateService(
private val transactionService: TransactionService,
private val ehlCommunicator: EhlCommunicator,
private val transactionRepository: TransactionRepository
) {
fun unblock(address: Int, withAuthorization: Boolean): Result<PumpStatus> {
val transaction = transactionService.createStartedTransaction(address, currentPriceKr)
ehlCommunicator.sendUnblock(address)
return Result.success(pumpStatus)
}
}
@Repository
interface TransactionRepository : JpaRepository<Transaction, UUID> {
fun findByDispenserAddress(address: Int, pageable: Pageable): Page<Transaction>
fun findWithFilters(...): Page<Transaction>
}
Domain Driven Design Principles
Domain Entities
Entities live in the service module and represent core business concepts:
@Entity
@Table(name = "transactions")
data class Transaction(
@Id
val transactionId: UUID = UUID.randomUUID(),
val dispenserAddress: Int,
val volumeDeciliters: Int,
val amountOre: Int,
@Enumerated(EnumType.STRING)
val paymentStatus: PaymentStatus,
val timestamp: LocalDateTime = LocalDateTime.now()
)
Domain Services
Services encapsulate business logic that doesn't naturally fit in entities:
@Service
class TransactionService(
private val transactionRepository: TransactionRepository
) {
@Transactional
fun createStartedTransaction(
dispenserAddress: Int,
pricePerLiterKr: Double
): Transaction {
val transaction = Transaction(
dispenserAddress = dispenserAddress,
volumeDeciliters = 0,
amountOre = 0,
paymentStatus = PaymentStatus.STARTED,
pricePerLiterKr = pricePerLiterKr
)
return transactionRepository.save(transaction)
}
}
Value Objects
Use data classes for value objects (immutable, no identity):
data class PumpStatus(
val address: Int,
val state: PumpState,
val volumeLitres: Double,
val amountKr: Double,
val pricePerLitreKr: Double
)
SOLID Principles
Single Responsibility Principle (SRP)
Each class has ONE reason to change:
TransactionService - Only transaction lifecycle
PriceService - Only price management
PumpStateService - Only pump state orchestration
Open/Closed Principle (OCP)
Use interfaces for extensibility:
interface PaymentGateway {
fun startPayment(request: PaymentRequest): Payment
}
class MockPaymentGateway : PaymentGateway { ... }
class SimulatedPaymentGateway : PaymentGateway { ... }
class NetsCloudPaymentGateway : PaymentGateway { ... }
Liskov Substitution Principle (LSP)
Implementations must be substitutable:
@Service
class PumpStateService(
private val paymentGateway: PaymentGateway
)
Interface Segregation Principle (ISP)
Keep interfaces focused:
interface EventPublisher {
fun publishPriceUpdate(priceKr: Double)
fun publishPumpStatusUpdate(status: PumpStatus)
}
interface SystemEvents {
fun publishPriceUpdate(...)
fun publishPumpStatusUpdate(...)
fun publishTransactionUpdate(...)
fun publishSystemHealth(...)
}
Dependency Inversion Principle (DIP)
Depend on abstractions, not concretions:
@Service
class TransactionService(
private val repository: TransactionRepository,
private val syncService: TransactionSyncService?
)
Clean Code Practices
Naming Conventions
- Services:
*Service suffix (e.g., TransactionService, PumpStateService)
- Repositories:
*Repository suffix (e.g., TransactionRepository)
- Controllers:
*Controller suffix (e.g., PumpController)
- Entities: Domain names (e.g.,
Transaction, PriceHistory, Customer)
- DTOs:
*Request, *Response suffixes (e.g., PriceUpdateRequest, TransactionResponse)
Function Design
- Small functions: One responsibility per function
- Descriptive names: Function name describes what it does
- Avoid side effects: Pure functions when possible
- Error handling: Use
Result<T> for operations that can fail
fun unblock(address: Int, withAuthorization: Boolean): Result<PumpStatus> {
validatePumpState(address)
val transaction = createTransactionIfNeeded(address)
sendUnblockCommand(address)
return updatePumpState(address, PumpState.READY_TO_PUMP)
}
fun doStuff(address: Int): PumpStatus {
}
Documentation
Always document:
- Classes: Purpose, responsibilities, usage examples
- Public methods: Parameters, return values, exceptions, business rules
- Complex logic: Why, not just what
- Business rules: Domain-specific constraints
@Service
class PumpStateService(...) {
fun unblock(address: Int, withAuthorization: Boolean): Result<PumpStatus> {
}
}
Code Organization
Package structure (service module):
no.cloudberries.lpg.service/
├── pump/ # Domain: Pump management
│ ├── PumpStateService.kt
│ ├── PumpAuthorizationService.kt
│ └── DispenserStatusRepository.kt
├── transaction/ # Domain: Transaction management
│ ├── TransactionService.kt
│ ├── Transaction.kt
│ └── TransactionRepository.kt
├── price/ # Domain: Price management
│ ├── PriceService.kt
│ ├── PriceHistory.kt
│ └── PriceHistoryRepository.kt
└── payment/ # Domain: Payment (interfaces)
└── PaymentGateway.kt
Hexagonal Architecture (Ports & Adapters)
Input Ports (Primary Adapters)
Controllers, CLI commands, scheduled tasks invoke services:
@RestController
class PumpController(
private val pumpStateService: PumpStateService
)
@Scheduled(fixedRate = 500)
fun pollVolume() {
pumpStateService.updateVolume()
}
Output Ports (Secondary Adapters)
Services depend on interfaces for external systems:
interface PaymentGateway {
fun startPayment(request: PaymentRequest): Payment
}
@Service
class TransactionService(
private val paymentGateway: PaymentGateway
)
@Service
class MockPaymentGateway : PaymentGateway { ... }
Creating New Features
Step 1: Analyze Domain
Identify the domain concept:
- Is it a new entity? → Create entity in appropriate domain package
- Is it business logic? → Create service in appropriate domain package
- Is it data access? → Create repository in same domain package
Step 2: Create Domain Layer (Service Module)
@Entity
@Table(name = "my_entities")
data class MyEntity(
@Id
val id: UUID = UUID.randomUUID(),
val name: String
)
@Repository
interface MyEntityRepository : JpaRepository<MyEntity, UUID> {
fun findByName(name: String): MyEntity?
}
@Service
class MyEntityService(
private val repository: MyEntityRepository
) {
@Transactional
fun create(name: String): MyEntity {
require(name.isNotBlank()) { "Name cannot be empty" }
require(repository.findByName(name) == null) { "Name must be unique" }
val entity = MyEntity(name = name)
return repository.save(entity)
}
}
Step 3: Create API Layer (API Module)
@RestController
@RequestMapping("/api/v1/my-entities")
class MyEntityController(
private val myEntityService: MyEntityService
) {
@PostMapping
fun create(@RequestBody request: CreateMyEntityRequest): ResponseEntity<MyEntityResponse> {
val entity = myEntityService.create(request.name)
return ResponseEntity.ok(MyEntityResponse.from(entity))
}
}
data class CreateMyEntityRequest(
val name: String
)
data class MyEntityResponse(
val id: UUID,
val name: String
) {
companion object {
fun from(entity: MyEntity) = MyEntityResponse(
id = entity.id,
name = entity.name
)
}
}
Code Review Checklist
When reviewing or creating code, verify:
Common Patterns
Result Type for Error Handling
fun unblock(address: Int): Result<PumpStatus> {
return try {
validatePumpState(address)
val status = performUnblock(address)
Result.success(status)
} catch (e: IllegalStateException) {
Result.failure(e)
}
}
val result = pumpStateService.unblock(1)
result.fold(
onSuccess = { status -> logger.info("Pump unblocked: {}", status.state) },
onFailure = { error -> logger.error("Failed: {}", error.message) }
)
Transaction Management
@Service
@Transactional(readOnly = true)
class TransactionService(...) {
@Transactional
fun createTransaction(...): Transaction {
}
fun getTransaction(id: UUID): Transaction? {
}
}
Domain Events
@Service
class PumpStateService(
private val eventPublisher: EventPublisher
) {
fun unblock(address: Int) {
eventPublisher.publishPumpStatusUpdate(pumpStatus)
}
}
Summary
- Module separation: API module = controllers, Service module = business logic + database
- Controller-Service-Repository: Clear layer responsibilities
- Domain Driven Design: Entities, services, repositories organized by domain
- SOLID principles: Single responsibility, dependency inversion, interfaces
- Clean Code: Small functions, descriptive names, thorough documentation
- Hexagonal Architecture: Ports (interfaces) and adapters (implementations)
- Documentation: Always document classes, public methods, and business rules
When creating new code, start with the domain (service module), then add the API layer (API module). Keep business logic in services, never in controllers or repositories.