Preparar aplicativos para una migración de región: código legacy y parametrización
Migrar aplicativos a una nueva región puede romper más cosas de las esperadas.
En nuestro blog anterior, “Cómo redujimos el costo en 70%”, describimos cómo migramos la infraestructura de Fork desde São Paulo hasta Ohio. Este artículo cubre la capa técnica de ese proceso: el código.
Una migración de región presenta varias capas de complejidad: conectividad interna, integración con terceros, sincronización de datos y funcionamiento de los aplicativos. Los primeros tres son resueltos con una configuración correcta, y los LLMs ayudan a detectar la mayoría de estos problemas.
Sin embargo, el funcionamiento de los aplicativos suele ser lo más difícil y este caso no fue distinto. Los aplicativos de Fork estaban escritos en una versión de Java que Elastic Beanstalk ya no permite desplegar, lo que hizo que la migración del código fuera un requisito, no una opción.
Cómo migrar código legacy sin morir en el intento

Un buen comienzo es intentar compilar el proyecto. En este caso, debido a que la aplicación está escrita en Java, algunas aplicaciones se compilan con Maven y otras con Gradle. Cualquiera fuera el caso, los cambios de versiones son un cambio simple de código pero con implicancias que afectan todo el árbol de dependencias del framework:
Maven — pom.xml
<!-- Antes -->
<properties>
<java.version>X.X</java.version>
</properties>
<!-- Después -->
<properties>
<java.version>21</java.version>
</properties>
Gradle — build.gradle (Groovy) / build.gradle.kts (Kotlin)
// Antes
java {
sourceCompatibility = JavaVersion.VERSION_X_X
targetCompatibility = JavaVersion.VERSION_X_X
}
// Después
java {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}
Además de eso, es importante considerar el framework en el cual está escrita la app. Si el framework está en una versión incompatible con la versión del runtime, también debe ser actualizado. Esto implica que el framework, por debajo del capó, actualiza todas sus librerías internas lo que rompe la aplicación y su funcionamiento.
<!-- Antes -->
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>X.X.X.RELEASE</version>
</parent>
<!-- Después -->
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.5</version>
</parent>
Con tan solo estas pocas líneas de código hemos actualizado el compilador + framework y hemos roto todas las aplicaciones originales. Lo importante es entender de qué formas distintas estos quiebres se reflejan en errores (o ausencia de) y los principales métodos para solucionar estos.
Namespace de paquetes distintos
Comencemos la limpieza de los aplicativos entendiendo el siguiente tipo de error:
error: package javax.servlet does not exist
error: package javax.persistence does not exist
error: cannot find symbol: class HttpServletRequest
Un error del estilo "package does not exist" es una pista clara de que ocurrió un cambio de más alto nivel que requiere reestructuración y ajuste a los endpoints existentes.
Independiente del motivo, a veces los namespaces de los paquetes cambian requiriendo un ajuste del import como también rediseños mayores cuando los métodos cambian en su funcionalidad. Para los aplicativos de Fork por ejemplo, el paquete "javax" tuvo que ser migrado a "jakarta" lo que, a pesar de introducir una capa de cambios en las aplicaciones, cuenta con documentación clara sobre cómo realizarlos:
En estricto rigor, en una vista de alto nivel, los cambios de este tipo de problemas se abordan inicialmente con la actualización en los imports, seguido de la limpieza de los errores que puedan ir surgiendo en base a ello:
// Antes
import javax.servlet.http.HttpServletRequest;
import javax.persistence.Entity;
// Después
import jakarta.servlet.http.HttpServletRequest;
import jakarta.persistence.Entity;
Compatibilidad de versiones
Cuando una librería desaparece en una nueva versión, casi siempre existe un reemplazo. Los frameworks documentan estos cambios en sus changelogs porque les conviene que el usuario migre en lugar de buscar otra alternativa. Ubicar ese changelog es el primer paso para entender qué aspectos claves se rompen debido a las nuevas versiones y qué alternativas existen:
A veces no basta con actualizar referencias e importaciones, sino que hay que cambiar de raíz la librería utilizada para solucionar un problema. Sin embargo, el argumento previo de buscar soluciones dentro del mismo changelog o documentación del framework se sostiene igual. Por ejemplo, la librería Springfox perdió soporte en 2020 y es incompatible con las versiones actuales post-migración. Su propósito es documentar la API, por lo que buscar una alternativa es un trabajo directo de elegir otra solución con un diseño similar de tal forma que solo realizemos cambios mínimos. En este caso bastó con acudir a la documentación oficial para encontrar la solución recomendada:
En la práctica, el cambio es tan directo como esto:
// Antes — Springfox
@Configuration
@EnableSwagger2
public class SwaggerConfig {
@Bean
public Docket apiDocket() {
return new Docket(DocumentationType.SWAGGER_2)
.select().apis(RequestHandlerSelectors.basePackage("com.example")).build();
}
}
// Después — SpringDoc
@Configuration
public class SwaggerConfig {
@Bean
public OpenAPI apiInfo() {
return new OpenAPI()
.info(new Info().title("Service Name").version("1.0.0"));
}
}
En donde la principal consecuencia es un cambio en la URL de la documentación:
| Antes | Después |
|---|---|
/swagger-ui.html |
/swagger-ui/index.html |
/v2/api-docs |
/v3/api-docs |
Que a su vez implica actualizar la configuración de seguridad para permitir que las nuevas URLs tengan el mismo acceso que las antiguas.
Cambios de comportamientos default
Ya abordamos los errores grandes y claros que reclama el compilador, como "package does not exist" o endpoints que responden 500. El caso más difícil es el opuesto: el compilador acepta el código sin queja, pero en tiempo de ejecución el endpoint lanza 403 (u otro error) sin explicación aparente.
Actualizar un aplicativo puede llegar a romper varios supuestos. Parámetros implícitos que debían comportarse de cierta manera ahora pueden exigir configuración explícita para lograr lo mismo. El compilador no ayuda en estos casos y lo único que puede garantizar comportamientos homogéneos a través de la migración son baterías de tests.
Este mismo fenómeno no solo vive en código de aplicativo sino también en sintaxis de queries escritas bajo el framework. Por ejemplo, en el cambio de Hibernate 5 (el ORM de Spring Boot) hacia la versión 6 podemos notar el siguiente problema:
// Antes — Hibernate 5 toleraba omitir SELECT
@Query("FROM unaTabla t WHERE t.unID = ?123")
// Después — Hibernate 6 requiere SELECT explícito
@Query("SELECT t FROM unaTabla t WHERE t.unID = ?123")
Esto no es deuda técnica. El código compilaba y funcionó correctamente durante años. Lo que cambia es el contrato implícito del framework al actualizar versión.
El compilador no reclama esto porque para él ambas queries son texto plano. Solo en runtime, cuando el endpoint relevante recibe un HTTP Request, se lanza la excepción. En el mejor caso, un test captura esto rápidamente. En un mal día, el endpoint puede ser uno relacionado a reportería mensual, y en dichos casos podría ser un error silencioso durante mucho tiempo antes de alertar a alguien.
Cuando el problema no está en tu código sino en quien lo compila
Existe otra categoría de fallo: los "annotation processors", herramientas que generan código en base a metadata y que pueden romperse en silencio al actualizar el compilador. Lombok es el caso más común, genera automáticamente getters y setters a partir de anotaciones. El error que aparece es:
error: cannot find symbol
symbol: method getName()
Mientras que el código se ve así:
// Esto es TODO lo que existe en tu archivo .java
@Data
public class User {
private String name;
private String email;
}
El método no existe en el archivo. Se crea en tiempo de compilación. La pista principal en estos casos es entender quién llama al método, ya que se llega a definiciones de clase sin métodos declarados.
Finalmente, la metodología de resolución es similar a los casos anteriores. En el caso de Lombok, podemos encontrar un changelog que referencia los problemas de versiones que encontramos:
La causa raíz es que versiones de Lombok anteriores a 1.18.34 son incompatibles con Java 21. El sistema de módulos de Java (JPMS), introducido en Java 9, restringe el acceso a código interno del compilador que Lombok necesita para generar código. La solución tiene dos pasos.
Primero, actualizar la versión en pom.xml:
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.34</version>
<scope>provided</scope>
</dependency>
Segundo, crear el archivo .mvn/jvm.config en la raíz del proyecto con los flags que abren los módulos necesarios:
--add-opens jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED
--add-opens jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED
Sin este archivo, Lombok lanza ExceptionInInitializerError al intentar acceder a las estructuras internas del compilador, lo que resulta en los errores cannot find symbol sobre todos los getters y setters generados.
Métodos de parametrización

