Tengo que contarte un secreto. No debería contártelo porque es un secreto, pero es difícil guardarlo. Quiere salir de mí. Me doy por vencido. Ahí va: puedes usar formularios de django como adaptadores de dominio. Ya está.

Ahora ya puedo parar y empezar la historia desde el principio.

Django Forms as Domain Adapters - CAPSiDE

Permíteme entretenerte

Voy a usar una simple historia de usuario como ejemplo. Digamos que tenemos una aplicación web que hace seguimiento de cuándo están disponibles los trabajadores de una empresa. Para tener vacaciones, necesitas la aprobación de tu superior para que este pueda reprogramar tareas y demás. Este flujo comienza con un formulario para solicitar vacaciones o días libres. Tal formulario deberá contener la siguiente información:

Tratar con entradas de usuario puede ser una tarea pesada, al menos para mí. Por suerte, estamos usando django.

Django es genial. Pruébalo. Y el formulario de django es uno de los mejores marcos de trabajo de formularios que he visto nunca. Una arma secreta magnífica. No se lo cuentes a tus enemigos. No se lo cuentes a tu jefe. Ni siquiera a tu mujer. ¡Pero pruébalo!

Utilizar django, con una solución clara, limpia y sencilla, es lo siguiente.

Crea un modelo django para las peticiones

class VacationsRequest(models.Model):
	user = models.ForeignKey(User, on_delete=models.CASCADE)
	start_date = models.DateField()
	end_date = models.DateField()
	request_date = models.DateField()
	status = models.CharField(max_length=50, choices=STATUS_TYPES, default=PENDING)
	request_type = models.CharField(max_length=50, choices=REQUEST_TYPES, default=VACATIONS)
	fiscal_year = models.SmallIntegerField()

y construye un formulario de django con ellas

class RequestVacationsForm(forms.ModelForm):

	class Meta:
		model = models.VacationsRequest
		exclude = ['user', 'request_date', 'status']

Django gestionará la renderización, el análisis sintáctico y la propia validación del formulario de datos. Incluso devolverá los errores al usuario. ¡Y estará listo para usar! No está mal por unas cuantas líneas de código. Te dije que era genial, ¿no?

Lo único que falta son los conductos. Instala el controlador (véase en jerga de django)

def request_vacations(request):
	if request.method == 'POST':
		form = forms.vacations.RequestVacationsForm(request.POST, user=request.user.user)
		if form.is_valid():
			// ... process the request
			return redirect(reverse('my_vacations'))
	else:
		form = forms.vacations.RequestVacationsForm(user=request.user.user)
		return render(request, 'vacations/request_vacations.pug', {'form': form })

y renderiza el formulario en una plantilla

form(action="/vacations/request_vacations" method="post")
	{% csrf_token %}
	{{ form.as_p }}
	.row
		.col.m12.center
			input.btn(type="submit" value="Submit")
		{{ form.media }}

Los formularios de django hacen que tratar con formularios web sea facilísimo.

Como resumen, django te ayuda discretamente a

¿No es genial?

Pero esto es un mero ejemplo. Las aplicaciones del mundo real tienden a ser más complejas. No te preocupes, ya verás como gestionar la complejidad es aún más guay.

Django Forms as Domain Adapters - CAPSiDE

Me estoy volviendo un poco loco

Todo esto está bien, pero en mi opinión hay un gran inconveniente: es de un nivel demasiado bajo.

En este caso ha funcionado porqué era una tarea fácil. Como dice el lema, «las cosas simples deberían ser simples, pero las cosas complejas deberían ser posibles».

Vamos a complicarlo un poco. Vamos a restringir el formulario de manera que solamente presente los valores válidos del año fiscal y también validaremos los datos con algunas políticas de empresa que van más allá de los intervalos y tipos de datos correctos (en otras palabras, cosas que el marco de trabajo no proporciona).

Este caso de uso es parte de una aplicación más grande que se ha ido desarrollando a través de un enfoque conocido como “diseño guiado por el dominio” (en inglés, Domain Driven Design o DDD). En CAPSiDE llevamos un tiempo usando DDD, y ha tenido un impacto favorable en el equipo de desarrollo.

Una de las ventajas de implementar un modelo de dominio es mantener las reglas en el modelo. Esta decisión arquitectónica asume que es mucho mejor tener un dominio complejo que añada las reglas de la empresa que tenerlas distribuidas implícitamente a través de controladores, vistas y por todo el proyecto.

Este artículo no es sobre arquitectura en sí, pero asume que el código está diseñado siguiendo una arquitectura hexagonal, de puertos y adaptadores o limpia. Principalmente, el código de dominio reside en su propio módulo y desconoce la existencia de django. Sobre este hay una capa de casos de uso. Entonces están los adaptadores para la capa de persistencia y la interfaz de usuario. Hablamos, pues, de los adaptadores de esta y de cómo mantener el dominio protegido mientras escalas la funcionalidad de la interfaz.

