El diseño de software es el proceso de definir la estructura general del software para que cumpla con los requisitos. Es un proceso importante que se realiza antes de la fase de implementación e implica la creación de un modelo conceptual del software, que incluye la identificación de los componentes del software, sus relaciones y la forma en que se interconectan. El modelo se documenta en forma de diagramas que describen cómo funcionará el software.

En este post vemos algunos de los conceptos del diseño de software a través de una kata, en concreto la kata Mars Rover.

Requisitos del software

La NASA aterrizará un escuadrón de rovers robóticos en una meseta en Marte. Esta meseta, que es curiosamente rectangular, debe ser navegada por los rovers para que sus cámaras a bordo puedan obtener una vista completa del terreno circundante para enviar de regreso a la Tierra.

La posición de un rover está representada por una combinación de coordenadas X e Y y la orientación está representada de la primera letra de los cuatro puntos cardinales de la brújula (N, E, S o W). La meseta se divide en una cuadrícula para simplificar la navegación. Una posición de ejemplo podría ser 0, 0, N, lo que significa que el rover está en la esquina inferior izquierda y mirando hacia el norte.

Para controlar un rover, la NASA envía una simple cadena de letras, cada una de las cuales representa un comando. Las letras posibles son L, R y M.

L y R hacen que el rover gire 90 grados a la izquierda o a la derecha respectivamente, sin moverse de su lugar actual.

M significa avanzar un punto de la cuadrícula y mantener el mismo rumbo.

Sabemos que el cuadrado directamente al norte de (x, y) es (x, y+1).

Los requisitos son:

Así empiezan los requisitos de la kata Mars Rover, todos los detalles se pueden ver en el README del proyecto.

Diseño

Durante la fase de diseño de software, es fundamental entender bien los requisitos del negocio. Muchas veces suceden malentendidos por problemas de comunicación entre técnicos y no técnicos y para solucionar estos tipos de confusiones, en Domain-Driven Design se utiliza un lenguaje ubicuo, cuyo objetivo es garantizar de que cuando alguien dice por ejemplo “Fluzmox” (es una palabra inventada), todos los involucrados en el proyecto entiendan su significado de la misma manera.

Cuando leemos los requisitos podemos identificar diferentes sustantivos: rover, meseta, coordenada, orientación, comando, obstáculo. Aquí tenemos los requisitos con los sustantivos resaltados:

Requisitos de la kata Mars Rover con los sustantivos resaltados.

El siguiente paso es averiguar cómo se relacionan entre sí los diferentes sustantivos. Un enfoque efectivo para identificar las relaciones es utilizar las expresiones "tiene un" y "es un". A un nivel alto, si dos elementos se relacionan a través de la expresión "tiene un", entonces es posible que estos conceptos deben estar agrupados juntos en una composición. En cambio, si dos conceptos se relacionan a través de la expresión "es un", entonces se puede inferir que estos conceptos son intercambiables entre sí (Liskov substitution principle).

Este es el resumen de los requisitos:

Con esta nueva comprensión, los conceptos y sus relaciones se verían así:

Relación entre los sustantivos de la kata Mars Rover.

Implementación

Paso 1 - El rover se puede mover hacia el norte

El primer requisito que vamos a cubrir es que el rover pueda moverse hacia el norte.

¿Qué necesitamos para esto?

Empezamos con los 3 bloques que debe contener un test:

Como dice el requisito, se debe usar TDD sin ninguna excusa.

El diseño de un buen test siempre debe empezar por el Assert, en nuestro caso debemos validar que el rover está en la posición esperada; es decir, en la coordenadas (0, 1).

assertThat(rover.getCoordinate()).isEqualTo(expected);

La action sería ejecutar el comando move del rover:

rover.move();

Para preparar los datos del test, usaremos el diagrama de los sustantivos y sus relaciones que hemos creado en la fase del diseño. Para este caso en particular, necesitamos una meseta (Plateau), que debe ser navegada por un rover que contendrá coordenadas y orientación.

Con todo definido, creamos la clase RoverMoveTest con el test shouldMoveNorth().

Este es el aspecto del test que creamos:

class RoverMoveTest {

 @Test
 void shouldMoveNorth() {
   Plateau plateau = new Plateau(5, 5);
   Coordinate coordinate = new Coordinate(0, 0);
   Orientation orientation = Orientation.NORTH;
   Rover rover = new Rover(plateau, coordinate, orientation);
   Coordinate expected = new Coordinate(0, 1);

   rover.move();

   assertThat(rover.getCoordinate()).isEqualTo(expected);
 }
}

