Structuring Ktor Projects Using Domain-Driven Design (DDD) Concepts: A Step-by-Step Guide With a Minimalistic CRM

In this guide, we’ll show you how to structure a project using domain-driven design (DDD) concepts, step by step, by building a minimalistic CRM system. Sample code for the article: https://github.com/antonarhipov/ktor-ddd-example  Ktor is a highly flexible framework that allows developers to structure their applications however they see fit. Unlike some opinionated frameworks (like Spring Boot), […]

Apr 30, 2025 - 13:32
 0
Structuring Ktor Projects Using Domain-Driven Design (DDD) Concepts: A Step-by-Step Guide With a Minimalistic CRM

In this guide, we’ll show you how to structure a project using domain-driven design (DDD) concepts, step by step, by building a minimalistic CRM system.

Sample code for the article: https://github.com/antonarhipov/ktor-ddd-example 

Ktor is a highly flexible framework that allows developers to structure their applications however they see fit. Unlike some opinionated frameworks (like Spring Boot), Ktor does not enforce a predefined project structure, which can be both an advantage and a challenge. Many developers, especially those new to Ktor, often wonder:

How should I structure my Ktor project for scalability, maintainability, and long-term growth?

In this guide, we’ll answer that question step by step by building a minimalistic CRM system. This CRM will start as a simple Ktor application and gradually evolve into a well-structured, scalable project following best practices inspired by domain-driven design (DDD) and feature-based modularization.

How we’ll approach this

We’ll start with a simple, unstructured Ktor project and progressively refine it to have a well-structured, scalable architecture. Each step will introduce new concepts and improvements:

  1. Introduce domain models to define entities like Customer, Note, and Reminder.
  2. Define repositories and services to separate the business logic from the routes.
  3. Add a presentation layer with Ktor routes.

Defining the domain model: Entities and value objects

Our first step is to define the core domain model for our minimalistic CRM. In domain-driven design (DDD), we structure the domain layer around entities, value objects, and aggregates to model real-world concepts effectively.

Defining entities

Since we’re building a minimalistic CRM system, we need to define the fundamental building blocks:

  • Customer: Represents a client with whom you interact.
  • Contact: Represents a method to reach a customer. One customer can have multiple contacts.
  • Note: Represents business information or interactions (can be tied to a customer or an order).
  • Reminder: Represents an alert associated with a contact (and optionally linked to a note) to help you remember promises or follow-ups.
// domain/customer/Customer.kt

import java.time.LocalDateTime
import java.util.UUID

// A simple Value Object for unique identifiers
@JvmInline
value class CustomerId(val value: Long)
@JvmInline
value class ContactId(val value: Long)
@JvmInline
value class NoteId(val value: Long)


data class Contact(
   val id: ContactId? = null,
   val name: String,
   val email: Email, 
   val phone: String
)

data class Note(
   val id: NoteId? = null,
   val content: String,
   val createdAt: LocalDateTime = LocalDateTime.now()
)

data class Customer(
   val id: CustomerId? = null,
   val name: String,
   val contacts: List = emptyList(),
   val notes: List = emptyList()
) {
   fun withContact(contact: Contact): Customer {
      return copy(contacts = contacts + contact)
   }

   fun withNote(note: Note): Customer {
      return copy(notes = notes + note)
   }
}

Defining value objects

Value objects are immutable and are defined solely by their attributes. They help encapsulate concepts such as identifiers or more complex attributes (e.g., an Email object with validation). In our example, the unique identifiers (like CustomerId) are implemented as value objects, ensuring immutability and encapsulation of identity details.

You can further extend this idea by encapsulating additional business rules. For example:

// Example: a more complex value object for 
// Email might include validation logic.
@JvmInline
value class Email(val address: String) {
    init {
        require(address.contains("@")) { "Invalid email address" }
    }
}

Relationships and aggregates