En el siguiente apartado, te enseñaré cómo puedes usar formularios de django como adaptadores de dominio. Para ser más específico, haremos lo siguiente:

Y esto lo haremos sin aumentar la complejidad. A partir de ahora no tocaremos nada de los conductos.

Volviendo a nuestro ejemplo concreto, digamos que por política solamente se pueden solicitar vacaciones para el próximo año en el último mes del año. Esto lo puedes expresar con código

def fiscal_years_for_date(date):
	year = date.year
	if date.month > 11:
		return [year, year + 1]
	else:
		return [year]

Cuando esta lógica forma parte del dominio, podemos probarlo (es lo primero que hemos hecho, ¿verdad?)

class VacationsFiscalYearsTest(TestCase):

	def test_fiscal_years_include_current_year(self):
		date = random_date()
		years = vacations.fiscal_years_for_date(date)
		self.assertIn(date.year, years)

	def test_fiscal_years_include_next_year_in_december(self):
		date = datetime.date(2018,12,2)
		years = vacations.fiscal_years_for_date(date)
		self.assertIn(date.year + 1, years)

	def test_fiscal_years_dont_include_next_year_if_not_in_december(self):
		date = random_date()
		years = vacations.fiscal_years_for_date(date)
		self.assertNotIn(date.year + 1, years)

Estas son pruebas unitarias, así que no hacen falta simulacros, trozos de código u otra parafernalia. Y se ejecutan rápidamente. Si la lógica cambia (que lo hará), tanto la compañía como las pruebas son fáciles de cambiar ya que no están emparejados con la aplicación, la interfaz de usuario o la orquestación de un caso de uso.

Ten en cuenta que este dominio y este código de prueba son completamente independientes de django. Fácilmente podría usarse con flask o con una aplicación de consola, de la misma manera que se usa durante las pruebas. Este es el verdadero beneficio de adoptar una arquitectura hexagonal. Al principio puede tener un pequeño coste adicional, pero se amortiza rápidamente.

Lo bueno es que puedes mantener estos beneficios arquitectónicos mientras usas django. En el contexto de una arquitectura hexagonal, puedes usar formularios de django como adaptadores de dominio.

Django Forms as Domain Adapters - CAPSiDE

Usar formularios de django como adaptadores de dominio – prácticamente magia

Aunque django es muy dogmático y tiene algunos valores buenos por defecto, también se mantiene fuera de tu camino. Sus formularios son fáciles de modificar para evitar que el año fiscal presente solamente valores válidos. Los formularios django son clases, por lo que puedes añadir un poco más de código para personalizarlos:

class RequestVacationsForm(forms.ModelForm):

	def __init__(self, *args, **kwargs):
		super().__init__(*args, **kwargs)
		self.fields['fiscal_year'].widget.choices = self.fiscal_years_choices()

	class Meta:
		model = models.VacationsRequest
		exclude = ['user', 'request_date', 'status']
		widgets = {
			'fiscal_year': widgets.Select(),
		}

	def current_date(self):
		return datetime.date.today()

	def fiscal_years_choices(self):
		current_date = self.current_date()
		years = vacations.fiscal_years_for_date(current_date)
		return [(y,y) for y in years]

Observa que solamente tienes que cambiar el widget que viene por defecto (una entrada) para seleccionarlo. Lo completas con valores válidos y así será imposible que un usuario proporcione un año inválido. Ten en cuenta que los valores vienen de la capa del dominio. El servicio de dominio vacations.fiscal_years_for_date (que aquí es una función simple) es todo lo que necesitas. Si esto cambia, solamente tendrás que modificar las reglas del dominio. La lógica puede ser tan compleja como necesites. Esto es un mero ejemplo, pero entenderás la idea.

Este fragmento de código es el adaptador de dominio. Un adaptador no necesita un desarrollo grande y costoso. A veces con una función ya será suficiente. Aquí estamos usando una clase que extiende parte del marco de trabajo que estamos usando. Y es barato. La clave es que el modelo de dominio permanece aislado en su propio paquete. Y este formulario de django (y la vista y la plantilla que hemos visto antes) son solamente un pequeño adaptador HTTP para él. Y, tal y como prometimos, añadimos este comportamiento completo de dominio sin tener que tocar los conductos. ¡La vista y la plantilla siguen siendo las mismas!

Django Forms as Domain Adapters - CAPSiDE

Lo quiero todo

Nuestra aplicación es ahora más robusta, permitiendo que los formularios restrinjan las entradas de los usuarios. Otro caso de uso es validar que las entradas cumplen con todas las políticas de la empresa.