Ahora vamos a ir creando las clases necesarias para cubrir estos requisitos.

Primero, tenemos el Plateau que será un objeto inmutable que contiene los valores máximos de X y Y. Este es el código usando Lombok.

@Value
public class Plateau {
 int maxX;
 int maxY;
}

¿Por qué es un objeto inmutable? En los requisitos vemos que el valor del Plateau no va a cambiar y la inmutabilidad, ofrece seguridad y buen rendimiento, no nos debemos preocupar por la concurrencia, y el código queda muy simple.

La segunda clase que vamos a crear es de las coordenadas. Coordinate sería un Value Object. Los value objects son unos de los componentes básicos de Domain-Driven Design y se usan para modelar conceptos del nuestro dominio, como pueden ser precio, fechas, emails… Es un objeto inmutable que no tiene un identificador, y para comparar se utilizan los valores de todos los atributos del objeto.

El ejemplo más común que se usa para explicar los value object es de los billetes: dos billetes de 5 euros son iguales, porque el objeto Dinero contiene dos atributos, cuantía y divisa, que en nuestro caso, las cuantías tienen valor 5 y las divisas son euro, esto significa que los dos value objects son iguales.

Esta es la clase Coordinate:

@Value
public class Coordinate {
 int x;
 int y;
}

Para que la comparación sea correcta, es necesario que la implementación del método equals() tenga en cuenta todos los atributos relevantes del objeto y que estos atributos sean inmutables. Utilizaremos Lombok que generará el siguiente código para el método equals():

public boolean equals(final Object o) {
 if (o == this) {
   return true;
 }
 if (!(o instanceof Coordinate)) {
   return false;
 }
 final Coordinate other = (Coordinate) o;
 if (this.getX() != other.getX()) {
   return false;
 }
 if (this.getY() != other.getY()) {
   return false;
 }
 return true;
}

La orientación será un enum con los puntos cardinales, pero ahora contendrá solo el valor de NORTH.

public enum Orientation {
 NORTH
}

Y, por último, no por importancia, nos queda crear la clase del Rover que contendrá las coordenadas y la orientación que se modifican con cada movimiento del rover:

@Data
@AllArgsConstructor
public class Rover {

 private final Plateau plateau;
 private Coordinate coordinate;
 private Orientation orientation;

 public void move() {
 }
}

Con esto tenemos todas las clases necesarias para que se pueda ejecutar nuestros tests y fallará, estamos en el step RED.

Debemos hacer la mínima implementación para que nuestro test pase:

@Data
@AllArgsConstructor
public class Rover {

 private final Plateau plateau;
 private Coordinate coordinate;
 private Orientation orientation;

 public void move() {
   this.coordinate = new Coordinate(getCoordinate().getX(), getCoordinate().getY()+1);
 }
}

Los tests pasan. Estamos en paso GREEN, tenemos implementada la funcionalidad de que rover se puede mover al norte.

El tercer paso del TDD es del REFACTOR, pero en este caso no tenemos nada para refactorizar.

Paso 2 - El rover se puede mover a todas las direcciones

Una vez creado el primer test y las clases que necesitábamos, vemos que para probar que el rover se pueda mover en todas las direcciones, podemos utilizar un test parametrizado, porque lo único que serían diferentes son: las coordenadas iniciales, la orientación y las coordenadas esperadas después del movimiento. Utilizaremos un método para proporcionar los valores para el test:

class RoverMoveTest {

 public static final int MIN_X = 0;
 public static final int MIN_Y = 0;
 public static final int MID_X = 3;
 public static final int MID_Y = 3;
 public static final int MAX_X = 5;
 public static final int MAX_Y = 5;
 private static Plateau plateau;