In domain-driven design (DDD), an aggregate is a cluster of domain objects that should be treated as a single unit when enforcing business rules. Each aggregate has a clearly defined boundary and a single aggregate root, which serves as the only entry point for modifying the aggregate’s state.

In our initial model:

  • Customer is the aggregate root, ensuring consistency and encapsulating its associated contacts and notes. All modifications to these related entities must go through the customer.
  • Reminder is a separate entity linked to a customer (and optionally a note), but it has its own lifecycle and behaviors, making it an independent aggregate.

Using aggregates helps enforce business rules and invariants. For example, defining customer as the aggregate root ensures that contacts are always managed in the context of their customer, preventing orphaned or inconsistent data.

This is what the project structure looks like so far with the two aggregations defined above:

ktor-crm/
└─ src/
   └─ main/
      └─ kotlin/
         ├─ Application.kt  // Ktor application entry point
         └─ domain/
            ├─ customer/
            │  └─ Customer.kt  // Customer, Contact, Note 
            └─ reminder/
               └── Reminder.kt  // Reminder

In this first step of our guide, we defined the core domain elements for our minimalistic CRM:

  • Entities: Customer, Contact, Note, and Reminder
  • Value objects: CustomerId, ContactId, NoteId, and ReminderId (and potentially others like Email)

These definitions set the stage for further expansion, such as implementing business logic within domain services, setting up repositories for persistence, and eventually wiring up these components in our Ktor application.

Next, we can look at how to encapsulate business behaviors, and later, how these domain elements interact with the application layer in a modularized Ktor structure.


Encapsulating business behaviors: Repositories, services, and domain events.

In a DDD-inspired design, repositories, domain services, and domain events work together to implement your CRM’s use cases while maintaining clear separation of concerns.

Repositories

Repositories act as an abstraction layer between your domain model and the data persistence mechanism (like a database). They provide simple methods for retrieving, storing, and updating domain entities without exposing the underlying data access logic.

interface CustomerRepository {
   fun findById(id: CustomerId): Customer?
   fun save(customer: Customer)

   // Additional methods like delete, update, list, etc.
}

The repository encapsulates all the queries and transactions needed to work with the domain model. For instance, when adding a new contact, the repository can ensure that the updated customer aggregate is properly stored.

Our demo project uses a mock in-memory implementation of the repository interface. The real implementation depends on the technology and infrastructure used by your organization. In Ktor projects, you often see the Exposed database library used to implement this functionality. However, Ktor does not enforce any constraints on repository implementations, so you can choose to use any other data access libraries.

Domain services

Domain services encapsulate business logic that doesn’t naturally belong to an entity or value object. They coordinate complex operations that might involve multiple domain objects or aggregates.

class CustomerService(
   private val customerRepository: CustomerRepository,
   private val eventPublisher: EventPublisher
) {

   fun createCustomer(name: String): Customer {
       val customer = Customer(name = name)
       customerRepository.save(customer)
       return customer
   }

   fun getCustomer(id: Long): Customer? {
       return customerRepository.findById(CustomerId(id))
   }

   fun addContact(customerId: CustomerId, contact: Contact): Customer? {
       val customer = customerRepository.findById(customerId)
           ?: return null

       // Business logic to add a contact (could be a method on Customer entity)
       val updatedCustomer = customer.withContact(contact)
       customerRepository.save(updatedCustomer)

       // Publish a domain event to signal that a new contact has been added
       eventPublisher.publish(ContactAddedEvent(customerId, contact))

       return updatedCustomer
   }


   fun addNote(customerId: CustomerId, note: Note): Customer? {
       val customer = customerRepository.findById(customerId)
           ?: return null

       val updatedCustomer = customer.withNote(note)
       customerRepository.save(updatedCustomer)

       // Publish a domain event to signal about a new note
       eventPublisher.publish(NoteAddedEvent(customerId, note))

       return updatedCustomer
   }
}

The service:

  • Fetches the current state of the customer.
  • Applies business rules (e.g., adding a contact or note).
  • Persists the updated state via the repository.
  • Publishes a domain event to notify other parts of the system of the change.