Habiendo resuelto el funcionamiento de los aplicativos, nos queda la segunda capa del problema. Parametrizar para lograr la migración sin perder la interconexión de sistemas.
Una startup en etapa temprana suele priorizar velocidad, lo que introduce valores hardcodeados a través de toda la aplicación: URLs, tokens, IPs. Al migrar de región, todas estas referencias se rompen en cascada. Parametrizar el 100% de estos valores es un requisito para que la migración sea viable.
Identificación de parámetros hardcodeados
Antes de mover cualquier valor a un sistema de parametrización, hay que saber qué existe. Un script dirigido sobre el código fuente es suficiente para construir un inventario completo.
Los servicios internos de AWS que más frecuentemente aparecen hardcodeados son aquellos cuya URL incluye la región. Por lo tanto, cualquier migración de región los rompe automáticamente:
| Servicio | Patrón de URL | Por qué cambia al migrar |
|---|---|---|
| API Gateway | <id>.execute-api.<region>.amazonaws.com |
El ID y la región están en la URL |
| RDS | <nombre>.<id>.<region>.rds.amazonaws.com |
Endpoint único por instancia y región |
| Elastic Beanstalk | <env>.eba-<id>.<region>.elasticbeanstalk.com |
URL generada por EB, atada a la región |
| S3 | <bucket>.s3.<region>.amazonaws.com |
Bucket es regional |
| SQS | sqs.<region>.amazonaws.com/<account>/<queue> |
Región explícita en la URL |
| ElastiCache | <cluster>.<id>.<region>.cache.amazonaws.com |
Endpoint por cluster y región |
| ALB / ELB | <nombre>.<region>.elb.amazonaws.com |
Load balancer regional |
| CloudFront | <id>.cloudfront.net |
Global pero el ID cambia si se recrea la distribución |
| Cognito | cognito-idp.<region>.amazonaws.com |
User pool atado a la región |
| ECR | <account>.dkr.ecr.<region>.amazonaws.com |
Registry regional |
Para encontrarlos todos en una lectura con el siguiente comando se realiza una revisión quirúrgica sobre palabras clave:
#!/bin/bash
echo "=== AUDITORÍA DE HARDCODED VALUES ==="
echo "--- POSIBLES SECRETS ---"
grep -rn --include="*.yml" --include="*.yaml" --include="*.properties" \
-E "(password|token|apikey|api[-_]key|secret|credential)\s*[:=]\s*\S+" \
src/main/resources/ | grep -v "\${" | grep -v "#"
echo ""
echo "--- URLs INTERNAS AWS ---"
grep -rn --include="*.yml" --include="*.yaml" --include="*.properties" \
-E "(execute-api|\.rds\.|elasticbeanstalk\.com|\.sqs\.|\.cache\.|\.elb\.|cloudfront\.net|cognito-idp|\.ecr\.|\.s3\.)[a-z0-9.-]*amazonaws\.com" \
src/main/resources/ | grep -v "\${"
echo ""
echo "--- AWS ACCESS KEYS ---"
grep -rn -E "AKIA[A-Z0-9]{16}" src/
El grep -v "\${" filtra todo lo que ya está parametrizado con un placeholder de Spring. El output son solo valores reales que quedan por migrar.
Con los resultados, clasificar cada hallazgo:
| Tipo | Ejemplo | Destino |
|---|---|---|
| Secret / credencial | token, password, API key | SSM SecureString |
| URL interna propia | API Gateway, EB endpoint, dominio interno | SSM String |
| URL de tercero estable | Google, Transbank, proveedor externo | Dejar hardcodeado |
| Config que varía por entorno | nombre de bucket, región, nombre de DB | SSM String o EB env var |
El criterio de corte: si el valor cambiaría al migrar de región o de cuenta AWS, va a SSM. Si es una URL pública de un tercero que no depende de tu infraestructura, puede quedarse hardcodeada a pesar de que sea una buena práctica parametrizar.
Parameter Store (AWS SSM)
Parameter Store es un servicio nativo de AWS para centralizar configuración y secretos con control de acceso por IAM. Los parámetros son por región y por cuenta, por lo que en organizaciones con cuentas separadas por ambiente se requiere replicación en cada una.
Por qué SSM
1. Seguridad
Un secreto en application.yml commiteado en el repositorio es visible para cualquier developer con acceso al repositorio y si este es público, para cualquier persona en internet. Las EB env vars resuelven el problema del repositorio, pero siguen siendo visibles en texto plano en la consola de AWS para cualquier usuario con acceso al environment.
SSM con tipo SecureString encripta el valor en reposo con KMS. Solo roles IAM con permiso explícito pueden leerlo, un dev puede tener acceso al código y al ambiente de EB sin tener acceso a los secretos.
2. Rotación sin cambiar el código
Cambiar un secreto hardcodeado requiere editar el archivo → commit → push → pipeline → redespliegue. Con SSM, el cambio es un solo comando:
aws ssm put-parameter \
--name "/mi-servicio/mi-ambiente/mi-variable" \
--value "nuevo-valor" \
--type SecureString \
--overwrite
La app lee el nuevo valor en el próximo despliegue, lo que requiere un reinicio del servicio, pero sin tocar código ni pipeline.
3. Separación de entornos sin duplicar código
Sin SSM, separar dev/staging/prod implica mantener múltiples archivos de config con valores distintos (application-dev.yml, application-staging.yml, application-prod.yml) que alguien debe mantener sincronizados manualmente.
Con SSM, un solo application.yml sirve para todos los entornos. En nuestro caso, el perfil activo de Spring determina qué path de SSM se carga:
# application.yml — común a todos los entornos
spring:
config:
import: "aws-parameterstore:/mi-servicio/"
# application-prod.yml — solo agrega el path de prod
spring:
config:
import: "aws-parameterstore:/mi-servicio_prod/"
4. Auditoría
SSM registra cada lectura de parámetro en CloudTrail, quién leyó qué secreto, desde qué rol, y cuándo. Un token hardcodeado no deja rastro de uso.
5. Estructura de paths y tipos
SSM organiza los parámetros por path jerárquico. La convención que usamos:
/mi-servicio/ ← compartido entre todos los entornos
/mi-servicio_staging/ ← solo staging
/mi-servicio_prod/ ← solo producción
El tipo del parámetro depende de su sensibilidad:
| Tipo SSM | Cuándo usarlo |
|---|---|
String |
URLs internas, nombres de recursos, configuración no sensible |
SecureString |
Passwords, tokens, API keys, cualquier credencial |
URLs de terceros estables (Google, Transbank, proveedores externos) no deben necesariamente estar en SSM, pueden estar hardcodeadas. En SSM queremos solo los valores que son propios de la infraestructura y que cambian entre entornos o regiones.
Integración al código
Dependiendo del framework, esta sección puede variar, pero de manera macro el principio es el mismo. Se configura un lugar centralizado de lectura de parámetros y a través de la aplicación se referencian estos parámetros por nombre.
En Java con Spring Boot, la conexión a SSM se declara tal como se vio en la sección anterior. El código consume el valor como cualquier otra propiedad:
// Antes — hardcodeado
public static final String miUrl = "...";
// Después — resuelto desde SSM en runtime
@Value("${mi-servicio.miUrl}")
private String miUrl;
En Python el patrón es equivalente:
# Variable de entorno (EB la inyecta desde SSM)
mi_url = os.getenv("miUrl")
# O directo desde SSM
ssm = boto3.client("ssm")
mi_url = ssm.get_parameter(Name="/mi-servicio/miUrl",
WithDecryption=True)["Parameter"]["Value"]
1. bootstrap.yml ya no funciona
Spring Cloud AWS 3.x usa spring.config.import exclusivamente. Los archivos bootstrap.yml son ignorados en silencio: la app arranca sin error pero SSM no carga.
2. Errores silenciosos: IAM
Si el instance profile no tiene la policy ssm:GetParametersByPath, SSM tampoco carga, sin excepción ni warning. Las propiedades simplemente quedan vacías y el primer endpoint que las usa falla en runtime. La policy mínima requerida:
{
"Effect": "Allow",
"Action": ["ssm:GetParametersByPath", "ssm:GetParameter"],
"Resource": "arn:aws:ssm:<REGION>:<ACCOUNT_ID>:parameter/mi-servicio/*"
}
Otras alternativas de parametrización
SSM no es la única herramienta disponible. Dependiendo del tipo de valor y de dónde se usa, existen otras dos opciones válidas:
| Alternativa | Para qué sirve | Cuándo usarla |
|---|---|---|
| SSM Parameter Store | Secrets y config de runtime de la app | Cualquier valor que la app lee al arrancar |
| CI/CD variables (GitLab/GitHub) | Credenciales del pipeline de deploy | ARNs de roles, tokens de herramientas de CI |
| EB env vars | Config simple no sensible | Flags de feature, nombres de entorno, timeouts |
Las CI/CD variables cubren el pipeline, no el runtime: en nuestro caso migramos de keys IAM estáticas a OIDC, donde el pipeline recibe credenciales temporales por JWT sin secrets de larga duración.
Comece agora com a Frust
Resultados
La migración técnica cubrió 14 aplicativos en Elastic Beanstalk, todos escritos en Java 8 con Spring Boot 2.x, migrados a Corretto 21 y Spring Boot 3.3.5. En cada uno se ejecutaron los cuatro ejes descritos en este artículo: actualización de runtime y framework, migración de namespaces, reconfiguración de seguridad, y parametrización completa en SSM.
Los resultados en términos de costes están cubiertos en el artículo anterior, "Cómo redujimos el costo en 70%".
Aprendizajes y limitaciones