 public static Stream<Arguments> dataForTest() {
   return Stream.of(
       Arguments.of(Coordinate.of(MIN_X, MIN_Y), Orientation.NORTH, Coordinate.of(MIN_X, MIN_Y + 1)),
       Arguments.of(Coordinate.of(MID_X, MID_Y), Orientation.NORTH, Coordinate.of(MID_X, MID_Y + 1)),
       Arguments.of(Coordinate.of(MAX_X, MAX_Y), Orientation.NORTH, Coordinate.of(MAX_X, MAX_Y)),

       Arguments.of(Coordinate.of(MIN_X, MIN_Y), Orientation.WEST, Coordinate.of(MIN_X, MIN_Y)),
       Arguments.of(Coordinate.of(MID_X, MID_Y), Orientation.WEST, Coordinate.of(MID_X - 1, MID_Y)),
       Arguments.of(Coordinate.of(MAX_X, MAX_Y), Orientation.WEST, Coordinate.of(4, MAX_Y)),

       Arguments.of(Coordinate.of(MIN_X, MIN_Y), Orientation.SOUTH, Coordinate.of(MIN_X, MIN_Y)),
       Arguments.of(Coordinate.of(MID_X, MID_Y), Orientation.SOUTH, Coordinate.of(MID_X, MID_Y - 1)),
       Arguments.of(Coordinate.of(MAX_X, MAX_Y), Orientation.SOUTH, Coordinate.of(MAX_X, MAX_Y - 1)),

       Arguments.of(Coordinate.of(MIN_X, MIN_Y), Orientation.EAST, Coordinate.of(MIN_X + 1, MIN_Y)),
       Arguments.of(Coordinate.of(MID_X, MID_Y), Orientation.EAST, Coordinate.of(MID_X + 1, MID_Y)),
       Arguments.of(Coordinate.of(MAX_X, MAX_Y), Orientation.EAST, Coordinate.of(MAX_X, MAX_Y))
   );
 }

 @BeforeAll
 static void beforeAll() {
   plateau = new Plateau(MAX_X, MAX_Y);
 }

 @ParameterizedTest
 @MethodSource("dataForTest")
 void roverShouldMoveTo(Coordinate initial, Orientation orientation, Coordinate expected) {
   Rover rover = new Rover(plateau, initial, orientation);

   rover.move();

   assertThat(rover.getCoordinate()).isEqualTo(expected);
 }
}

Añadimos los otros puntos cardinales en Orientation:

public enum Orientation {
 WEST, SOUTH, EAST, NORTH
}

En Coordinate usaremos Static Factory Method para la creación de los nuevos objetos:

@Value
public class Coordinate {

 int x;
 int y;

 public static Coordinate of(int x, int y) {
   return new Coordinate(x, y);
 }
}

La recomendación de usar Static Factory Methods en vez de constructores viene del libro de Joshua Bloch “Effective Java”. Esta técnica es muy similar al patrón de diseño Flyweight y está usada ampliamente en Java, por ejemplo BigInteger.valueOf(Integer.MAX_VALUE) o List.of("Hola", "mundo").

Finalmente, la implementación de la funcionalidad del método move():

@Data
@AllArgsConstructor
public class Rover {

 private final Plateau plateau;
 private Coordinate coordinate;
 private Orientation orientation;

 public void move() {
   switch (orientation) {
     case NORTH:
       if (getCoordinate().getY() < plateau.getMaxY()) {
         this.coordinate = Coordinate.of(getCoordinate().getX(), getCoordinate().getY() + 1);
       }
       break;
     case SOUTH:
       if (getCoordinate().getY() > 0) {
         this.coordinate = Coordinate.of(getCoordinate().getX(), getCoordinate().getY() - 1);
       }
       break;
     case EAST:
       if (getCoordinate().getX() < plateau.getMaxX()) {
         this.coordinate = Coordinate.of(getCoordinate().getX() + 1, getCoordinate().getY());
       }
       break;
     case WEST:
       if (getCoordinate().getX() > 0) {
         this.coordinate = Coordinate.of(getCoordinate().getX() - 1, getCoordinate().getY());
       }
       break;
   }
 }
}

Paso 3 - El rover puede girar a la izquierda

Creamos el test RoverTurnLeftTest que va a ser un @ParameterizedTest:

public class RoverTurnLeftTest {

 public static Stream<Arguments> provideDataForTurnLeft() {
   return Stream.of(
       Arguments.of(Orientation.NORTH, Orientation.WEST),
       Arguments.of(Orientation.WEST, Orientation.SOUTH),
       Arguments.of(Orientation.SOUTH, Orientation.EAST),
       Arguments.of(Orientation.EAST, Orientation.NORTH)
   );
 }

 @ParameterizedTest
 @MethodSource("provideDataForTurnLeft")
 void testTurnLeft(Orientation initial, Orientation expected) {
   Plateau plateau = new Plateau(5, 5);
   Coordinate coordinate = Coordinate.of(0, 0);
   Rover rover = new Rover(plateau, coordinate, initial);

   rover.turnLeft();

   assertThat(rover.getOrientation()).isEqualTo(expected);
 }
}

La implementación del método turnLeft() es supersimple:

public void turnLeft() {
 switch (orientation) {
   case NORTH:
     this.orientation = Orientation.WEST;
     break;
   case SOUTH:
     this.orientation = Orientation.EAST;
     break;
   case EAST:
     this.orientation = Orientation.NORTH;
     break;
   case WEST:
     this.orientation = Orientation.SOUTH;
     break;
 }
}

