Construyendo un MVP sin BBDD
Buenas prácticas de programación, código limpio, bajo acoplamiento... Todos estos conceptos son los primeros que escuchas en la universidad y podríamos englobarlos dentro del concepto calidad de código.
Justo después intentas crear un producto de software y te das cuenta de que la calidad del código depende de cómo de fácil se adapte a las diferentes necesidades de un producto que está en constante evolución.
En este post voy a intentar replicar la situación que me ayudó a entender cómo eso se puede aplicar en la vida real.
¿MVP, eso que es?
Un MVP (Minimum Viable Product) es una estrategia centrada en validar las hipótesis de un posible producto lo antes posible y con el menor esfuerzo. Las principales características que debemos cumplir al máximo son:
- Rápido: Cuanto antes seamos capaz de llegar al mercado, más datos vamos a poder obtener. Los datos son el motor principal de un MVP. Ellos guiarán el camino a seguir y las decisiones a tomar intentando evitar al máximo los errores y la inversión de recursos en areas que no aporten valor.
- Barato: El producto tiene que poder desecharse en caso de que no sea exitoso, ello implica que no debe de suponer una inversion de recursos grande. Esta propiedad se tiene que cumplir durante la mayor parte de vida del producto, eso significa que no solo debe de ser barato al inicio, si no que debe de evolucionar sin una inversión grande de recursos.
Caso práctico
Ya basta de introducciones, vayamos con un caso de ejemplo, imaginémonos la siguiente situación:
Tenemos una aplicación bancaria que tiene una cantidad estable de usuarios mensuales. Cómo la marca está muy bien valorada, se plantea la posibilidad de añadir un carrito de compra para comprar merchandising de la marca. Con este pequeño experimento analizaremos cómo de bien puede encajar un pequeño apartado de compras dentro de la aplicación.
Una vez con la definición del MVP vamos a extraer los casos de uso que se tiene que añadir al sistema.
- Ver los productos a vender
- Añadir productos a un carrito de compra
- Ver un carrito de compra
- Pagar el contenido de un carrito de compra
La aplicación que existe actualmente esta construida en SpringBoot con Kotlin, usa SpringDataJPA para persistir su modelo de entidades en una base de datos relacional PostgreSQL.
Tablas
Ya que el sistema esta basado en una base de datos relacional, vamos a diseñar un modelo de datos sencillos basados en tablas.
Clases
Una vez con el modelo de base de datos generado solo necesitaríamos implementar las entidades de Kotlin con JPA, esto nos ahorraría tener que implementar los CRUD de base de datos.
Ya por último exponemos los endpoints y tendremos nuestro MVP listo para ser validado.
Resumen
¿Qué queremos lograr?
Implementar el MVP en el backend lo antes posible
¿Cómo lo hemos logrado?
Usando frameworks como SpringDataJPA para evitar escribir código boilerplate.
¿Qué hemos sacrificado?
Hemos usado las mismas tecnologías que se usan en el sistema actual por lo que parece que no hemos sacrificado nada.
Validando el MVP
Una vez con nuestra propuesta nos disponemos a validar el MVP. Una vez con los datos obtenidos recibimos esta respuesta:
La nueva característica ha tenido bastante éxito pero hemos detectado muchos usuarios que quieren más variedad de productos, por lo que ahora vamos a mostrar productos que nos ofrece un proveedor externo. Ademas queremos que el equipo de operaciones pueda añadir productos a mano y cambiar sus propiedades al momento para seguir consiguiendo más datos.
¿Esto significa que el MVP se ha validado?
En este tipo de desarrollos lo mas habitual es que el MVP evolucione a lo largo del tiempo hasta poder convertirse en un producto. No existe una etapa en la que después de aprender y validar se construya un nuevo proyecto desde cero. Por lo que la implementación debe evolucionar constantemente.
Analicemos cómo va a impactar estos cambios en la implementación que hemos realizado:
- Añadir los productos desde un proveedor externo supone cambiar totalmente el modelo relacional que habíamos implementado. Eso supondrá cambios en el modelo de base de datos, una migración, cambios en el modelo de entidades y habrá que buscar una forma de unificar la obtención de productos entre la base de datos y el proveedor.
- Ofrecer una forma sencilla para que el equipo de operaciones pueda añadir y editar productos sin supervision supondrá la creación de un back-office y la formación de los mismos en esta nueva herramienta.
Si comparamos el coste de implementar esta iteración con el coste de la primera implementación, se plantea un coste mucho mas alto.
¿Pero cómo es posible, qué hemos hecho mal?
El problema de las decisiones por inercia
Todas las decisiones deben estar, en la medida de lo posible, argumentadas con datos.
Una vez empezemos a obtener datos de cómo nuestros usuarios interactúan con el sistema todo el producto puede empezar a tomar decisiones basadas en datos, y eso incluye a la implementación. Puede parecer difícil porque tendemos a pensar cosas como que necesitamos obligatoriamente una base de datos para ponernos a programar, pero eso no es cierto.
Cuanto más tiempo esperemos para tomar una decisión, mas datos tendremos que la puedan respaldar y más acertada será. Pero sobretodo hay que estar preparados para equivocarnos.
En este caso vamos a ejemplificarlo con la elección de la base de datos, pero esta situación puede ocurrir con cualquier aspecto tecnológico, desde el sistema de colas, hasta si es mejor usar lambdas o un servidor alojado en AWS.
Sin darnos cuenta al principio de la implementación decidimos usar una BBDD relacional y el framework de JPA, además parecía una buena opción porque es lo que ya estaban usando dentro de la aplicación. Esto unido a que hemos acoplado las entidades de Kotlin a el modelo de BBDD hace que evolucionarlo sea muy costoso.
Vamos a volver a hacer la implementación pero ahora sin base de datos:
Construyendo un MVP sin BBDD
Partimos de los mismos requisitos que antes.
- Ver los productos a vender
- Añadir productos a un carrito de compra
- Ver un carrito de compra
- Pagar el contenido de un carrito de compra
Definimos nuestras entidades:
Cart
- Id
- Lista de productos
- Pagado o no pagado
Product
- Id
- Nombre
- Precio
A cada clase le voy a añadir una función que las convertirá en un objeto sencillo con el que devolverlo en las llamadas.
class Cart(id: CartId) : Entity<CartId>(id) {
private var isPaid = false
private val products = mutableListOf<Product>()
fun payCart() {
isPaid = true
}
fun addProduct(product: Product) {
products.add(product)
}
private fun getTotalPrice(): Double {
require(products.isNotEmpty()) { return 0.0 }
return products.map { it.price }.reduce { acc, product -> acc + product }
}
fun toData(): CartData {
return CartData(id.toString(), products, isPaid, getTotalPrice())
}
data class CartData(val id: String, val products: List<Product>, val isPaid: Boolean, val price: Double)
}
class Product(id: ProductId, val price: Double, val name: String) : Entity<ProductId>(id) {
fun toData(): ProductData {
return ProductData(id.toString(),price,name)
}
data class ProductData(val id: String, val price: Double, val name: String)
}
Ahora llega el momento de los casos de uso:
@GetMapping("/products")
fun getProducts(): List<Product.ProductData>? {
return productRepository.findAll()?.map{ it.toData()}
}
@PostMapping("/carts")
fun createCart(): String {
val id = CartId()
cartRepository.save(Cart(id))
return id.toString()
}
@GetMapping("/carts/{id}")
fun findCart(@PathVariable id: String): Cart.CartData? {
return cartRepository.find(CartId(id))?.toData()
}
@PutMapping("/carts/{id}")
fun addProductsToCart(@RequestBody products: List<String>, @PathVariable id: String){
val cart = cartRepository.find(CartId(id))
require(cart!=null)
require(products.all{productRepository.find(ProductId(it)) != null})
products.forEach{cart.addProduct(productRepository.find(ProductId(it))!!)}
cartRepository.save(cart)
}
@PostMapping("/carts/{id}")
fun payCart(@PathVariable id: String){
val cart = cartRepository.find(CartId(id))
require(cart!=null)
cart.payCart()
}
¿Y la BBDD?
Cómo se ve en el código anterior estamos usando repositorios, pero ninguna BBDD. Para poder validar que todo esto funcione correctamente estamos usando repositorios en memoria.
abstract class InMemoryRepository<E: Entity<I>, I: Id> : Repository<E, I> {
protected val database =mutableMapOf<I,E>()
override fun find(id: I): E? {
return database[id]
}
override fun findAll(): List<E>? {
return database.values.toList()
}
override fun save(data: E) {
database[data.id] = data
}
override fun delete(id: I) {
database.remove(id)
}
fun clear(){
database.clear()
}
}
Todo este ejemplo esta preparado con algunas definiciones básicas como Entity, Id, y Repository. Pero estas no son necesarias, aunque en caso de que quieres usarlas en el código tienes más detalle.
¿Y ahora qué?
Perfecto, ya tenemos nuestro MVP sin BBDD ¿y en qué nos beneficia esto?
Todo dependerá del tipo de validación que vayamos a hacer ahora:
- Si lo vamos a hacer una demo pequeña y para un grupo de usuarios reducido la solución que hemos presentado nos serviría
- Si queremos añadir persistencia tenemos todas las opciones disponibles, solo tenemos que implementar un repositorio para la que queramos evaluar.
- InMemoryRepository
- MongoRepository
- PostgreSQLRepository
- GoogleSheetsRepository
Pero analicemos que hemos conseguido realmente:
- Más velocidad: Sin configurar absolutamente nada de BBDD podemos empezar a validar nuestro MVP
- Menos acoplamiento: Ahora el código no depende de ninguna tecnología en concreto, dentro de cada repository podemos usar la tecnología que queramos.
- Mejorar los tests: Teniendo los repositorios en memoria ya no se tiene que mockear los repositorios, ni levantar la aplicación para hacer pruebas sencillas
- Repositorios multi-repo: Uno de los problemas que surgían al intentar evolucionar el MVP era una entidad te podia llegar desde BBDD o desde una API. Ahora podemos implementar un repo que se inyecte esos dos repositorios sin que nada de la implementación cambie.
- Cambios progresivos de modelo: Al no tener la BBDD directamente mapeada en el código podemos ir aplicando los cambios de modelo de forma progresiva.
Evolucionando el nuevo MVP
No podíamos acabar sin analizar cómo afectarían los cambios anteriores a nuestro nuevo MVP.
-
Mostrar productos de un tercero
- Cómo hemos visto anteriormente podríamos implementar un multi-repo en el que implementar la lógica de las dos formas de acceder a los productos sin afectar a la estabilidad del sistema.
-
Ofrecer una solución para que operaciones pueda añadir nuevos productos
- En este caso una solución podría ser crear de forma temporal los registros de los productos en un google sheets. Seria una forma rápida y barata de obtener la información y validar el MVP. Y de nuevo seria implementar un repositorio.
Conclusiones
Lo primero de todo me gustaría matizar que no significa que siempre que se construya un MVP debería estar prohibido añadirle una base de datos. En este ejercicio intento demostrar que para construir un primer MVP se necesita muy poco y qué constantemente abusamos de frameworks y tecnologías que consideramos erróneamente esenciales.
Por otro lado recalcar la importancia de la independencia de las diferentes capas de la aplicación. Gran parte de los problemas que surgieron con el ejemplo de JPA se deben a usar las entidades del propio framework como entidades de dominio algo que condiciona todo el modelo de dominio al modelo de base de datos, algo que tal y como vimos dificulta su evolución
Por tanto y resumiendo; no hacen falta frameworks gigantes para invertir menos tiempo programando y mantener la independencia del código respecto a las tecnologías que usemos nos dará más opciones a la hora de evolucionar el producto que estemos desarrollando.
Muchas gracias por leerme, aquí te dejo todo el código que aparece en el artículo.
Top comments (0)