Hay mucha vida más allá de los principios SOLID y, sobre todo, mucho antes.
GRASP, General Responsibility Assignment Software Patterns, es un conjunto de patrones o heurísticas para definir el reparto de responsabilidades de un sistema orientado a objetos. Básicamente, nos ayudan a responder a la pregunta: ¿a qué clase pertenece esta responsabilidad? Así que se podría decir que cada uno de estos patrones nos proporciona una posible respuesta.
Los principios GRASP son el fundamento de otros muchos principios y nos ayudan a encontrar respuesta a preguntas bastante básicas.
¿Quién tiene que crear un objeto? Creator
La responsabilidad de crear un objeto de una cierta clase debería estar en otro objeto tal que cumple una o más de las siguientes condiciones:
- Contiene o agrega instancias de la clase a crear.
- Registra instancias de la clase a crear.
- Usa instancias de de la clase a crear.
- Contiene la información necesaria para instanciar objetos de la clase a crear.
La primera condición: contiene o agrega instancias de otra clase, se aplica, por ejemplo, en los agregados de dominio. En lugar de entregarle objetos ya instanciados, lo correcto sería entregarle los datos necesarios para crearlos de modo que el agregado se responsabiliza de proteger las invariantes de dominio que les afectan.
He aquí un ejemplo. Creamos un objeto Order
y le añadimos un Item
pasándole la información necesaria para crearlo:
require 'rspec'
require_relative '../src/order.rb'
RSpec.describe 'Order should' do
it "add items" do
order = Order.new
order.add_item 'Item 1', 1, 20
expect(order.items.length).to eq(1)
end
end
Este es el código. Order.rb
require_relative './item.rb'
class Order
def initialize
@items = []
end
def add_item(name, quantity, price)
new_item = Item.new name, quantity, price
@items.push(new_item)
end
def items
@items
end
end
e Item.rb
class Item
def initialize(name, quantity, price)
@name = name
@quantity = quantity
@price = price
end
end
La cuarta condición nos ayuda a entender patrones de construcción como puede ser Factory o Builder, objetos que tienen la información necesaria para instanciar otros objetos.
¿Quién debería hacer esto? Information expert
Pon la responsabilidad en la clase que tenga la información necesaria para ejercerla, que es la information expert
para ese asunto.
Uno de los ejemplos en los que resulta más fácil verlo en acción es en el modelado de una venta en una tienda, así que podemos aprovechar el ejemplo anterior.
Supongamos que necesitamos conocer el importe total del pedido. Normalmente tendremos clases como Order
e Item
. Es sencillo ver que Order
, al contener la colección de Items
solicitados, es quien tiene toda la información necesaria para poder calcular el importe total, mientras que Item
sería responsable del importe de cada línea.
Lo podemos ver aquí:
require 'rspec'
require_relative '../src/order.rb'
RSpec.describe 'Order should' do
it "sum total amount" do
order = Order.new
order.add_item('Item 1', 2, 20)
order.add_item('Item 2', 3, 15)
expect(order.total_amount).to eq(85)
end
end
Item.rb. Item
sabe calcular el importe de la línea, ya que sabe tanto el precio como la cantidad:
class Item
def initialize(name, quantity, price)
@name = name
@quantity = quantity
@price = price
end
def amount
@price * @quantity
end
end
Order.rb. Mientras, que Order
, al contener todos los Items
, puede calcular el total del pedido:
require_relative './item.rb'
class Order
def initialize
@items = []
end
def add_item(name, quantity, price)
new_item = Item.new name, quantity, price
@items.push(new_item)
end
def items
@items
end
def total_amount
@items.sum { |item| item.amount }
end
end
¿Cómo gestiono variantes basadas en clase de objetos? Polymorphism
El polimorfismo nos ayuda cuando tenemos que hacer distintas cosas en función del tipo de objeto o información que recibimos. La responsabilidad de cada variedad de comportamiento corresponde al tipo, de modo que el objeto consumidor u orquestador no tiene que saber previamente el tipo de objeto que está manejando, simplemente le envía el mensaje para que actúe. Hemos tratado esto más ampliamente en un artículo sobre Programar sin ifs.
El ejemplo clásico de las figuras geométricas nos viene bien aquí. Cada figura tiene unas propiedades ligeramente distintas, pero todas saben dibujarse o calcular su área. El código usuario de las clases no tiene que preguntar primero de qué tipo se trata para dibujarlas. Veámoslo en el siguiente test:
require 'rspec'
require_relative '../src/surface_calculator.rb'
RSpec.describe 'Surface Calculator should' do
it 'calculate surface joining different shapes' do
surface = SurfaceCalculator.new
surface.add_triangle 10, 20
surface.add_square 5
expect(surface.total).to eq(125)
end
end
SurfaceCalculator es capaz de agregar distintos tipos de objetos Shape, cada uno de los cuales es una especialización:
require_relative './triangle'
require_relative './square'
class SurfaceCalculator
def initialize
@shapes = []
end
def add_triangle(base, height)
triangle = Triangle.new base, height
@shapes.append triangle
end
def add_square(side)
square = Square.new side
@shapes.append square
end
def total
@shapes.sum { |shape| shape.area }
end
end
Como podemos ver en el método total
, SurfaceCalculator
solo tiene que preguntarle a cada Shape su área, sin necesidad de preguntarle cuál es su tipo primero. Esto hace que las figuras deban entender el mensaje area
(o, lo que es lo mismo, tener el método area
)
He aquí el código de las formas:
Triangle, representa un triángulo:
class Triangle
def initialize(base, height)
@base = base
@height = height
end
def area
@height * @base / 2
end
end
Square un cuadrado:
class Square
def initialize (side)
@side = side
end
def area
@side ** 2
end
end
Podemos ver, por tanto, que cada una de las formas se encarga de realizar el cálculo especializado de su área, liberando a SurfaceCalculator
de esa responsabilidad, y centrándolo en la suya propia que es agregar todas las áreas de las figuras que contiene.
Esto tiene el beneficio de que será fácil añadir nuevas formas, como Rectangle:
class Rectangle
def initialize(base, height)
@base = base
@height = height
end
def area
@height * @base
end
end
El problema es que esta forma de trabajar no es segura, ya que nada nos garantiza la existencia previa del método area
en los diferentes tipos de forma. ¿Podemos garantizarlo de alguna forma? Vayamos al siguiente principio.
¿Cómo diseño los objetos para evitar el impacto de las variaciones? Protected variations
Es el principio tras la definición de interfaces. Lo que buscamos al aplicar este principio es proteger a los componentes del sistema de las variaciones de otros componentes. Se trataría de establecer algún tipo de contrato entre los componentes participantes que obligue a ser capaz de responder a unos mensajes determinados.
En Ruby, el lenguaje usando en estos ejemplos, y en otros lenguajes (Golang, etc.) no existe la idea de definir interfaces explícitamente. En su lugar, tenemos interfaces implícitas. Por eso, el código anterior funciona siempre que usemos objetos que tengan el método area
. Esto ya sería una buena razón para aplicar el principio Creator
, pues nos puede facilitar el asegurar que SurfaceCalculator
solo utiliza formas de las que sabe que le pueden dar una respuesta en el método area
.
En otros lenguajes, como Java o PHP, tenemos que definir interfaces explícitamente, de modo que el propio intérprete o compilador nos obligan a que las clases las implementen correctamente.
Para mostrar un ejemplo en Ruby, vamos a simular interfaces con una clase base abstracta Shape
que tiene el método area
. Si no lo sobreescribimos en una clase hija, lanzará una excepción.
Shape es la clase base que representa una forma genérica.
class Shape
def area
raise 'Method area not implemented'
end
end
Triangle, representa un triángulo:
require_relative 'shape.rb'
class Triangle < Shape
def initialize(base, height)
@base = base
@height = height
end
def area
@height * @base / 2
end
end
Square un cuadrado:
require_relative 'shape.rb'
class Square < Shape
def initialize (side)
@side = side
end
def area
@side ** 2
end
end
Rectangle:
require_relative 'shape.rb'
class Rectangle < Shape
def initialize(base, height)
@base = base
@height = height
end
def area
@height * @base
end
end
Como se puede apreciar es el mismo código, pero ahora hemos definido cada forma como una Shape
que es capaz de responder al mensaje area
y decirnos qué superficie tiene.
¿Cómo evito el acoplamiento directo? Indirection
El objetivo del patrón Indirection es evitar el acoplamiento directo entre otros dos objetos, normalmente introduciendo un objeto intermediario. El objetivo es permitir que los objetos relacionados puedan evolucionar independientemente.
En los ejemplos anteriores hemos creado una clase SurfaceCalculator
que puede calcular el área de figuras compuestas por triángulos y cuadrados. Sin embargo, ¿qué tenemos que hacer para que pueda procesar también rectángulos u otras figuras?. Tal como está ahora mismo, SurfaceCalculator
está acoplada al tipo de figuras que conoce. Por el propio principio Creator, solo puede instanciar figuras conocidas y no puede instanciar figuras que no conoce salvo que la modifiquemos.
Algo así:
require 'rspec'
require_relative '../src/surface_calculator.rb'
RSpec.describe 'Surface Calculator should' do
it 'calculate surface joining different shapes' do
surface = SurfaceCalculator.new
surface.add_triangle 10, 20
surface.add_square 5
surface.add_rectangle 15, 10
expect(surface.total).to eq(275)
end
end
La cuestión es que podríamos evitar este acoplamiento interponiendo un mediador entre SurfaceCalculator
y las figuras que soporta.
Por ejemplo, usando una clase ShapeFactory
que se encargue de fabricar las formas para SurfaceCalculator
, de modo que ya no necesita saber qué figuras concretas están disponibles. Algo como esto:
require 'rspec'
require_relative '../src/surface_calculator.rb'
require_relative '../src/shape_factory.rb'
RSpec.describe 'Surface Calculator should' do
it 'calculate surface joining different shapes provided by factory' do
factory = ShapeFactory.new
surface = SurfaceCalculator.new
surface.add factory.make 'triangle', 10, 20
surface.add factory.make 'square', 5
surface.add factory.make 'rectangle', 15, 10
expect(surface.total).to eq(275)
end
end
Este es el código de ShapeFactory
:
require_relative './triangle'
require_relative './square'
require_relative './rectangle'
class ShapeFactory
def make(shape, *param)
case shape
when 'triangle'
Triangle.new param[0], param[1]
when 'square'
Square.new param[0]
when 'rectangle'
Rectangle.new param[0], param[1]
else
raise "Shape #{shape} not supported"
end
end
end
Gracias a esto, SurfaceCalculator
queda más simplificado y puede evolucionar sin preocuparse de ninguna forma:
class SurfaceCalculator
def initialize
@shapes = []
end
def add(shape)
@shapes.append shape
end
def total
@shapes.sum { |shape| shape.area }
end
end
Podríamos discutir dos cosas:
ShapeFactory
está acoplado a las formas, tiene que tener conocimiento de la que soporta y de las que no. Sin embargo esa es su responsabilidad: saber qué formas se pueden instanciar.
Por otro lado, ¿no hemos dicho que SurfaceCalculator
, al agregar formas tendría que ser la Creator
?. Ciertamente, pero en este caso, nada nos impediría hacer que ShapeFactory
sea colaboradora de SurfaceCalculator
, permitiéndonos cumplir de nuevo este principio:
require 'rspec'
require_relative '../src/surface_calculator.rb'
require_relative '../src/shape_factory.rb'
RSpec.describe 'Surface Calculator should' do
it 'calculate surface joining different shapes' do
factory = ShapeFactory.new
surface = SurfaceCalculator.new factory
surface.add 'triangle', 10, 20
surface.add 'square', 5
surface.add 'rectangle', 15, 10
expect(surface.total).to eq(275)
end
end
De modo que ahora, SurfaceCalculator
quedaría así:
class SurfaceCalculator
def initialize(factory)
@factory = factory
@shapes = []
end
def add(shape, *params)
@shapes.append @factory.make shape, *params
end
def total
@shapes.sum { |shape| shape.area }
end
end
¿Quién gestiona las entradas al sistema? Controller
Controller es una clase queque representa el sistema en general o bien un caso de uso de la aplicación y que gestiona uno o más eventos del sistema enviados por la capa de UI, de la que no forma parte, encargándose de delegar en otros objetos del sistema para obtener la respuesta que debería devolver.
El uso correcto de Controller incluye la posibilidad de procesar eventos estrechamente relacionados, como pueden ser los distintos verbos en REST para un mismo recurso.
¿Cómo reduzco el acoplamiento? Low coupling
Ya hemos hablado de acoplamiento en una entrega anterior. El acoplamiento es el grado de interdependencia entre objetos. Si dos objetos deben interactuar existe un acoplamiento entre ellos, pero es deseable que sea el mínimo posible. La aplicación de otros principios nos permite mantener el acoplamiento reducido y relajado, como puede ser el uso de la indirección, o la inversión de dependencias.
¿Cómo focalizo las responsabilidades de una clase? High cohesion
En un artículo anterior también mencionamos la cohesión. La cohesión es la fuerza que mantiene focalizadas las responsabilidades de una clase, o de un módulo en su caso. Una clase con alta cohesión es más fácil de entender, ya que no tiene elementos que nos puedan hacer dudar de cuáles son sus responsabilidades.
La alta cohesión se logra, sobre todo, aprendiendo a decir no a las responsabilidades que no corresponden a una clase, especialmente a aquellas que a primera vista sí parecerían adecuadas. También se contribuye a ella identificando aquellas cosas que cambian juntas, ya que, cuando es así, deberían ir juntas.
¿Dónde pongo la responsabilidad cuando no puedo asignarla a una clase específica? Pure fabrication
Se trata de una clase que no representa un concepto del dominio, pero que necesitamos cuando no podemos asignar la responsabilidad a una clase que sí lo hace. Este tipo de clase es lo que solemos entender como Servicio. Con frecuencia, los objetos mediadores que usamos al aplicar Indirection son Pure fabrication, también. Es decir, son artificios que introducimos para facilitarnos las cosas. El caso anterior con ShapeFactory
puede servirnos como ejemplo.