Paso 4 - El rover puede girar a la derecha

Es muy parecido al paso anterior:

public class RoverTurnRightTest {

 public static Stream<Arguments> provideDataForTurnRight() {
   return Stream.of(
       Arguments.of(Orientation.NORTH, Orientation.EAST),
       Arguments.of(Orientation.WEST, Orientation.NORTH),
       Arguments.of(Orientation.SOUTH, Orientation.WEST),
       Arguments.of(Orientation.EAST, Orientation.SOUTH)
   );
 }

 @ParameterizedTest
 @MethodSource("provideDataForTurnRight")
 void testTurnRight(Orientation initial, Orientation expected) {
   Plateau plateau = new Plateau(5, 5);
   Coordinate coordinate = Coordinate.of(0, 0);
   Rover rover = new Rover(plateau, coordinate, initial);

   rover.turnRight();

   assertThat(rover.getOrientation()).isEqualTo(expected);
 }
}
public void turnRight() {
 switch (orientation) {
   case NORTH:
     this.orientation = Orientation.EAST;
     break;
   case EAST:
     this.orientation = Orientation.SOUTH;
     break;
   case SOUTH:
     this.orientation = Orientation.WEST;
     break;
   case WEST:
     this.orientation = Orientation.NORTH;
     break;
 }
}

Paso 5 - El rover tiene un controlador para interpretar los comandos

Creamos el test del controlador:

public class RoverControllerTest {

 private static final Plateau plateau = new Plateau(5, 5);

 public static Stream<Arguments> provideCommands() {
   return Stream.of(
       Arguments.of(
           new Rover(plateau, Coordinate.of(1, 2), Orientation.NORTH),
           "LMLMLMLMM",
           new Rover(plateau, Coordinate.of(1, 3), Orientation.NORTH)
       ),
       Arguments.of(
           new Rover(plateau, Coordinate.of(3, 3), Orientation.EAST),
           "MMRMMRMRRM",
           new Rover(plateau, Coordinate.of(5, 1), Orientation.EAST)
       )
   );
 }

 @ParameterizedTest
 @MethodSource("provideCommands")
 void executeCommandsTest(Rover initial, String commands, Rover expected) {
   RoverController roverController = new RoverController(initial);

   roverController.run(commands);

   assertThat(initial.getCoordinate()).isEqualTo(expected.getCoordinate());
   assertThat(initial.getOrientation()).isEqualTo(expected.getOrientation());
 }
}

La implementación es simple, el método run() lee los comandos, si coincide con alguno de los comandos M, L o R, los ejecuta:

public class RoverController {

 private final Rover rover;

 public RoverController(Rover rover) {
   this.rover = rover;
 }

 public void run(String commands) {
   for (String c : commands.split("")) {
     switch (c) {
       case "M":
         rover.move();
         break;
       case "L":
         rover.turnLeft();
         break;
       case "R":
         rover.turnRight();
         break;
       default:
     }
   }
 }
}

Paso 6 - Implementar detección de obstáculos.

En el README del proyecto, hay ejemplos como se puede probar cuando el rover explora una meseta con obstáculos:

Vamos a ver los ejemplos:

  1. Posición inicial y orientación: 0 0 N
    Cadena de comandos: MRMMRMMM
    Posición final y orientación: 0 1 E
Primer ejemplo de posiciones del rover.
  1. Posición inicial y orientación: 0 0 E
    Cadena de comandos: MMMLMMMLMMM
    Posición final y orientación: 3 3 W
Segundo ejemplo de posiciones del Rover.
  1. Posición inicial y orientación: 0 0 N
    Cadena de comandos: MMMMMMRMMRMLMRMLMRMLMR
    Posición final y orientación: 4 3 S
Tercer ejemplo con posiciones del rover.

Añadimos estos datos en el método que proporciona los datos para el test en RoverControllerTest:

public class RoverControllerTest {

 private static Plateau plateau;

 private static Plateau plateauWithObstacles;

 @BeforeAll
 static void beforeAll() {
   plateau = new Plateau(5, 5);

   plateauWithObstacles = new Plateau(5, 5);
   plateauWithObstacles.addObstacle(Coordinate.of(1, 1));
   plateauWithObstacles.addObstacle(Coordinate.of(2, 3));
   plateauWithObstacles.addObstacle(Coordinate.of(4, 2));
 }

