messages) {
- if (messages == null || messages.isEmpty()) {
- return;
- }
-
- ChatEntity chat = chatJpaRepository.findOneByUuid(chatUuid).orElse(null);
-
- if (chat == null) {
- chat = new ChatEntity();
- chat.setUuid(chatUuid != null ? chatUuid : UUID.randomUUID().toString());
- chat.setResume(createResume(messages.getFirst()));
- chat.setCreatedDate(new Date());
- chat = chatJpaRepository.save(chat);
- } else if (chat.getMessages().isEmpty()) {
- chat.setResume(createResume(messages.getFirst()));
- chat = chatJpaRepository.save(chat);
- }
-
- ChatEntity finalChat = chat;
- messages.forEach(msg -> finalChat.getMessages().add(ChatMessageMapper.toEntity(finalChat, msg)));
-
- chatJpaRepository.save(finalChat);
- }
-
- private String createResume(ChatMessage chatMessage) {
- return chatMessage.text().length() > 35 ? chatMessage.text().substring(0, 35).concat("...") : chatMessage.text();
- }
-}
\ No newline at end of file
diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml
deleted file mode 100644
index a944928..0000000
--- a/backend/src/main/resources/application.yml
+++ /dev/null
@@ -1,21 +0,0 @@
-server:
- port: 8080
-spring:
- application:
- name: ia-chat-boot
- datasource:
- url: jdbc:sqlite:file:chat.db
- jpa:
- database-platform: org.hibernate.community.dialect.SQLiteDialect
- hibernate:
- ddl-auto: update
-springdoc:
- api-docs.path: /api-docs
- swagger-ui.path: /swagger-ui.html
-llama:
- model:
- name: openchat-3.5-0106.Q4_K_M
- gpu:
- enabled: true
- layers: 35
- tokens: 1024
diff --git a/chat-api/pom.xml b/chat-api/pom.xml
new file mode 100644
index 0000000..528d598
--- /dev/null
+++ b/chat-api/pom.xml
@@ -0,0 +1,144 @@
+
+ 4.0.0
+
+
+ com.pablotj
+ ai-chat-offline
+ 1.0.0-SNAPSHOT
+
+
+ chat-api
+ AI Chat Platform - Backend
+ Enterprise-grade AI Chat Platform Backend
+ jar
+
+
+ 21
+ 3.2.0
+ 2.8.9
+ 4.2.0
+ 3.45.1.0
+ 1.5.5.Final
+ 1.12.0
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-web
+
+
+
+ org.springframework.boot
+ spring-boot-starter-validation
+
+
+
+ org.springframework.boot
+ spring-boot-actuator
+ ${spring-boot.version}
+
+
+
+
+ org.springframework.boot
+ spring-boot-actuator-autoconfigure
+ ${spring-boot.version}
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-data-jpa
+
+
+
+ org.xerial
+ sqlite-jdbc
+ ${sqlite.version}
+
+
+
+ org.hibernate.orm
+ hibernate-community-dialects
+
+
+
+
+ de.kherud
+ llama
+ ${llama-java.version}
+
+
+
+
+ org.springdoc
+ springdoc-openapi-starter-webmvc-ui
+ ${springdoc.version}
+
+
+
+
+ org.mapstruct
+ mapstruct
+ ${mapstruct.version}
+
+
+
+ org.mapstruct
+ mapstruct-processor
+ ${mapstruct.version}
+ provided
+
+
+
+
+ io.micrometer
+ micrometer-core
+ ${micrometer.version}
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+
+ org.testcontainers
+ junit-jupiter
+ test
+
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+ 3.11.0
+
+ ${java.version}
+ ${java.version}
+
+
+ org.mapstruct
+ mapstruct-processor
+ ${mapstruct.version}
+
+
+
+
+
+
+
diff --git a/chat-api/src/main/java/com/pablotj/ai/chat/AiChatPlatformApplication.java b/chat-api/src/main/java/com/pablotj/ai/chat/AiChatPlatformApplication.java
new file mode 100644
index 0000000..3f70c70
--- /dev/null
+++ b/chat-api/src/main/java/com/pablotj/ai/chat/AiChatPlatformApplication.java
@@ -0,0 +1,27 @@
+package com.pablotj.ai.chat;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.scheduling.annotation.EnableAsync;
+import org.springframework.transaction.annotation.EnableTransactionManagement;
+
+/**
+ * AI Chat Platform - Enterprise Application Entry Point
+ *
+ * A sophisticated AI-powered chat platform built with hexagonal architecture,
+ * featuring offline AI capabilities, robust conversation management,
+ * and enterprise-grade scalability.
+ *
+ * @author Pablo TJ
+ * @version 1.0.0
+ * @since 2024
+ */
+@SpringBootApplication
+@EnableAsync
+@EnableTransactionManagement
+public class AiChatPlatformApplication {
+
+ public static void main(String[] args) {
+ SpringApplication.run(AiChatPlatformApplication.class, args);
+ }
+}
diff --git a/chat-api/src/main/java/com/pablotj/ai/chat/application/dto/ConversationDto.java b/chat-api/src/main/java/com/pablotj/ai/chat/application/dto/ConversationDto.java
new file mode 100644
index 0000000..ff41fdf
--- /dev/null
+++ b/chat-api/src/main/java/com/pablotj/ai/chat/application/dto/ConversationDto.java
@@ -0,0 +1,57 @@
+package com.pablotj.ai.chat.application.dto;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import java.time.Instant;
+import java.util.List;
+
+/**
+ * Data Transfer Object for complete conversation data.
+ */
+@Schema(description = "Complete conversation with messages")
+public record ConversationDto(
+
+ @Schema(description = "Unique conversation identifier", example = "123e4567-e89b-12d3-a456-426614174000")
+ @NotBlank
+ String conversationId,
+
+ @Schema(description = "Conversation title", example = "Discussion about AI")
+ @NotBlank
+ String title,
+
+ @Schema(description = "Optional conversation description", example = "A detailed conversation about artificial intelligence")
+ String description,
+
+ @Schema(description = "Conversation status", example = "ACTIVE")
+ @NotBlank
+ String status,
+
+ @Schema(description = "Conversation creation timestamp")
+ @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", timezone = "UTC")
+ @NotNull
+ Instant createdAt,
+
+ @Schema(description = "List of messages in the conversation")
+ @NotNull
+ List messages,
+
+ @Schema(description = "Total number of messages", example = "5")
+ int messageCount
+) {
+
+ public ConversationDto {
+ if (messages == null) {
+ messages = List.of();
+ }
+ }
+
+ public boolean isEmpty() {
+ return messages.isEmpty();
+ }
+
+ public boolean hasDescription() {
+ return description != null && !description.trim().isEmpty();
+ }
+}
diff --git a/chat-api/src/main/java/com/pablotj/ai/chat/application/dto/ConversationMessageDto.java b/chat-api/src/main/java/com/pablotj/ai/chat/application/dto/ConversationMessageDto.java
new file mode 100644
index 0000000..2809d30
--- /dev/null
+++ b/chat-api/src/main/java/com/pablotj/ai/chat/application/dto/ConversationMessageDto.java
@@ -0,0 +1,58 @@
+package com.pablotj.ai.chat.application.dto;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import java.time.Instant;
+import java.util.Map;
+
+/**
+ * Data Transfer Object for conversation messages.
+ */
+@Schema(description = "A message within a conversation")
+public record ConversationMessageDto(
+
+ @Schema(description = "Unique message identifier", example = "msg-123e4567-e89b-12d3-a456-426614174000")
+ @NotBlank
+ String messageId,
+
+ @Schema(description = "Conversation identifier this message belongs to", example = "123e4567-e89b-12d3-a456-426614174000")
+ @NotBlank
+ String conversationId,
+
+ @Schema(description = "Message role", example = "USER", allowableValues = {"USER", "ASSISTANT", "SYSTEM"})
+ @NotBlank
+ String role,
+
+ @Schema(description = "Message content text", example = "Hello, how can you help me today?")
+ @NotBlank
+ String content,
+
+ @Schema(description = "Message creation timestamp")
+ @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "dd/MM/yyyy HH:mm:ss", timezone = "Europe/Madrid")
+ @NotNull
+ Instant createdAt,
+
+ @Schema(description = "Additional message metadata")
+ Map metadata
+) {
+
+ public ConversationMessageDto {
+ if (metadata == null) {
+ metadata = Map.of();
+ }
+ }
+
+ public boolean isFromUser() {
+ return "USER".equals(role);
+ }
+
+ public boolean isFromAssistant() {
+ return "ASSISTANT".equals(role);
+ }
+
+ public boolean hasMetadata() {
+ return !metadata.isEmpty();
+ }
+}
diff --git a/chat-api/src/main/java/com/pablotj/ai/chat/application/dto/ConversationSummaryDto.java b/chat-api/src/main/java/com/pablotj/ai/chat/application/dto/ConversationSummaryDto.java
new file mode 100644
index 0000000..9925083
--- /dev/null
+++ b/chat-api/src/main/java/com/pablotj/ai/chat/application/dto/ConversationSummaryDto.java
@@ -0,0 +1,42 @@
+package com.pablotj.ai.chat.application.dto;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import java.time.Instant;
+
+/**
+ * Data Transfer Object for conversation summary information.
+ */
+@Schema(description = "Conversation summary for listing purposes")
+public record ConversationSummaryDto(
+
+ @Schema(description = "Unique conversation identifier", example = "123e4567-e89b-12d3-a456-426614174000")
+ @NotBlank
+ String conversationId,
+
+ @Schema(description = "Conversation title", example = "Discussion about AI")
+ @NotBlank
+ String title,
+
+ @Schema(description = "Optional conversation description", example = "A detailed conversation about artificial intelligence")
+ String description,
+
+ @Schema(description = "Conversation status", example = "ACTIVE")
+ @NotBlank
+ String status,
+
+ @Schema(description = "Conversation creation timestamp")
+ @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", timezone = "UTC")
+ @NotNull
+ Instant createdAt,
+
+ @Schema(description = "Total number of messages in the conversation", example = "5")
+ int messageCount
+) {
+
+ public boolean hasDescription() {
+ return description != null && !description.trim().isEmpty();
+ }
+}
diff --git a/chat-api/src/main/java/com/pablotj/ai/chat/application/mapper/ConversationMapper.java b/chat-api/src/main/java/com/pablotj/ai/chat/application/mapper/ConversationMapper.java
new file mode 100644
index 0000000..0681d4c
--- /dev/null
+++ b/chat-api/src/main/java/com/pablotj/ai/chat/application/mapper/ConversationMapper.java
@@ -0,0 +1,49 @@
+package com.pablotj.ai.chat.application.mapper;
+
+import com.pablotj.ai.chat.application.dto.ConversationDto;
+import com.pablotj.ai.chat.application.dto.ConversationMessageDto;
+import com.pablotj.ai.chat.application.dto.ConversationSummaryDto;
+import com.pablotj.ai.chat.domain.model.Conversation;
+import com.pablotj.ai.chat.domain.model.ConversationMessage;
+import org.mapstruct.Mapper;
+import org.mapstruct.Mapping;
+import org.mapstruct.Named;
+
+/**
+ * MapStruct mapper for converting between domain models and DTOs.
+ */
+@Mapper(componentModel = "spring")
+public interface ConversationMapper {
+
+ @Mapping(source = "conversationId.uuid", target = "conversationId")
+ @Mapping(source = "summary.title", target = "title")
+ @Mapping(source = "summary.description", target = "description")
+ @Mapping(source = "status", target = "status", qualifiedByName = "statusToString")
+ @Mapping(source = "messages", target = "messages")
+ @Mapping(expression = "java(conversation.getMessageCount())", target = "messageCount")
+ ConversationDto toDto(Conversation conversation);
+
+ @Mapping(source = "conversationId.uuid", target = "conversationId")
+ @Mapping(source = "summary.title", target = "title")
+ @Mapping(source = "summary.description", target = "description")
+ @Mapping(source = "status", target = "status", qualifiedByName = "statusToString")
+ @Mapping(expression = "java(conversation.getMessageCount())", target = "messageCount")
+ ConversationSummaryDto toSummaryDto(Conversation conversation);
+
+ @Mapping(source = "messageId.uuid", target = "messageId")
+ @Mapping(source = "conversationId.uuid", target = "conversationId")
+ @Mapping(source = "role", target = "role", qualifiedByName = "roleToString")
+ @Mapping(source = "content.text", target = "content")
+ @Mapping(source = "metadata.allProperties", target = "metadata")
+ ConversationMessageDto toMessageDto(ConversationMessage message);
+
+ @Named("statusToString")
+ default String statusToString(com.pablotj.ai.chat.domain.model.ConversationStatus status) {
+ return status != null ? status.name() : null;
+ }
+
+ @Named("roleToString")
+ default String roleToString(com.pablotj.ai.chat.domain.model.MessageRole role) {
+ return role != null ? role.name() : null;
+ }
+}
\ No newline at end of file
diff --git a/chat-api/src/main/java/com/pablotj/ai/chat/application/usecase/CreateConversationUseCase.java b/chat-api/src/main/java/com/pablotj/ai/chat/application/usecase/CreateConversationUseCase.java
new file mode 100644
index 0000000..9abf37e
--- /dev/null
+++ b/chat-api/src/main/java/com/pablotj/ai/chat/application/usecase/CreateConversationUseCase.java
@@ -0,0 +1,58 @@
+package com.pablotj.ai.chat.application.usecase;
+
+import com.pablotj.ai.chat.application.dto.ConversationDto;
+import com.pablotj.ai.chat.application.mapper.ConversationMapper;
+import com.pablotj.ai.chat.domain.model.Conversation;
+import com.pablotj.ai.chat.domain.model.ConversationSummary;
+import com.pablotj.ai.chat.domain.repository.ConversationRepository;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+/**
+ * Use case for creating new conversations.
+ * Handles the business logic for conversation creation with proper validation and persistence.
+ */
+@Service
+@Transactional
+public class CreateConversationUseCase {
+
+ private static final Logger logger = LoggerFactory.getLogger(CreateConversationUseCase.class);
+
+ private final ConversationRepository conversationRepository;
+ private final ConversationMapper conversationMapper;
+
+ public CreateConversationUseCase(
+ ConversationRepository conversationRepository,
+ ConversationMapper conversationMapper) {
+ this.conversationRepository = conversationRepository;
+ this.conversationMapper = conversationMapper;
+ }
+
+ /**
+ * Creates a new conversation with default settings.
+ *
+ * @return the created conversation DTO
+ */
+ public ConversationDto execute() {
+ return execute(ConversationSummary.defaultSummary());
+ }
+
+ /**
+ * Creates a new conversation with the specified summary.
+ *
+ * @param summary the conversation summary
+ * @return the created conversation DTO
+ */
+ public ConversationDto execute(ConversationSummary summary) {
+ logger.debug("Creating new conversation with summary: {}", summary);
+
+ Conversation conversation = Conversation.createNew(summary);
+ Conversation savedConversation = conversationRepository.save(conversation);
+
+ logger.info("Successfully created conversation with ID: {}", savedConversation.getConversationId());
+
+ return conversationMapper.toDto(savedConversation);
+ }
+}
diff --git a/chat-api/src/main/java/com/pablotj/ai/chat/application/usecase/GetConversationHistoryUseCase.java b/chat-api/src/main/java/com/pablotj/ai/chat/application/usecase/GetConversationHistoryUseCase.java
new file mode 100644
index 0000000..37a4701
--- /dev/null
+++ b/chat-api/src/main/java/com/pablotj/ai/chat/application/usecase/GetConversationHistoryUseCase.java
@@ -0,0 +1,50 @@
+package com.pablotj.ai.chat.application.usecase;
+
+import com.pablotj.ai.chat.application.dto.ConversationSummaryDto;
+import com.pablotj.ai.chat.application.mapper.ConversationMapper;
+import com.pablotj.ai.chat.domain.repository.ConversationRepository;
+import java.util.List;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+/**
+ * Use case for retrieving conversation history.
+ * Provides read-only access to conversation summaries for listing purposes.
+ */
+@Service
+@Transactional(readOnly = true)
+public class GetConversationHistoryUseCase {
+
+ private static final Logger logger = LoggerFactory.getLogger(GetConversationHistoryUseCase.class);
+
+ private final ConversationRepository conversationRepository;
+ private final ConversationMapper conversationMapper;
+
+ public GetConversationHistoryUseCase(
+ ConversationRepository conversationRepository,
+ ConversationMapper conversationMapper) {
+ this.conversationRepository = conversationRepository;
+ this.conversationMapper = conversationMapper;
+ }
+
+ /**
+ * Retrieves all active conversations ordered by creation date.
+ *
+ * @return list of conversation summary DTOs
+ */
+ public List execute() {
+ logger.debug("Retrieving conversation history");
+
+ List conversations = conversationRepository
+ .findAllActiveOrderedByCreationDate()
+ .stream()
+ .map(conversationMapper::toSummaryDto)
+ .toList();
+
+ logger.debug("Retrieved {} conversations", conversations.size());
+
+ return conversations;
+ }
+}
diff --git a/chat-api/src/main/java/com/pablotj/ai/chat/application/usecase/GetConversationMessagesUseCase.java b/chat-api/src/main/java/com/pablotj/ai/chat/application/usecase/GetConversationMessagesUseCase.java
new file mode 100644
index 0000000..4ac928b
--- /dev/null
+++ b/chat-api/src/main/java/com/pablotj/ai/chat/application/usecase/GetConversationMessagesUseCase.java
@@ -0,0 +1,59 @@
+package com.pablotj.ai.chat.application.usecase;
+
+import com.pablotj.ai.chat.application.dto.ConversationMessageDto;
+import com.pablotj.ai.chat.application.mapper.ConversationMapper;
+import com.pablotj.ai.chat.domain.exception.ConversationNotFoundException;
+import com.pablotj.ai.chat.domain.model.Conversation;
+import com.pablotj.ai.chat.domain.model.ConversationId;
+import com.pablotj.ai.chat.domain.repository.ConversationRepository;
+import java.util.List;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+/**
+ * Use case for retrieving messages from a specific conversation.
+ */
+@Service
+@Transactional(readOnly = true)
+public class GetConversationMessagesUseCase {
+
+ private static final Logger logger = LoggerFactory.getLogger(GetConversationMessagesUseCase.class);
+
+ private final ConversationRepository conversationRepository;
+ private final ConversationMapper conversationMapper;
+
+ public GetConversationMessagesUseCase(
+ ConversationRepository conversationRepository,
+ ConversationMapper conversationMapper) {
+ this.conversationRepository = conversationRepository;
+ this.conversationMapper = conversationMapper;
+ }
+
+ /**
+ * Retrieves all messages from the specified conversation.
+ *
+ * @param conversationIdValue the conversation ID as string
+ * @return list of conversation message DTOs
+ * @throws ConversationNotFoundException if the conversation doesn't exist
+ */
+ public List execute(String conversationIdValue) {
+ ConversationId conversationId = ConversationId.of(conversationIdValue);
+
+ logger.debug("Retrieving messages for conversation: {}", conversationId);
+
+ Conversation conversation = conversationRepository
+ .findById(conversationId)
+ .orElseThrow(() -> new ConversationNotFoundException(conversationId));
+
+ List messages = conversation.getMessages()
+ .stream()
+ .map(conversationMapper::toMessageDto)
+ .toList();
+
+ logger.debug("Retrieved {} messages for conversation: {}", messages.size(), conversationId);
+
+ return messages;
+ }
+}
diff --git a/chat-api/src/main/java/com/pablotj/ai/chat/application/usecase/ProcessUserMessageUseCase.java b/chat-api/src/main/java/com/pablotj/ai/chat/application/usecase/ProcessUserMessageUseCase.java
new file mode 100644
index 0000000..8fa1c1d
--- /dev/null
+++ b/chat-api/src/main/java/com/pablotj/ai/chat/application/usecase/ProcessUserMessageUseCase.java
@@ -0,0 +1,93 @@
+package com.pablotj.ai.chat.application.usecase;
+
+import com.pablotj.ai.chat.application.dto.ConversationMessageDto;
+import com.pablotj.ai.chat.application.mapper.ConversationMapper;
+import com.pablotj.ai.chat.domain.exception.AiServiceUnavailableException;
+import com.pablotj.ai.chat.domain.exception.ConversationNotFoundException;
+import com.pablotj.ai.chat.domain.model.Conversation;
+import com.pablotj.ai.chat.domain.model.ConversationId;
+import com.pablotj.ai.chat.domain.model.ConversationMessage;
+import com.pablotj.ai.chat.domain.model.ConversationSummary;
+import com.pablotj.ai.chat.domain.model.MessageContent;
+import com.pablotj.ai.chat.domain.repository.ConversationRepository;
+import com.pablotj.ai.chat.domain.service.AiConversationService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+/**
+ * Use case for processing user messages and generating AI responses.
+ * Orchestrates the complete conversation flow including AI response generation.
+ */
+@Service
+@Transactional
+public class ProcessUserMessageUseCase {
+
+ private static final Logger logger = LoggerFactory.getLogger(ProcessUserMessageUseCase.class);
+
+ private final ConversationRepository conversationRepository;
+ private final AiConversationService aiConversationService;
+ private final ConversationMapper conversationMapper;
+
+ public ProcessUserMessageUseCase(
+ ConversationRepository conversationRepository,
+ AiConversationService aiConversationService,
+ ConversationMapper conversationMapper) {
+ this.conversationRepository = conversationRepository;
+ this.aiConversationService = aiConversationService;
+ this.conversationMapper = conversationMapper;
+ }
+
+ /**
+ * Processes a user message and generates an AI response.
+ *
+ * @param conversationIdValue the conversation ID as string
+ * @param userMessageText the user's message text
+ * @return the AI response message DTO
+ * @throws ConversationNotFoundException if the conversation doesn't exist
+ * @throws AiServiceUnavailableException if the AI service is unavailable
+ */
+ public ConversationMessageDto execute(String conversationIdValue, String userMessageText) {
+ ConversationId conversationId = ConversationId.of(conversationIdValue);
+ MessageContent userMessageContent = MessageContent.of(userMessageText);
+
+ logger.debug("Processing user message for conversation: {}", conversationId);
+
+ // Retrieve conversation
+ Conversation conversation = conversationRepository
+ .findById(conversationId)
+ .orElseThrow(() -> new ConversationNotFoundException(conversationId));
+
+ // Create user message
+ ConversationMessage userMessage = ConversationMessage.createUserMessage(
+ conversation.getConversationId(), userMessageContent);
+
+ // Add user message to conversation
+ conversation = conversation.addMessage(userMessage);
+
+ // Update conversation summary if it's the first message
+ if (conversation.getMessageCount() == 1) {
+ ConversationSummary newSummary = ConversationSummary.fromFirstMessage(userMessageContent);
+ conversation = conversation.updateSummary(newSummary);
+ }
+
+ // Generate AI response
+ MessageContent aiResponseContent = aiConversationService.generateResponse(
+ conversation.getMessages(), userMessage);
+
+ ConversationMessage aiMessage = ConversationMessage.createAssistantMessage(
+ conversation.getConversationId(), aiResponseContent);
+
+ // Add AI message to conversation
+ conversation = conversation.addMessage(aiMessage);
+
+ // Save updated conversation
+ conversationRepository.save(conversation);
+
+ logger.info("Successfully processed message and generated AI response for conversation: {}",
+ conversation.getConversationId());
+
+ return conversationMapper.toMessageDto(aiMessage);
+ }
+}
diff --git a/chat-api/src/main/java/com/pablotj/ai/chat/domain/exception/AiServiceUnavailableException.java b/chat-api/src/main/java/com/pablotj/ai/chat/domain/exception/AiServiceUnavailableException.java
new file mode 100644
index 0000000..5f18d58
--- /dev/null
+++ b/chat-api/src/main/java/com/pablotj/ai/chat/domain/exception/AiServiceUnavailableException.java
@@ -0,0 +1,15 @@
+package com.pablotj.ai.chat.domain.exception;
+
+/**
+ * Exception thrown when the AI service is unavailable or encounters an error.
+ */
+public class AiServiceUnavailableException extends DomainException {
+
+ public AiServiceUnavailableException(String message) {
+ super(message);
+ }
+
+ public AiServiceUnavailableException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/chat-api/src/main/java/com/pablotj/ai/chat/domain/exception/ConversationNotFoundException.java b/chat-api/src/main/java/com/pablotj/ai/chat/domain/exception/ConversationNotFoundException.java
new file mode 100644
index 0000000..43144a1
--- /dev/null
+++ b/chat-api/src/main/java/com/pablotj/ai/chat/domain/exception/ConversationNotFoundException.java
@@ -0,0 +1,20 @@
+package com.pablotj.ai.chat.domain.exception;
+
+import com.pablotj.ai.chat.domain.model.ConversationId;
+
+/**
+ * Exception thrown when a requested conversation cannot be found.
+ */
+public class ConversationNotFoundException extends DomainException {
+
+ private final ConversationId conversationId;
+
+ public ConversationNotFoundException(ConversationId conversationId) {
+ super(String.format("Conversation not found with ID: %s", conversationId.getUuid()));
+ this.conversationId = conversationId;
+ }
+
+ public ConversationId getConversationId() {
+ return conversationId;
+ }
+}
diff --git a/chat-api/src/main/java/com/pablotj/ai/chat/domain/exception/DomainException.java b/chat-api/src/main/java/com/pablotj/ai/chat/domain/exception/DomainException.java
new file mode 100644
index 0000000..00a7de3
--- /dev/null
+++ b/chat-api/src/main/java/com/pablotj/ai/chat/domain/exception/DomainException.java
@@ -0,0 +1,15 @@
+package com.pablotj.ai.chat.domain.exception;
+
+/**
+ * Base exception class for domain-specific exceptions.
+ */
+public abstract class DomainException extends RuntimeException {
+
+ protected DomainException(String message) {
+ super(message);
+ }
+
+ protected DomainException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/chat-api/src/main/java/com/pablotj/ai/chat/domain/model/Conversation.java b/chat-api/src/main/java/com/pablotj/ai/chat/domain/model/Conversation.java
new file mode 100644
index 0000000..de6653b
--- /dev/null
+++ b/chat-api/src/main/java/com/pablotj/ai/chat/domain/model/Conversation.java
@@ -0,0 +1,197 @@
+package com.pablotj.ai.chat.domain.model;
+
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+
+/**
+ * Aggregate root representing a conversation with its messages.
+ * Encapsulates conversation business logic and invariants.
+ */
+public final class Conversation {
+
+ private static final int MAX_MESSAGES_PER_CONVERSATION = 1000;
+
+ private final ConversationId conversationId;
+ private final ConversationSummary summary;
+ private final List messages;
+ private final Instant createdAt;
+ private final ConversationStatus status;
+
+ private Conversation(Builder builder) {
+ this.conversationId = Objects.requireNonNull(builder.conversationId, "Conversation ID is required");
+ this.summary = Objects.requireNonNull(builder.summary, "Conversation summary is required");
+ this.messages = new ArrayList<>(builder.messages);
+ this.createdAt = Objects.requireNonNull(builder.createdAt, "Created timestamp is required");
+ this.status = Objects.requireNonNull(builder.status, "Conversation status is required");
+
+ validateInvariants();
+ }
+
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ public static Conversation createNew(ConversationSummary summary) {
+ return builder()
+ .conversationId(ConversationId.generate())
+ .summary(summary)
+ .messages(Collections.emptyList())
+ .createdAt(Instant.now())
+ .status(ConversationStatus.ACTIVE)
+ .build();
+ }
+
+ private void validateInvariants() {
+ if (messages.size() > MAX_MESSAGES_PER_CONVERSATION) {
+ throw new IllegalStateException(
+ String.format("Conversation cannot have more than %d messages", MAX_MESSAGES_PER_CONVERSATION)
+ );
+ }
+ }
+
+ // Getters
+ public ConversationId getConversationId() {
+ return conversationId;
+ }
+
+ public ConversationSummary getSummary() {
+ return summary;
+ }
+
+ public List getMessages() {
+ return Collections.unmodifiableList(messages);
+ }
+
+ public Instant getCreatedAt() {
+ return createdAt;
+ }
+
+ public ConversationStatus getStatus() {
+ return status;
+ }
+
+ // Domain behavior
+ public Conversation addMessage(ConversationMessage message) {
+ if (!message.getConversationId().equals(this.conversationId)) {
+ throw new IllegalArgumentException("Message does not belong to this conversation");
+ }
+
+ List newMessages = new ArrayList<>(this.messages);
+ newMessages.add(message);
+
+ return builder()
+ .conversationId(this.conversationId)
+ .summary(this.summary)
+ .messages(newMessages)
+ .createdAt(this.createdAt)
+ .status(this.status)
+ .build();
+ }
+
+ public Conversation updateSummary(ConversationSummary newSummary) {
+ return builder()
+ .conversationId(this.conversationId)
+ .summary(newSummary)
+ .messages(this.messages)
+ .createdAt(this.createdAt)
+ .status(this.status)
+ .build();
+ }
+
+ public Conversation changeStatus(ConversationStatus newStatus) {
+ return builder()
+ .conversationId(this.conversationId)
+ .summary(this.summary)
+ .messages(this.messages)
+ .createdAt(this.createdAt)
+ .status(newStatus)
+ .build();
+ }
+
+ public Optional getLastMessage() {
+ return messages.isEmpty() ?
+ Optional.empty() :
+ Optional.of(messages.get(messages.size() - 1));
+ }
+
+ public List getMessagesByRole(MessageRole role) {
+ return messages.stream()
+ .filter(message -> message.getRole() == role)
+ .toList();
+ }
+
+ public int getMessageCount() {
+ return messages.size();
+ }
+
+ public boolean isEmpty() {
+ return messages.isEmpty();
+ }
+
+ public boolean isActive() {
+ return status == ConversationStatus.ACTIVE;
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (this == obj) return true;
+ if (obj == null || getClass() != obj.getClass()) return false;
+ Conversation that = (Conversation) obj;
+ return Objects.equals(conversationId, that.conversationId);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(conversationId);
+ }
+
+ @Override
+ public String toString() {
+ return String.format("Conversation{id=%s, summary='%s', messages=%d, status=%s}",
+ conversationId, summary, messages.size(), status);
+ }
+
+ public static final class Builder {
+ private ConversationId conversationId;
+ private ConversationSummary summary;
+ private List messages = new ArrayList<>();
+ private Instant createdAt;
+ private ConversationStatus status;
+
+ private Builder() {
+ }
+
+ public Builder conversationId(ConversationId conversationId) {
+ this.conversationId = conversationId;
+ return this;
+ }
+
+ public Builder summary(ConversationSummary summary) {
+ this.summary = summary;
+ return this;
+ }
+
+ public Builder messages(List messages) {
+ this.messages = messages != null ? new ArrayList<>(messages) : new ArrayList<>();
+ return this;
+ }
+
+ public Builder createdAt(Instant createdAt) {
+ this.createdAt = createdAt;
+ return this;
+ }
+
+ public Builder status(ConversationStatus status) {
+ this.status = status;
+ return this;
+ }
+
+ public Conversation build() {
+ return new Conversation(this);
+ }
+ }
+}
diff --git a/chat-api/src/main/java/com/pablotj/ai/chat/domain/model/ConversationId.java b/chat-api/src/main/java/com/pablotj/ai/chat/domain/model/ConversationId.java
new file mode 100644
index 0000000..ddbd5d1
--- /dev/null
+++ b/chat-api/src/main/java/com/pablotj/ai/chat/domain/model/ConversationId.java
@@ -0,0 +1,69 @@
+package com.pablotj.ai.chat.domain.model;
+
+import java.util.Objects;
+import java.util.UUID;
+
+/**
+ * Value Object representing a unique conversation identifier.
+ * Ensures type safety and encapsulates conversation ID logic.
+ */
+public final class ConversationId {
+
+ private final String uuid;
+ private Long id;
+
+ public ConversationId() {
+ this(UUID.randomUUID().toString());
+ }
+
+ public ConversationId(Long id, String uuid) {
+ this.id = Objects.requireNonNull(id, "Conversation ID cannot be null");
+ this.uuid = Objects.requireNonNull(uuid, "Conversation UUID cannot be null");
+ }
+
+ private ConversationId(String uuid) {
+ this.id = null;
+ this.uuid = Objects.requireNonNull(uuid, "Conversation UUID cannot be null");
+ }
+
+ public static ConversationId generate() {
+ return new ConversationId(UUID.randomUUID().toString());
+ }
+
+ public static ConversationId of(String value) {
+ if (value == null || value.trim().isEmpty()) {
+ throw new IllegalArgumentException("Conversation UUID cannot be null or empty");
+ }
+ return new ConversationId(value.trim());
+ }
+
+ public Long getId() {
+ return id;
+ }
+
+ public void setId(Long id) {
+ this.id = id;
+ }
+
+ public String getUuid() {
+ return uuid;
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (this == obj) return true;
+ if (obj == null || getClass() != obj.getClass()) return false;
+ ConversationId that = (ConversationId) obj;
+ return Objects.equals(uuid, that.uuid);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(uuid);
+ }
+
+ @Override
+ public String toString() {
+ return uuid;
+ }
+}
diff --git a/chat-api/src/main/java/com/pablotj/ai/chat/domain/model/ConversationMessage.java b/chat-api/src/main/java/com/pablotj/ai/chat/domain/model/ConversationMessage.java
new file mode 100644
index 0000000..a029626
--- /dev/null
+++ b/chat-api/src/main/java/com/pablotj/ai/chat/domain/model/ConversationMessage.java
@@ -0,0 +1,165 @@
+package com.pablotj.ai.chat.domain.model;
+
+import java.time.Instant;
+import java.util.Objects;
+
+/**
+ * Domain entity representing a message within a conversation.
+ * Encapsulates message data with rich domain behavior.
+ */
+public final class ConversationMessage {
+
+ private final MessageId messageId;
+ private final ConversationId conversationId;
+ private final MessageRole role;
+ private final MessageContent content;
+ private final Instant createdAt;
+ private final MessageMetadata metadata;
+
+ private ConversationMessage(Builder builder) {
+ this.messageId = Objects.requireNonNull(builder.messageId, "Message ID is required");
+ this.conversationId = Objects.requireNonNull(builder.conversationId, "Conversation ID is required");
+ this.role = Objects.requireNonNull(builder.role, "Message role is required");
+ this.content = Objects.requireNonNull(builder.content, "Message content is required");
+ this.createdAt = Objects.requireNonNull(builder.createdAt, "Created timestamp is required");
+ this.metadata = builder.metadata != null ? builder.metadata : MessageMetadata.empty();
+ }
+
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ public static ConversationMessage createUserMessage(
+ ConversationId conversationId,
+ MessageContent content) {
+ return builder()
+ .messageId(MessageId.generate())
+ .conversationId(conversationId)
+ .role(MessageRole.USER)
+ .content(content)
+ .createdAt(Instant.now())
+ .build();
+ }
+
+ public static ConversationMessage createAssistantMessage(
+ ConversationId conversationId,
+ MessageContent content) {
+ return builder()
+ .messageId(MessageId.generate())
+ .conversationId(conversationId)
+ .role(MessageRole.ASSISTANT)
+ .content(content)
+ .createdAt(Instant.now())
+ .build();
+ }
+
+ // Getters
+ public MessageId getMessageId() {
+ return messageId;
+ }
+
+ public ConversationId getConversationId() {
+ return conversationId;
+ }
+
+ public MessageRole getRole() {
+ return role;
+ }
+
+ public MessageContent getContent() {
+ return content;
+ }
+
+ public Instant getCreatedAt() {
+ return createdAt;
+ }
+
+ public MessageMetadata getMetadata() {
+ return metadata;
+ }
+
+ // Domain behavior
+ public boolean isFromUser() {
+ return role.isUser();
+ }
+
+ public boolean isFromAssistant() {
+ return role.isAssistant();
+ }
+
+ public ConversationMessage withMetadata(MessageMetadata metadata) {
+ return builder()
+ .messageId(this.messageId)
+ .conversationId(this.conversationId)
+ .role(this.role)
+ .content(this.content)
+ .createdAt(this.createdAt)
+ .metadata(metadata)
+ .build();
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (this == obj) return true;
+ if (obj == null || getClass() != obj.getClass()) return false;
+ ConversationMessage that = (ConversationMessage) obj;
+ return Objects.equals(messageId, that.messageId);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(messageId);
+ }
+
+ @Override
+ public String toString() {
+ return String.format("ConversationMessage{id=%s, role=%s, content='%s'}",
+ messageId, role, content.truncate(50));
+ }
+
+ public static final class Builder {
+ private MessageId messageId;
+ private ConversationId conversationId;
+ private MessageRole role;
+ private MessageContent content;
+ private Instant createdAt;
+ private MessageMetadata metadata;
+
+ private Builder() {
+ }
+
+ public Builder messageId(MessageId messageId) {
+ this.messageId = messageId;
+ return this;
+ }
+
+ public Builder conversationId(ConversationId conversationId) {
+ this.conversationId = conversationId;
+ return this;
+ }
+
+ public Builder role(MessageRole role) {
+ this.role = role;
+ return this;
+ }
+
+ public Builder content(MessageContent content) {
+ this.content = content;
+ return this;
+ }
+
+ public Builder createdAt(Instant createdAt) {
+ this.createdAt = createdAt;
+ return this;
+ }
+
+ public Builder metadata(MessageMetadata metadata) {
+ this.metadata = metadata;
+ return this;
+ }
+
+ public ConversationMessage build() {
+ return new ConversationMessage(this);
+ }
+ }
+}
diff --git a/chat-api/src/main/java/com/pablotj/ai/chat/domain/model/ConversationStatus.java b/chat-api/src/main/java/com/pablotj/ai/chat/domain/model/ConversationStatus.java
new file mode 100644
index 0000000..aba7bae
--- /dev/null
+++ b/chat-api/src/main/java/com/pablotj/ai/chat/domain/model/ConversationStatus.java
@@ -0,0 +1,53 @@
+package com.pablotj.ai.chat.domain.model;
+
+/**
+ * Enumeration representing the status of a conversation.
+ */
+public enum ConversationStatus {
+
+ ACTIVE("active", "Conversation is active and accepting messages"),
+ ARCHIVED("archived", "Conversation has been archived"),
+ DELETED("deleted", "Conversation has been marked for deletion"),
+ SUSPENDED("suspended", "Conversation has been temporarily suspended");
+
+ private final String code;
+ private final String description;
+
+ ConversationStatus(String code, String description) {
+ this.code = code;
+ this.description = description;
+ }
+
+ public static ConversationStatus fromCode(String code) {
+ for (ConversationStatus status : values()) {
+ if (status.code.equals(code)) {
+ return status;
+ }
+ }
+ throw new IllegalArgumentException("Unknown conversation status code: " + code);
+ }
+
+ public String getCode() {
+ return code;
+ }
+
+ public String getDescription() {
+ return description;
+ }
+
+ public boolean isActive() {
+ return this == ACTIVE;
+ }
+
+ public boolean isArchived() {
+ return this == ARCHIVED;
+ }
+
+ public boolean isDeleted() {
+ return this == DELETED;
+ }
+
+ public boolean canReceiveMessages() {
+ return this == ACTIVE;
+ }
+}
diff --git a/chat-api/src/main/java/com/pablotj/ai/chat/domain/model/ConversationSummary.java b/chat-api/src/main/java/com/pablotj/ai/chat/domain/model/ConversationSummary.java
new file mode 100644
index 0000000..6e836f9
--- /dev/null
+++ b/chat-api/src/main/java/com/pablotj/ai/chat/domain/model/ConversationSummary.java
@@ -0,0 +1,140 @@
+package com.pablotj.ai.chat.domain.model;
+
+import java.util.Objects;
+
+/**
+ * Value Object representing a conversation summary.
+ */
+public final class ConversationSummary {
+
+ private static final int MAX_TITLE_LENGTH = 100;
+ private static final int MAX_DESCRIPTION_LENGTH = 500;
+ private static final String DEFAULT_TITLE = "New Conversation";
+
+ private final String title;
+ private final String description;
+
+ public ConversationSummary(String title) {
+ this.title = title;
+ this.description = "";
+ }
+
+ private ConversationSummary(String title, String description) {
+ this.title = validateAndNormalizeTitle(title);
+ this.description = validateAndNormalizeDescription(description);
+ }
+
+ public static ConversationSummary of(String title, String description) {
+ return new ConversationSummary(title, description);
+ }
+
+ public static ConversationSummary defaultSummary() {
+ return new ConversationSummary(DEFAULT_TITLE, null);
+ }
+
+ public static ConversationSummary fromFirstMessage(MessageContent firstMessage) {
+ String title = generateTitleFromContent(firstMessage.getText());
+ return new ConversationSummary(title, null);
+ }
+
+ private static String generateTitleFromContent(String content) {
+ if (content == null || content.trim().isEmpty()) {
+ return DEFAULT_TITLE;
+ }
+
+ String normalized = content.trim();
+ if (normalized.length() <= 50) {
+ return normalized;
+ }
+
+ // Find a good breaking point (space, punctuation)
+ int breakPoint = findBreakPoint(normalized, 50);
+ return normalized.substring(0, breakPoint) + "...";
+ }
+
+ private static int findBreakPoint(String text, int maxLength) {
+ if (text.length() <= maxLength) {
+ return text.length();
+ }
+
+ // Look for space or punctuation near the max length
+ for (int i = maxLength; i > maxLength - 20 && i > 0; i--) {
+ char c = text.charAt(i);
+ if (Character.isWhitespace(c) || c == '.' || c == ',' || c == ';' || c == '!') {
+ return i;
+ }
+ }
+
+ return maxLength;
+ }
+
+ private String validateAndNormalizeTitle(String title) {
+ if (title == null || title.trim().isEmpty()) {
+ return DEFAULT_TITLE;
+ }
+
+ String normalized = title.trim();
+ if (normalized.length() > MAX_TITLE_LENGTH) {
+ normalized = normalized.substring(0, MAX_TITLE_LENGTH - 3) + "...";
+ }
+
+ return normalized;
+ }
+
+ private String validateAndNormalizeDescription(String description) {
+ if (description == null) {
+ return null;
+ }
+
+ String normalized = description.trim();
+ if (normalized.isEmpty()) {
+ return null;
+ }
+
+ if (normalized.length() > MAX_DESCRIPTION_LENGTH) {
+ normalized = normalized.substring(0, MAX_DESCRIPTION_LENGTH - 3) + "...";
+ }
+
+ return normalized;
+ }
+
+ public String getTitle() {
+ return title;
+ }
+
+ public String getDescription() {
+ return description;
+ }
+
+ public boolean hasDescription() {
+ return description != null && !description.isEmpty();
+ }
+
+ public ConversationSummary withDescription(String newDescription) {
+ return new ConversationSummary(this.title, newDescription);
+ }
+
+ public ConversationSummary withTitle(String newTitle) {
+ return new ConversationSummary(newTitle, this.description);
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (this == obj) return true;
+ if (obj == null || getClass() != obj.getClass()) return false;
+ ConversationSummary that = (ConversationSummary) obj;
+ return Objects.equals(title, that.title) && Objects.equals(description, that.description);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(title, description);
+ }
+
+ @Override
+ public String toString() {
+ return hasDescription() ?
+ String.format("%s - %s", title, description) :
+ title;
+ }
+}
diff --git a/chat-api/src/main/java/com/pablotj/ai/chat/domain/model/MessageContent.java b/chat-api/src/main/java/com/pablotj/ai/chat/domain/model/MessageContent.java
new file mode 100644
index 0000000..d1adbd8
--- /dev/null
+++ b/chat-api/src/main/java/com/pablotj/ai/chat/domain/model/MessageContent.java
@@ -0,0 +1,79 @@
+package com.pablotj.ai.chat.domain.model;
+
+import java.util.Objects;
+
+/**
+ * Value Object representing message content with validation and formatting.
+ */
+public final class MessageContent {
+
+ private static final int MAX_LENGTH = 10000;
+ private static final int MIN_LENGTH = 1;
+
+ private final String text;
+
+ private MessageContent(String text) {
+ this.text = validateAndNormalize(text);
+ }
+
+ public static MessageContent of(String text) {
+ return new MessageContent(text);
+ }
+
+ private String validateAndNormalize(String text) {
+ if (text == null) {
+ throw new IllegalArgumentException("Message content cannot be null");
+ }
+
+ String normalized = text.trim();
+
+ if (normalized.length() < MIN_LENGTH) {
+ throw new IllegalArgumentException("Message content cannot be empty");
+ }
+
+ if (normalized.length() > MAX_LENGTH) {
+ throw new IllegalArgumentException(
+ String.format("Message content cannot exceed %d characters", MAX_LENGTH)
+ );
+ }
+
+ return normalized;
+ }
+
+ public String getText() {
+ return text;
+ }
+
+ public int getLength() {
+ return text.length();
+ }
+
+ public boolean isEmpty() {
+ return text.isEmpty();
+ }
+
+ public MessageContent truncate(int maxLength) {
+ if (text.length() <= maxLength) {
+ return this;
+ }
+ return new MessageContent(text.substring(0, maxLength) + "...");
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (this == obj) return true;
+ if (obj == null || getClass() != obj.getClass()) return false;
+ MessageContent that = (MessageContent) obj;
+ return Objects.equals(text, that.text);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(text);
+ }
+
+ @Override
+ public String toString() {
+ return text;
+ }
+}
diff --git a/chat-api/src/main/java/com/pablotj/ai/chat/domain/model/MessageId.java b/chat-api/src/main/java/com/pablotj/ai/chat/domain/model/MessageId.java
new file mode 100644
index 0000000..3064866
--- /dev/null
+++ b/chat-api/src/main/java/com/pablotj/ai/chat/domain/model/MessageId.java
@@ -0,0 +1,63 @@
+package com.pablotj.ai.chat.domain.model;
+
+import java.util.Objects;
+import java.util.UUID;
+
+/**
+ * Value Object representing a unique message identifier.
+ */
+public final class MessageId {
+
+ private final String uuid;
+ private Long id;
+
+ public MessageId(Long id, String uuid) {
+ this.id = Objects.requireNonNull(id, "Message ID cannot be null");
+ this.uuid = Objects.requireNonNull(uuid, "Message UUID cannot be null");
+ }
+
+ private MessageId(String uuid) {
+ this.uuid = Objects.requireNonNull(uuid, "Message UUID cannot be null");
+ }
+
+ public static MessageId generate() {
+ return new MessageId(UUID.randomUUID().toString());
+ }
+
+ public static MessageId of(String value) {
+ if (value == null || value.trim().isEmpty()) {
+ throw new IllegalArgumentException("Message UUID cannot be null or empty");
+ }
+ return new MessageId(value.trim());
+ }
+
+ public Long getId() {
+ return id;
+ }
+
+ public void setId(Long id) {
+ this.id = id;
+ }
+
+ public String getUuid() {
+ return uuid;
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (this == obj) return true;
+ if (obj == null || getClass() != obj.getClass()) return false;
+ MessageId messageId = (MessageId) obj;
+ return Objects.equals(uuid, messageId.uuid);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(uuid);
+ }
+
+ @Override
+ public String toString() {
+ return uuid;
+ }
+}
diff --git a/chat-api/src/main/java/com/pablotj/ai/chat/domain/model/MessageMetadata.java b/chat-api/src/main/java/com/pablotj/ai/chat/domain/model/MessageMetadata.java
new file mode 100644
index 0000000..d2ebcb3
--- /dev/null
+++ b/chat-api/src/main/java/com/pablotj/ai/chat/domain/model/MessageMetadata.java
@@ -0,0 +1,94 @@
+package com.pablotj.ai.chat.domain.model;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+
+/**
+ * Value Object containing metadata for messages.
+ */
+public final class MessageMetadata {
+
+ private final Map properties;
+
+ private MessageMetadata(Map properties) {
+ this.properties = Collections.unmodifiableMap(new HashMap<>(properties));
+ }
+
+ public static MessageMetadata empty() {
+ return new MessageMetadata(Collections.emptyMap());
+ }
+
+ public static MessageMetadata of(Map properties) {
+ return new MessageMetadata(properties != null ? properties : Collections.emptyMap());
+ }
+
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ public Optional