Eso lo podemos hacer con formularios de django enganchándonos al método cleandel formulario. Digamos que solo se nos permite pedir vacaciones futuras, solamente podemos validarlo después de que el usuario haya presentado datos válidos.

De nuevo, aplicamos la lógica en su correspondiente módulo dentro del dominio

def date_should_be_greater_than_request_date(date, request_date):	
	return date > request_date

y, por supuesto, los tests que lo acompañan:

class VacationsRequestDate(TestCase):

	daydelta = datetime.date(2018, 1, 2) - datetime.date(2018,1,1)

	def test_date_should_be_greater_than_request_date(self):
		date = random_date()
		request_date = date - self.daydelta
		self.assertTrue(vacations.date_should_be_greater_than_request_date(date, request_date))

De nuevo, django nos ayuda. Podemos hacer caso omiso del limpio método del formulario. Allí podemos validar las políticas de empresa adicionales con datos válidos. Aprovechando la validación y conversión de datos en la implementación predeterminada del formulario de django.

def clean(self):
		cleaned_data = super().clean()
		date = cleaned_data.get('date')
		if date is not None:
			request_date = self.current_date()
			if not vacations.date_should_be_greater_than_request_date(date, request_date):
				raise ValidationError(_('You can not ask vacations for days in the past'), code='invalid')

Siguiendo a django, recurrimos a una excepción ValidationError para hacer saber al marco del formulario que hay datos inválidos. De esta manera, django lo gestionará de forma nativa y tramitará los informes de errores. Si ya hemos personalizado la apariencia de los errores de formulario, no tenemos que hacerlo de nuevo. Y para el usuario será limpio y ordenado, ya que los errores se reportarán uniformemente a través de la aplicación.

Django Forms as Domain Adapters - CAPSiDE

¿Valió la pena?

Así de simple, el valor de introducir estas reglas en el dominio es que

Django Forms as Domain Adapters - CAPSiDE

Utilizando formularios de django como adaptadores de dominio – El espectáculo debe continuar

Hemos aportado un ejemplo sencillo para enseñarte a usar formularios de django como adaptadores de dominio. Utilizar un marco con un conjunto enorme de instalaciones fue motivo de preocupación al principio, ya que no estábamos seguros de que se pudiera mantener fuera de nuestro camino. Pero hasta ahora nuestra experiencia ha sido muy positiva. Hemos visto que DDD nos funciona y que con las herramientas adecuadas ya no tienes excusa para modificar código por tu cuenta.

La arquitectura es limpia y consistente y, una vez veas que los formularios de django no son otra cosa que adaptadores de dominio, podrías incluso hacerlo explícito poniéndolos en un paquete de adapters. A django no le importará, pero a ti seguramente sí. Y te importará aún más dentro de seis meses.

Recuerda, tú eres el que manda y django y DDD son solamente herramientas. ¡Pero qué geniales son!

Y para terminar, aquí está el adaptador entero:

from django import forms
from django.core.exceptions import ValidationError
from django.utils.translation import ugettext_lazy as _
import datetime

import django.forms.widgets as native
import capside.calendar.widgets as widgets

from capside.calendar import models
from capside.calendar import domain
from capside.infrastructure.errors import DomainException

class RequestVacationsForm(forms.Form):
	start_date = forms.DateField(widget=widgets.DateInput())
	end_date = forms.DateField(widget=widgets.DateInput())
	type = forms.ChoiceField(choices=domain.absence_request.ABSENCE_TYPES, widget=widgets.Select())
	fiscal_year = forms.IntegerField(widget=widgets.Select())

	def __init__(self, *args, **kwargs):
		self.user = kwargs.pop('user', None)
		super().__init__(*args, **kwargs)
		self.fields['fiscal_year'].widget.choices = self.fiscal_years_choices()

	def current_date(self):
		return datetime.date.today()

	def fiscal_years_choices(self):
		years = domain.absence_request.fiscal_years_for_date(self.current_date())
		return [(y,y) for y in years]

	def absence_request(self):
		cleaned_data = super().clean()
		return domain.absence_request.AbsenceRequest(
			user = self.user,
			start_date = cleaned_data.get('start_date'),
			end_date = cleaned_data.get('end_date'),
			type = cleaned_data.get('type'),
			fiscal_year = cleaned_data.get('fiscal_year'),
			request_date = self.current_date(),

		)

	def clean(self):
		cleaned_data = super().clean()
		absence_request = self.absence_request()
		try:
			absence_request.validate()
		except DomainException as e:
			raise ValidationError(e, code='invalid')

TAGS: Adaptadores de dominio, DDD, django, Domain Driven Design, Labs, Marcos de trabajo

speech-bubble-13-icon Created with Sketch.
Comentarios

Deja un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

*
*