 public static Stream<Arguments> provideCommands() {
   return Stream.of(
       // ...
       Arguments.of(
           new Rover(plateauWithObstacles, Coordinate.of(0, 0), Orientation.NORTH),
           "MRMMRMMM",
           new Rover(plateauWithObstacles, Coordinate.of(0, 1), Orientation.EAST)
       ),
       Arguments.of(
           new Rover(plateauWithObstacles, Coordinate.of(0, 0), Orientation.EAST),
           "MMMLMMMLMMM",
           new Rover(plateauWithObstacles, Coordinate.of(3, 3), Orientation.WEST)
       ),
       Arguments.of(
           new Rover(plateauWithObstacles, Coordinate.of(0, 0), Orientation.NORTH),
           "MMMMMMRMMRMLMRMLMRMLMR",
           new Rover(plateauWithObstacles, Coordinate.of(4, 3), Orientation.SOUTH)
       )
   );
 }
 // ...

}

En el Plateau creamos los métodos para añadir obstáculo a una lista y otro para comprobar si existe obstáculo en una coordenada:

@Value
public class Plateau {

 int maxX;
 int maxY;
 List<Coordinate> obstacles = new ArrayList<>();

 public void addObstacle(Coordinate obstacle) {
   this.obstacles.add(obstacle);
 }

 public boolean hasObstacleAt(Coordinate nextCoordinate) {
   return obstacles.contains(nextCoordinate);
 }
}

Modificamos el rover para que lance una excepción si encuentra un obstáculo en la siguiente coordenada:

@Data
@AllArgsConstructor
public class Rover {

 private final Plateau plateau;
 private Coordinate coordinate;
 private Orientation orientation;

 public void move() {
   switch (orientation) {
     case NORTH:
       if (getCoordinate().getY() < plateau.getMaxY()) {
         setNewCoordinate(Coordinate.of(getCoordinate().getX(), getCoordinate().getY() + 1));
       }
       break;
     case SOUTH:
       if (getCoordinate().getY() > 0) {
         setNewCoordinate(Coordinate.of(getCoordinate().getX(), getCoordinate().getY() - 1));
       }
       break;
     case EAST:
       if (getCoordinate().getX() < plateau.getMaxX()) {
         setNewCoordinate(Coordinate.of(getCoordinate().getX() + 1, getCoordinate().getY()));
       }
       break;
     case WEST:
       if (getCoordinate().getX() > 0) {
         setNewCoordinate(Coordinate.of(getCoordinate().getX() - 1, getCoordinate().getY()));
       }
       break;
   }
 }

 // ...

 private void setNewCoordinate(Coordinate nextCoordinate) {
   if (plateau.hasObstacleAt(nextCoordinate)) {
     throw new ObstacleDetectedException("An obstacle has been detected at the " + coordinate);
   }
   this.coordinate = nextCoordinate;
 }
}

Creamos la excepción ObstacleDetectedException:

public class ObstacleDetectedException extends RuntimeException {

  public ObstacleDetectedException(String message) {
    super(message);
  }
}

Ahora queda solo capturar esta excepción en RoverController e informar del obstáculo y abortar la secuencia.

public class RoverController {

 private final Rover rover;

 public RoverController(Rover rover) {
   this.rover = rover;
 }

 public void run(String commands) {

   try {
     for (String c : commands.split("")) {
       switch (c) {
         case "M":
           rover.move();
           break;
         case "L":
           rover.turnLeft();
           break;
         case "R":
           rover.turnRight();
           break;
         default:
       }
     }
   } catch (ObstacleDetectedException e) {
     System.out.println(e.getMessage());
   }
 }
}

Con esto tenemos una solución de la kata.

Conclusión

Incluso con una kata aparentemente simple como Mars Rover, es posible aplicar principios de diseño de software. Hemos aplicado TDD durante el proceso de la implementación, ayudándonos a no perder el foco de los requisitos y cubrir todos los casos de uso de manera efectiva, evitando la sobre-ingeniería y logrando una solución eficiente.

¿Se puede mejorar? Siempre se puede mejorar. En el siguiente artículo aplicaremos los patrones de diseño State, Command y Abstract Factory.

Espero que este artículo haya sido útil para aquellos que buscan mejorar sus habilidades de programación y construir soluciones de software de alta calidad.

Como siempre, tenéis el código en GitHub. Podéis usar la rama main para empezar y la rama solution para ver las solución con commit separado para cada paso.

Referencias

Cuéntanos qué te parece.

Los comentarios serán moderados. Serán visibles si aportan un argumento constructivo. Si no estás de acuerdo con algún punto, por favor, muestra tus opiniones de manera educada.

Suscríbete