tutoriales.com

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.

Intermedio25 min de lectura8 views
Reportar error

🚀 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.

💡 Consejo: Familiarizarse con los conceptos básicos de AWS Lambda y la Arquitectura Hexagonal te ayudará a comprender mejor este tutorial.

🧐 ¿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.
⚠️ Advertencia: Implementar Arquitectura Hexagonal introduce un poco más de *boilerplate* inicial. Es importante equilibrar los beneficios con la complejidad para proyectos pequeños o prototipos.

🧱 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 UserRepository que 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 DynamoDBUserRepository que implementa UserRepository y sabe cómo interactuar con DynamoDB.
Arquitectura Hexagonal Serverless Dominio de la Aplicación Entidades Casos de Uso Puerto API Adaptador API Gateway/ Lambda Puerto BD Adaptador DynamoDB Puerto Mensajería Adaptador SQS Impulsadores Impulsados

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
📌 Nota: Los casos de uso dependen de la interfaz `TaskRepository`, no de una implementación concreta. Esto es clave para el desacoplamiento.

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
🔥 Importante: La inyección de dependencias en `bootstrap.py` se realiza *antes* de que la función Lambda sea invocada. Esto optimiza el tiempo de ejecución de las invocaciones posteriores (cold start vs warm start).

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
90% Completado

💡 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.

Microservicio de Tareas TaskCompleted Event Adaptador SQS Publica Cola SQS Consume Lambda de Notificación Envía Email AWS SES Event-Driven Architecture Flow

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!

💡 Consejo Final: La Arquitectura Hexagonal es un conjunto de principios, no un dogma rígido. Adáptala a las necesidades específicas de tu proyecto y equipo.

Tutoriales relacionados

Comentarios (0)

Aún no hay comentarios. ¡Sé el primero!