Uno de los principales aprendizajes que dejó esta experiencia es el hecho de que, a pesar de que gran parte del desarrollo de software hoy en día es AI-driven, como desarrolladores no debemos olvidar los fundamentales. Entender qué documentación es relevante para el problema en mano, ya que a pesar de contar con asistencia de LLMs, esta tiene claras limitaciones si no es guiada de manera apropiada.
En particular, se logró realizar la actualización de aplicaciones exitosamente y se desarrolló una metodología en base a un problema abstraído a tal grado que la solución puede replicarse en cualquier tipo de aplicativo.
Los desafíos, soluciones y consideraciones indicadas en este artículo fueron el resultado de un trabajo de ingeniería mayor que, a pesar del uso de LLM para descubrir y explorar casos borde, fue desafiante.
Una distinción que este proceso dejó en evidencia es la diferencia entre errores de compilación y errores de runtime, y cómo el LLM se comportó de manera muy distinta frente a cada uno.
Los errores de compilación fueron resueltos de forma confiable y directa: reemplazos de namespace, APIs removidas, versiones incompatibles. Son mecánicos, verificables estáticamente, y el modelo los identificó bien en una sola pasada leyendo outputs claros en consola.
Por otro lado, los errores de runtime fueron la fuente de ruido real. Varios aplicativos compilaban sin errores y fallaban al arrancar, bajo carga, o solo al ejecutar un endpoint específico. Algunos de estos fallos no tenían señal en compilación: dependencias que el framework dejó de tolerar por defecto, configuraciones que se ignoraban en silencio sin lanzar excepción, o comportamientos que solo se manifestaban cuando llegaba tráfico real.
La regla que surgió de esto: el LLM es un aliado para los cambios mecánicos, pero no puede anticipar comportamientos que solo existen en ejecución. Una batería de tests funcionales corriendo localmente, antes de cada push, es el único mecanismo que cierra esa brecha.
Una migración de región comprime en semanas decisiones de arquitectura que normalmente se toman de forma gradual. Fuerza parametrizar, actualizar y desacoplar todo al mismo tiempo. El resultado, cuando se hace bien, es un sistema más robusto del que entró al proceso.
Si este tipo de desafíos en la nube son de tu interés, visítanos en blog.frust.co donde encontrarás artículos sobre migraciones, infraestructura en AWS y optimización de costos o directamente en frust.co para entender y ahorrar en tus costes en la nube.
Comece agora com a Frust
Ainda tem dúvidas? Veja as perguntas frequentes.