Domain events

Domain events are messages that indicate something significant has occurred within the domain. They help decouple the direct consequences of a business operation from the triggering action. Other components can listen to these events and perform additional tasks (e.g., sending notifications, updating search indexes) without cluttering the core business logic.

// DomainEvents.kt
sealed interface DomainEvent

data class ContactAddedEvent(
   val customerId: CustomerId,
   val contact: Contact
): DomainEvent

data class NoteAddedEvent(
   val customerId: CustomerId,
   val note: Note
): DomainEvent

Event publishing

An EventPublisher interface can be used to publish these events. In a real-world application, this can be integrated with an event bus or messaging system:

interface EventPublisher {
   fun publish(event: DomainEvent)
}

Benefits:

  • Decoupling: Other parts of your application can subscribe to these events without the service needing to know about them.
  • Extensibility: As your application evolves, additional behavior can be attached to these events without modifying the core business logic.

Bringing it all together

In our minimalistic CRM, when a user action triggers a use case (e.g., adding a new contact or note), the flow might look like this:

  1. User action:
    The client makes an API call to add a contact to a customer.
  2. Service coordination:
    The CustomerService retrieves the customer using the CustomerRepository, applies the business logic to add the contact, and saves the updated customer.
  3. Event publication:
    After saving, the service publishes a ContactAddedEvent through the EventPublisher. Other parts of the system (like notification modules or logging services) can listen to this event and react accordingly.

This approach ensures that your domain logic remains clean, well-organized, and focused on business rules while delegating infrastructure and cross-cutting concerns to dedicated components.

Now the project structure looks as follows:

ktor-crm/
└─ src/
   └─ main/
      └─ kotlin/
         └─ com/
            └─ example/
               ├─ Application.kt
               ├─ domain/
               │  ├─ customer/
               │  │  ├─ Customer.kt
               │  │  ├─ CustomerRepository.kt
               │  │  └─ CustomerService.kt
               │  └─ reminder /
               │     ├─ Reminder.kt
               │     ├─ ReminderRepository.kt
               │     └─ ReminderService.kt
               └─ events/ 
                  ├─ DomainEvents.kt
                  └─ EventPublisher.kt

Building the presentation layer with Ktor

The code samples above didn’t use any external libraries or Ktor APIs because these are just implementation details. The conceptual framework of domain-driven design allowed us to structure the code so that we can plug in the necessary dependencies to create the application.

Now we can move on and wire the services into the web server, exposing the REST API as a presentation layer. For this purpose, we will add the corresponding route definitions into the domain subpackages.

// domain/customer/CustomerRoutes.kt
fun Application.customerRoutes() {
   routing {
       val repository = InMemoryCustomerRepository() 
       val eventPublisher = EventPublisherImpl()

       //TODO: use the service in the routes below
       val service = CustomerService(repository, eventPublisher)

       route("/customers") {
           // Create customer
           post {
               val customer = call.receive()
               val createdCustomer = service.createCustomer(customer.name)
               call.respond(HttpStatusCode.Created, createdCustomer)
           }

           // Get customer by ID
           get("/{id}") {
               val id = call.parameters["id"]?.toLongOrNull() ?: return@get call.respondText(
                   "Missing or malformed id",
                   status = HttpStatusCode.BadRequest
               )

               val customer = service.getCustomer(id)
               if (customer != null) {
                   call.respond(customer)
               } else {
                   call.respondText("Customer not found", status = HttpStatusCode.NotFound)
               }
           }

           // Add contact to customer
           post("/{id}/contacts") {
               val id = call.parameters["id"]?.toLongOrNull() ?: return@post call.respondText(
                   "Missing or malformed id",
                   status = HttpStatusCode.BadRequest
               )

               val contact = call.receive()
               val updatedCustomer = service.addContact(CustomerId(id), contact)

               if (updatedCustomer != null) {
                   call.respond(updatedCustomer)
               } else {
                   call.respondText("Customer not found", status = HttpStatusCode.NotFound)
               }
           }

           // Add note to customer
           post("/{id}/notes") {
               val id = call.parameters["id"]?.toLongOrNull() ?: return@post call.respondText(
                   "Missing or malformed id",
                   status = HttpStatusCode.BadRequest
               )

               val note = call.receive()
               val updatedCustomer = service.addNote(CustomerId(id), note)

               if (updatedCustomer != null) {
                   call.respond(updatedCustomer)
               } else {
                   call.respondText("Customer not found", status = HttpStatusCode.NotFound)
               }
           }
       }
   }
}



