Construir un simulador de correos electrónicos en Kotlin es la mejor forma de poner en práctica clases, enums, data classes e interfaces en un proyecto real. Aquí aprenderás a modelar usuarios, correos y carpetas, y a definir repositorios en memoria que gestionen toda la información del inbox.
El proyecto se divide en cuatro fases: modelos, repositorios, lógica de negocio y menú en consola. En esta primera entrega te enfocas en sentar las bases con un enfoque de Domain-Driven Design, donde el código refleja la realidad del negocio que estás simulando.
¿Qué modelos necesitas para simular un servicio de correos?
Todo proveedor de correo organiza los mensajes en carpetas y diferencia entre usuarios. Por eso, los primeros modelos que defines son los que representan esas entidades del mundo real.
¿Cómo modelar las carpetas con una enum class?
Las carpetas de un correo son un conjunto cerrado de valores: inbox, sent, archive y spam. Para representarlas usas una enum class llamada Folder, que segmenta los tipos de carpetas que puede tener el proveedor [02:15].
Este tipo de clase es ideal cuando los valores son finitos y conocidos de antemano, lo que evita errores por usar strings sueltos en el código.
¿Cómo crear la data class de usuario con autenticación?
La data class User representa a quien se registra y se loguea en el simulador. Cada usuario tiene un id inicializado por defecto con UUID.randomUUID(), un username de tipo string y un password declarado como private var [03:40].
Dentro de la misma data class defines una función authenticate que recibe un string y devuelve un boolean comparando si la contraseña proporcionada coincide con la almacenada. Esa función puede escribirse como single line function aprovechando la inferencia de tipos de Kotlin.
¿Qué es una single line function en Kotlin? Es una función que reemplaza el bloque { return ... } por un signo = seguido de la expresión. Kotlin infiere el tipo de retorno automáticamente.
¿Cómo estructurar la data class email?
La data class Email es más completa porque debe registrar quién lo creó, a quién va dirigido y dónde está ubicado. Sus campos son:
id: un UUID generado con UUID.randomUUID().
ownerId: el UUID del usuario que creó el correo, lo que crea una relación con User.id.
from: el username o correo de quien envía.
to: el destinatario del mensaje.
subject: el asunto del correo.
body: el cuerpo del mensaje como string.
folder: variable var de tipo Folder con valor por defecto Folder.INBOX.
isRead: boolean que arranca en false por defecto [06:50].
La clave aquí es que folder sea var, porque un correo se mueve entre carpetas, mientras que ownerId y from se mantienen inmutables porque identifican al creador.
¿Cómo defines los contratos de los repositorios?
Un repositorio se encarga de crear, leer, actualizar y borrar datos de un tipo de objeto. Lo ideal es separar un repositorio por entidad, cada uno con su propia interfaz, para mantener responsabilidades claras [08:30].
¿Qué métodos lleva el UserRepository?
La interfaz UserRepository se mantiene simple y declara dos funciones:
save(user: User): Boolean para guardar un usuario y devolver si la operación fue exitosa.
findByUsername(username: String): User? que retorna el usuario o null si no existe.
En proyectos productivos las respuestas se modelan con clases tipo Result con casos success y error, pero un boolean es suficiente para este escenario educativo.
¿Qué métodos lleva el EmailRepository?
La interfaz EmailRepository necesita más operaciones porque los correos tienen ciclos de vida más complejos:
save(email: Email) para guardar un correo nuevo.
findByOwner(ownerId: UUID): List<Email> que devuelve todos los correos de un usuario.
findById(id: UUID): Email? para localizar un correo específico al marcarlo como leído o moverlo de carpeta.
delete(id: UUID) para eliminar un correo por su identificador [11:20].
¿Por qué usar UUID en vez de String para los IDs? Porque UUID.randomUUID() garantiza identificadores únicos sin colisiones y se inicializa automáticamente al crear cada data class.
¿Cómo implementar los repositorios en memoria?
Las implementaciones concretas viven en clases con prefijo InMemory, que guardan los datos en listas mutables dentro del scope de la clase. Este enfoque sigue el Domain-Driven Design, donde generas software a partir de la idea de negocio y las relaciones entre objetos [13:05].
¿Cómo se ve InMemoryUserRepository?
La clase InMemoryUserRepository implementa UserRepository y mantiene una private val users = mutableListOf<User>(). La función save se reduce a users.add(user), ya que add retorna un boolean nativo de la lista.
Para findByUsername usas users.firstOrNull { it.username == username }, una lambda que recorre la lista y devuelve la primera coincidencia o null si no encuentra nada.
¿Cómo se construye InMemoryEmailRepository?
En InMemoryEmailRepository mantienes private val emails = mutableListOf<Email>(). Cada método aprovecha funciones de colecciones de Kotlin:
save ejecuta emails.add(email).
findByOwner aplica emails.filter { it.ownerId == ownerId } para devolver solo los correos del usuario.
findById usa emails.firstOrNull { it.id == id }.
delete ejecuta emails.removeIf { it.id == id }, donde la expresión se evalúa por cada elemento y elimina los que coinciden.
Un detalle importante: al comparar IDs siempre usas == (igualdad), no = (asignación), para evitar el error type mismatch expected boolean [18:40].
Con los modelos y repositorios listos, ya tienes la base que sostiene toda la aplicación. El siguiente paso es declarar la lógica de negocio que orquesta cómo UserRepository y EmailRepository interactúan entre sí. ¿Cómo organizarías tú esta capa? Cuéntalo en los comentarios.