Desarrollo de Microservicios Serverless con AWS Lambda y la Arquitectura Hexagonal
Este tutorial explora cómo diseñar y construir microservicios serverless utilizando AWS Lambda, adoptando la Arquitectura Hexagonal para desacoplar la lógica de negocio de los detalles técnicos. Aprenderás a crear aplicaciones escalables, probables y mantenibles en la nube.
🚀 Introducción a los Microservicios Serverless y la Arquitectura Hexagonal
En el mundo del desarrollo de software moderno, la eficiencia, la escalabilidad y la mantenibilidad son pilares fundamentales. Los microservicios serverless, particularmente con plataformas como AWS Lambda, ofrecen una forma poderosa de construir aplicaciones que son inherentemente escalables y de pago por uso. Sin embargo, la construcción de estos microservicios no debe descuidar una buena arquitectura que garantice su longevidad y facilite su evolución. Aquí es donde entra en juego la Arquitectura Hexagonal (también conocida como Ports and Adapters).
Este tutorial te guiará a través del proceso de diseño y desarrollo de microservicios serverless en AWS Lambda, integrando los principios de la Arquitectura Hexagonal para asegurar una separación clara de preocupaciones, haciendo tus servicios más robustos, probables y adaptables a cambios futuros. Exploraremos cómo los ports y adapters pueden coexistir armoniosamente con el paradigma serverless.
🧐 ¿Por qué Arquitectura Hexagonal para Serverless?
La Arquitectura Hexagonal, propuesta por Alistair Cockburn, busca aislar el core de la lógica de negocio de las influencias externas, como bases de datos, APIs de terceros, frameworks web o interfaces de usuario. Esto se logra definiendo puertos (interfaces) que el dominio de la aplicación utiliza para interactuar con el exterior, y adaptadores que implementan esos puertos para un componente externo específico.
🎯 Beneficios en un Contexto Serverless
La combinación de serverless y arquitectura hexagonal ofrece ventajas significativas:
- Desacoplamiento: El corazón de tu lógica de negocio serverless permanece agnóstico a la infraestructura (AWS Lambda, S3, DynamoDB, API Gateway). Esto significa que podrías, en teoría, cambiar de proveedor cloud o de base de datos con menos impacto en tu código principal.
- Testeabilidad: Al desacoplar la lógica de negocio de los detalles de infraestructura, se vuelve mucho más fácil realizar pruebas unitarias y de integración. Puedes mockear fácilmente los adaptadores para simular las interacciones externas sin necesidad de desplegar en la nube.
- Mantenibilidad: Una estructura clara y modular reduce la complejidad. Los desarrolladores pueden entender y modificar partes específicas del sistema sin afectar otras, lo que es crucial en equipos grandes y para la vida útil de la aplicación.
- Flexibilidad: Adaptarse a nuevos requisitos o cambiar tecnologías externas (e.g., de DynamoDB a RDS) se vuelve menos traumático, ya que solo necesitas reemplazar un adaptador, no reescribir la lógica de negocio.
- Escalabilidad: Si bien AWS Lambda ya proporciona escalabilidad, una arquitectura limpia ayuda a que el código en sí mismo sea más eficiente y menos propenso a cuellos de botella lógicos, optimizando aún más el rendimiento de las funciones.
🧱 Componentes Clave de la Arquitectura Hexagonal en Serverless
Para aplicar la Arquitectura Hexagonal a un microservicio serverless, identificamos los siguientes componentes principales:
1. Dominio/Core de la Aplicación (El Hexágono)
Es el centro de tu aplicación, donde reside toda la lógica de negocio pura y las reglas de tu problema. Es independiente de cualquier tecnología o infraestructura externa. Contiene:
- Entidades de Dominio: Representan los conceptos clave de tu negocio (e.g.,
Usuario,Producto,Pedido). - Casos de Uso (Application Services): Orquestan las operaciones sobre las entidades de dominio. Son los ports primarios. Definen qué puede hacer la aplicación (e.g.,
CrearUsuario,ActualizarPedido).
2. Puertos (Interfaces)
Los puertos son interfaces que el dominio utiliza para comunicarse con el mundo exterior o viceversa. Hay dos tipos principales:
- Puertos Impulsadores (Driving Ports / Inbound Ports): Son las interfaces que definen cómo el mundo exterior impulsará la aplicación. Por ejemplo, una interfaz para un controller API que el dominio espera que se implemente para iniciar un caso de uso.
- Puertos Impulsados (Driven Ports / Outbound Ports): Son las interfaces que el dominio impulsa para interactuar con componentes externos, como una base de datos, un sistema de mensajería o un servicio externo. Por ejemplo, una interfaz
UserRepositoryque el dominio utiliza para guardar o recuperar usuarios.
3. Adaptadores (Implementaciones)
Los adaptadores son las implementaciones concretas de los puertos. Convierten la interacción específica de una tecnología externa a la interfaz genérica que el dominio espera.
- Adaptadores Impulsadores (Driving Adapters): Implementan los puertos impulsadores. Son la forma en que el mundo exterior se conecta al dominio. En AWS Lambda, esto podría ser un handler de Lambda que recibe un evento de API Gateway y lo traduce en una llamada a un caso de uso del dominio.
- Adaptadores Impulsados (Driven Adapters): Implementan los puertos impulsados. Permiten que el dominio se comunique con sistemas externos. Por ejemplo, un adaptador
DynamoDBUserRepositoryque implementaUserRepositoryy sabe cómo interactuar con DynamoDB.
Este diagrama ilustra la separación de preocupaciones. El hexágono (dominio) no tiene conocimiento directo de API Gateway o DynamoDB; solo conoce las interfaces (puertos) que necesita para operar.
🛠️ Manos a la Obra: Construyendo un Microservicio con AWS Lambda y Arquitectura Hexagonal
Vamos a construir un microservicio simple de Gestión de Tareas que permite crear y listar tareas. Utilizaremos Python como lenguaje de programación, pero los conceptos son aplicables a cualquier lenguaje.
1. ⚙️ Configuración del Proyecto
Crearemos una estructura de directorios que refleje la separación de la Arquitectura Hexagonal:
serverless-tasks/
├── src/
│ ├── domain/
│ │ ├── entities.py
│ │ ├── ports.py
│ │ └── use_cases.py
│ ├── infrastructure/
│ │ ├── adapters/
│ │ │ ├── db/
│ │ │ │ └── dynamodb_task_repository.py
│ │ │ └── driving/
│ │ │ └── lambda_api_handler.py
│ │ └── config/
│ │ └── settings.py
│ └── application/
│ └── bootstrap.py # Orquestador de dependencias
├── serverless.yml
├── requirements.txt
└── event.json
2. 📖 Definición del Dominio (Core del Hexágono)
src/domain/entities.py
Aquí definimos nuestra entidad Task. Una tarea tendrá un ID, título, descripción y un estado.
from dataclasses import dataclass
from enum import Enum
class TaskStatus(Enum):
PENDING = "PENDING"
COMPLETED = "COMPLETED"
@dataclass
class Task:
id: str
title: str
description: str
status: TaskStatus = TaskStatus.PENDING
def complete(self):
self.status = TaskStatus.COMPLETED
def to_dict(self):
return {
"id": self.id,
"title": self.title,
"description": self.description,
"status": self.status.value
}
@staticmethod
def from_dict(data: dict):
return Task(
id=data["id"],
title=data["title"],
description=data["description"],
status=TaskStatus(data["status"])
)
src/domain/ports.py
Definimos las interfaces (puertos) que nuestro dominio necesitará para interactuar con el mundo exterior. En este caso, un repositorio de tareas.
from abc import ABC, abstractmethod
from typing import List, Optional
from src.domain.entities import Task
# Puerto Impulsado (Driven Port): El dominio necesita un repositorio para persistir/recuperar tareas
class TaskRepository(ABC):
@abstractmethod
def save(self, task: Task) -> None:
pass
@abstractmethod
def get_by_id(self, task_id: str) -> Optional[Task]:
pass
@abstractmethod
def get_all(self) -> List[Task]:
pass
# Puerto Impulsador (Driving Port): El dominio expone un servicio para interactuar con tareas
# Aunque en Python no siempre se definen interfaces explícitas para driving ports
# (se usan directamente los casos de uso), se podría definir una si fuera necesario
# para mayor formalidad o para otros lenguajes.
# Aquí, los casos de uso actúan como la interfaz directamente.
src/domain/use_cases.py
Aquí se implementa la lógica de negocio real. Estos son los casos de uso o servicios de aplicación.
import uuid
from typing import List, Optional
from src.domain.entities import Task
from src.domain.ports import TaskRepository
class CreateTaskUseCase:
def __init__(self, task_repository: TaskRepository):
self.task_repository = task_repository
def execute(self, title: str, description: str) -> Task:
task_id = str(uuid.uuid4())
new_task = Task(id=task_id, title=title, description=description)
self.task_repository.save(new_task)
return new_task
class GetTaskUseCase:
def __init__(self, task_repository: TaskRepository):
self.task_repository = task_repository
def execute(self, task_id: str) -> Optional[Task]:
return self.task_repository.get_by_id(task_id)
class ListTasksUseCase:
def __init__(self, task_repository: TaskRepository):
self.task_repository = task_repository
def execute(self) -> List[Task]:
return self.task_repository.get_all()
class CompleteTaskUseCase:
def __init__(self, task_repository: TaskRepository):
self.task_repository = task_repository
def execute(self, task_id: str) -> Optional[Task]:
task = self.task_repository.get_by_id(task_id)
if task:
task.complete()
self.task_repository.save(task) # Persistir el cambio de estado
return task
3. 💾 Implementación de Infraestructura (Adaptadores)
src/infrastructure/config/settings.py
Para manejar variables de entorno y configuraciones.
import os
class Settings:
TASK_TABLE_NAME: str = os.getenv("TASK_TABLE_NAME", "tasks-table")
settings = Settings()
src/infrastructure/adapters/db/dynamodb_task_repository.py
Este es un Adaptador Impulsado. Implementa el TaskRepository usando AWS DynamoDB. Aquí es donde se manejan los detalles específicos de DynamoDB.
import os
import boto3
from botocore.exceptions import ClientError
from typing import List, Optional
from src.domain.entities import Task, TaskStatus
from src.domain.ports import TaskRepository
from src.infrastructure.config.settings import settings
class DynamoDBTaskRepository(TaskRepository):
def __init__(self):
self.dynamodb = boto3.resource('dynamodb')
self.table = self.dynamodb.Table(settings.TASK_TABLE_NAME)
def save(self, task: Task) -> None:
try:
self.table.put_item(Item=task.to_dict())
except ClientError as e:
print(f"Error saving task to DynamoDB: {e.response['Error']['Message']}")
raise
def get_by_id(self, task_id: str) -> Optional[Task]:
try:
response = self.table.get_item(Key={'id': task_id})
item = response.get('Item')
if item:
return Task.from_dict(item)
return None
except ClientError as e:
print(f"Error getting task from DynamoDB: {e.response['Error']['Message']}")
raise
def get_all(self) -> List[Task]:
try:
response = self.table.scan()
items = response.get('Items', [])
return [Task.from_dict(item) for item in items]
except ClientError as e:
print(f"Error scanning tasks from DynamoDB: {e.response['Error']['Message']}")
raise
src/infrastructure/adapters/driving/lambda_api_handler.py
Este es un Adaptador Impulsador. Es el punto de entrada de AWS Lambda/API Gateway. Recibe eventos HTTP y los traduce en llamadas a nuestros casos de uso. Luego, traduce la respuesta del dominio a un formato HTTP.
import json
import os
from src.application.bootstrap import bootstrap_dependencies
from src.domain.entities import TaskStatus
# Inicializar las dependencias una sola vez para la duración de la instancia de Lambda
# Esto es importante para el warm start de las funciones serverless
container = bootstrap_dependencies()
def lambda_handler(event, context):
http_method = event['httpMethod']
path = event['path']
try:
if http_method == 'POST' and path == '/tasks':
body = json.loads(event['body'])
title = body.get('title')
description = body.get('description')
if not title or not description:
return {
'statusCode': 400,
'body': json.dumps({'message': 'Title and description are required'})
}
create_task_use_case = container.resolve("create_task_use_case")
new_task = create_task_use_case.execute(title, description)
return {
'statusCode': 201,
'body': json.dumps(new_task.to_dict())
}
elif http_method == 'GET' and path == '/tasks':
list_tasks_use_case = container.resolve("list_tasks_use_case")
tasks = list_tasks_use_case.execute()
return {
'statusCode': 200,
'body': json.dumps([task.to_dict() for task in tasks])
}
elif http_method == 'GET' and path.startswith('/tasks/'):
task_id = path.split('/')[-1]
get_task_use_case = container.resolve("get_task_use_case")
task = get_task_use_case.execute(task_id)
if task:
return {
'statusCode': 200,
'body': json.dumps(task.to_dict())
}
return {
'statusCode': 404,
'body': json.dumps({'message': 'Task not found'})
}
elif http_method == 'PUT' and path.startswith('/tasks/') and path.endswith('/complete'):
task_id = path.split('/')[-2] # Assuming path is /tasks/{id}/complete
complete_task_use_case = container.resolve("complete_task_use_case")
completed_task = complete_task_use_case.execute(task_id)
if completed_task:
return {
'statusCode': 200,
'body': json.dumps(completed_task.to_dict())
}
return {
'statusCode': 404,
'body': json.dumps({'message': 'Task not found'})
}
return {
'statusCode': 404,
'body': json.dumps({'message': 'Not Found'})
}
except Exception as e:
print(f"Error processing request: {e}")
return {
'statusCode': 500,
'body': json.dumps({'message': 'Internal Server Error'})
}
4. ✨ Orquestación de Dependencias (Bootstrap)
src/application/bootstrap.py
Este módulo es responsable de cablear las dependencias, es decir, de inyectar las implementaciones concretas de los adaptadores en los casos de uso del dominio. Esto se hace una vez al inicio de la aplicación (o de la función Lambda).
class DependencyContainer:
def __init__(self):
self._dependencies = {}
def register(self, name, instance):
self._dependencies[name] = instance
def resolve(self, name):
if name not in self._dependencies:
raise ValueError(f"Dependency '{name}' not registered.")
return self._dependencies[name]
def bootstrap_dependencies() -> DependencyContainer:
from src.domain.use_cases import CreateTaskUseCase, GetTaskUseCase, ListTasksUseCase, CompleteTaskUseCase
from src.infrastructure.adapters.db.dynamodb_task_repository import DynamoDBTaskRepository
container = DependencyContainer()
# Registrar adaptadores
task_repository = DynamoDBTaskRepository()
container.register("task_repository", task_repository)
# Registrar casos de uso, inyectando los repositorios
container.register("create_task_use_case", CreateTaskUseCase(task_repository=task_repository))
container.register("get_task_use_case", GetTaskUseCase(task_repository=task_repository))
container.register("list_tasks_use_case", ListTasksUseCase(task_repository=task_repository))
container.register("complete_task_use_case", CompleteTaskUseCase(task_repository=task_repository))
return container
5. ☁️ Despliegue con Serverless Framework
Utilizaremos Serverless Framework para definir y desplegar nuestra infraestructura. Asegúrate de tenerlo instalado (npm install -g serverless).
serverless.yml
service: serverless-hexagonal-tasks
frameworkVersion: '3'
provider:
name: aws
runtime: python3.9
region: us-east-1
stage: dev
environment:
TASK_TABLE_NAME: ${self:custom.tableName}
iam:
role: # Definimos un rol de IAM específico para nuestra función
statements:
- Effect: Allow
Action:
- dynamodb:GetItem
- dynamodb:PutItem
- dynamodb:UpdateItem
- dynamodb:Scan
Resource: !GetAtt TasksTable.Arn
custom:
tableName: tasks-table-${sls:stage}
pacakge:
individually: true
patterns:
- 'src/**'
functions:
apiHandler:
handler: src/infrastructure/adapters/driving/lambda_api_handler.lambda_handler
events:
- http:
path: tasks
method: post
cors: true
- http:
path: tasks
method: get
cors: true
- http:
path: tasks/{id}
method: get
cors: true
- http:
path: tasks/{id}/complete
method: put
cors: true
resources:
Resources:
TasksTable:
Type: AWS::DynamoDB::Table
Properties:
TableName: ${self:custom.tableName}
AttributeDefinitions:
- AttributeName: id
AttributeType: S
KeySchema:
- AttributeName: id
KeyType: HASH
BillingMode: PAY_PER_REQUEST
plugins:
- serverless-python-requirements
pacakge:
individually: true
excludeDevDependencies: true
include:
- src/**
requirements.txt
boto3
Para desplegar el servicio, ejecuta:
serverless deploy
Esto creará tu función Lambda, una tabla DynamoDB y los endpoints de API Gateway.
6. 🧪 Pruebas del Microservicio
Una vez desplegado, puedes probar tu microservicio usando curl o Postman.
Crear una tarea (POST /tasks):
curl -X POST <API_GATEWAY_URL>/tasks \
-H "Content-Type: application/json" \
-d '{"title": "Aprender Arquitectura Hexagonal", "description": "Leer el tutorial y practicar."}'
Listar tareas (GET /tasks):
curl -X GET <API_GATEWAY_URL>/tasks
Obtener una tarea por ID (GET /tasks/{id}):
curl -X GET <API_GATEWAY_URL>/tasks/YOUR_TASK_ID
Completar una tarea (PUT /tasks/{id}/complete):
curl -X PUT <API_GATEWAY_URL>/tasks/YOUR_TASK_ID/complete
💡 Consideraciones Avanzadas y Mejores Prácticas
1. Manejo de Errores y Validaciones
Extiende la validación de entrada de datos en tu adaptador impulsador (Lambda handler) y en tus casos de uso. Implementa un manejo de errores robusto que devuelva mensajes claros y códigos de estado HTTP apropiados.
2. Logging y Monitoreo
Utiliza CloudWatch Logs para registrar la actividad de tu función Lambda. Asegúrate de que tus logs contengan información relevante para la depuración y el monitoreo, especialmente en los adaptadores, para ver las interacciones con sistemas externos.
3. Seguridad
- IAM Roles: Asegúrate de que tu función Lambda tenga los permisos de IAM mínimos necesarios (Principio de Mínimo Privilegio). Como se vio en
serverless.yml. - Validación de Entrada: Siempre valida y sanitiza las entradas de usuario para prevenir inyecciones y otros ataques.
- Secretos: No codifiques secretos directamente. Utiliza AWS Secrets Manager o AWS Systems Manager Parameter Store para gestionar credenciales y otra información sensible.
4. Pruebas Automatizadas
La Arquitectura Hexagonal brilla en la testeabilidad. Deberías tener:
- Pruebas Unitarias: Para las entidades de dominio y los casos de uso, sin dependencias externas.
- Pruebas de Integración: Para los adaptadores, verificando que interactúan correctamente con DynamoDB, API Gateway, etc. Puedes usar mocks para simular la capa de dominio en estas pruebas.
Ejemplo de prueba unitaria para un caso de uso
import unittest
from unittest.mock import Mock
from src.domain.entities import Task, TaskStatus
from src.domain.use_cases import CreateTaskUseCase, GetTaskUseCase
class TestTaskUseCases(unittest.TestCase):
def setUp(self):
self.mock_repo = Mock()
self.create_task_use_case = CreateTaskUseCase(self.mock_repo)
self.get_task_use_case = GetTaskUseCase(self.mock_repo)
def test_create_task(self):
title = "Test Task"
description = "Description for test task"
new_task = self.create_task_use_case.execute(title, description)
self.assertIsNotNone(new_task.id)
self.assertEqual(new_task.title, title)
self.assertEqual(new_task.description, description)
self.assertEqual(new_task.status, TaskStatus.PENDING)
self.mock_repo.save.assert_called_once()
def test_get_task_by_id(self):
task_id = "123"
expected_task = Task(id=task_id, title="Existing Task", description="Desc")
self.mock_repo.get_by_id.return_value = expected_task
task = self.get_task_use_case.execute(task_id)
self.assertEqual(task, expected_task)
self.mock_repo.get_by_id.assert_called_with(task_id)
def test_get_non_existent_task(self):
self.mock_repo.get_by_id.return_value = None
task = self.get_task_use_case.execute("non-existent-id")
self.assertIsNone(task)
5. Event-Driven Architecture (EDA)
Para microservicios más complejos, considera extender la arquitectura con eventos. Por ejemplo, al completar una tarea, el dominio podría emitir un evento TaskCompletedEvent que sería manejado por un adaptador de mensajería (e.g., SQS o EventBridge) para notificar a otros servicios.
6. Infraestructura como Código (IaC)
Continúa utilizando Serverless Framework o AWS CDK para gestionar tu infraestructura. Esto garantiza que tus despliegues sean repetibles y consistentes.
conclusiones ✨
La combinación de microservicios serverless con AWS Lambda y la Arquitectura Hexagonal es una receta poderosa para construir aplicaciones escalables, resilientes y fáciles de mantener. Al mantener una clara separación entre tu lógica de negocio y los detalles de infraestructura, creas un sistema más adaptable a los cambios tecnológicos y de negocio. Este enfoque promueve un código más limpio, una mejor testeabilidad y una mayor agilidad en el desarrollo.
Aunque implica una inversión inicial en el diseño y la estructura del código, los beneficios a largo plazo en términos de mantenibilidad y flexibilidad superan con creces el esfuerzo, especialmente para proyectos de tamaño mediano a grande. ¡Anímate a aplicar estos principios en tus próximos proyectos serverless!
Tutoriales relacionados
- Despliegue de APIs Serverless con AWS Lambda y API Gateway: Una Guía Prácticaintermediate15 min
- Gestionando el Estado en Aplicaciones Serverless con AWS Step Functions y DynamoDBintermediate20 min
- Orquestación de Flujos de Trabajo Serverless con AWS Step Functions: Guía Completaintermediate18 min
- Implementando Contenedores Serverless con AWS Fargate: Una Guía Detalladaintermediate25 min
- Simplificando la Orquestación de Contenedores Serverless con AWS App Runner: Guía Prácticaintermediate18 min
Comentarios (0)
Aún no hay comentarios. ¡Sé el primero!