// domain/reminder/ReminderRoutes.kt
fun Application.reminderRoutes() {
   routing {
       val repository = InMemoryReminderRepository()
       val service = ReminderService(repository)

       route("/reminders") {
           // Create a reminder
           post {
               val reminder = call.receive()
               val createdReminder = service.createReminder(
                   customerId = reminder.customerId,
                   noteId = reminder.noteId?.value,
                   remindAt = reminder.remindAt,
                   message = reminder.message
               )
               call.respond(HttpStatusCode.Created, createdReminder)
           }

           // Get reminder by ID
           get("/{id}") {
               val id = call.parameters["id"] ?: return@get call.respondText(
                   "Missing or malformed id",
                   status = HttpStatusCode.BadRequest
               )

               val reminder = service.getReminder(id)
               if (reminder != null) {
                   call.respond(reminder)
               } else {
                   call.respondText("Reminder not found", 
status = HttpStatusCode.NotFound)
               }
           }

           // Get reminders for a customer
           get("/customer/{customerId}") {
               val customerId = call.parameters["customerId"] 
?: return@get call.respondText(
                 	  "Missing or malformed customerId",
               	    status = HttpStatusCode.BadRequest
             		  )

               val reminders = service.getRemindersForCustomer(CustomerId(customerId))
               call.respond(reminders)
           }
       }
   }
}

You might have noticed that the repository and event publisher implementations are created manually. However, as the project grows, it might be more convenient to implement this using the dependency injection (DI) pattern instead.

The subroutes for the corresponding domain objects and aggregates are defined as extension functions in their respective files. This is a common pattern when working with Ktor projects. These functions are called from within the Application.kt module function on the application startup:

// Application.kt
fun main() {
   embeddedServer(Netty, port = 8080, module = Application::module)
       .start(wait = true)
}

fun Application.module() {
   install(ContentNegotiation) {
       json()
   }

   routing {
       // Register routes from the domain subpackages
       customerRoutes() 
       reminderRoutes()
   }
}

With the routing files, our project structure looks as follows:

ktor-crm/
└─ src/
   └─ main/
      └─ kotlin/
         └─ com/
            └─ example/
               ├─ Application.kt
               ├─ domain/
               │  ├─ customer/
               │  │  ├─ Customer.kt
               │  │  ├─ CustomerRepository.kt
               │  │  ├─ CustomerRoutes.kt
               │  │  └─ CustomerService.kt
               │  └─ reminder /
               │     ├─ Reminder.kt
               │     ├─ ReminderRepository.kt
               │     ├─ ReminderRoutes.kt
               │     └─ ReminderService.kt
               └─ events/ 
                  ├─ DomainEvents.kt
                  └─ EventPublisher.kt 

At this point, we have integrated the business logic into the Ktor application, and the next step ise adding the infrastructural aspects, such as database configuration, authentication, etc. 

Summary

This article introduced key domain-driven design (DDD) concepts – like entities, value objects, repositories, and domain services – and demonstrated how to apply them in a Ktor project. We built a simple CRM system, organizing the code into well-defined domain subpackages (customers, reminders) with dedicated value classes and modular routes. This approach creates a clear, scalable, and maintainable codebase for your Ktor applications.

The source code for the sample presented in this article is available at: https://github.com/antonarhipov/ktor-ddd-example