Backend Latam Radio #8
Publicado el 01/06/2026
Hardening II: integridad del dominio y refactor de capas
La auditoría dejó claro que tenía modelos sin unique=True, sin índices, ni Meta.ordering, y serializers que serializaban demasiado y rendían poco. También una capa de servicios que importaba de la capa de presentación, lo cual viola la flecha de dependencias que dije al principio.
Aquí cuento dos cosas que valen un post entero: una migración con dedupe seguro y el orden de las capas restaurado.
El problema con unique=True en una base con duplicados
No puedes simplemente añadir unique=True a un campo y aplicar la migración: si ya hay duplicados, la ALTER TABLE falla.
Mi base tenía duplicados en Tag. ¿Cuántos? No lo sabía, pero el TagViewSet del repo pre-auditoría ya tenía un parche:
# El parche que existía: limpiar duplicados al vuelo
def get_queryset(self):
subquery = (
Tag.objects.exclude(name='')
.values('name')
.annotate(min_id=Min('id'))
.values('min_id')
)
return Tag.objects.filter(id__in=Subquery(subquery)).order_by('name')
Elegante en su feo: ocultaba el problema en lugar de arreglarlo.
La migración: RunPython antes del AlterField
# streams/migrations/0002_dedupe_and_constraints.py
from django.db import migrations, models
def _dedupe_simple_model(apps, model_name, field, station_relation):
"""Fusiona filas duplicadas por `field` y reenruta las relaciones M2M
de Station hacia el ejemplar más antiguo (menor id)."""
Model = apps.get_model("streams", model_name)
Station = apps.get_model("streams", "Station")
through = getattr(Station, station_relation).through
fk_name = f"{model_name.lower()}_id"
seen = {}
to_delete = []
for obj in Model.objects.order_by("id").iterator():
key = getattr(obj, field)
if key in seen:
keep_id = seen[key]
for row in through.objects.filter(**{fk_name: obj.id}):
already = through.objects.filter(
station_id=row.station_id, **{fk_name: keep_id}
).exists()
if already:
row.delete()
else:
setattr(row, fk_name, keep_id)
row.save()
to_delete.append(obj.id)
else:
seen[key] = obj.id
if to_delete:
Model.objects.filter(id__in=to_delete).delete()
def dedupe_related_models(apps, schema_editor):
_dedupe_simple_model(apps, "Tag", "name", "tags")
_dedupe_simple_model(apps, "Language", "lang", "languages")
_dedupe_simple_model(apps, "LanguageCode", "lang_codes", "language_codes")
class Migration(migrations.Migration):
dependencies = [("streams", "0001_initial")]
operations = [
# 1. Limpiamos duplicados ANTES de aplicar las constraints
migrations.RunPython(
dedupe_related_models, reverse_code=migrations.RunPython.noop
),
# 2. Ahora sí, aplicamos unique=True
migrations.AlterField(
model_name="tag",
name="name",
field=models.CharField(max_length=255, unique=True),
),
migrations.AlterField(
model_name="language",
name="lang",
field=models.CharField(max_length=255, unique=True),
),
migrations.AlterField(
model_name="languagecode",
name="lang_codes",
field=models.CharField(max_length=255, unique=True),
),
# ... más AlterField con índices, blank=True, etc.
migrations.AddIndex(
model_name="station",
index=models.Index(
fields=["country_code", "lastcheckok"],
name="station_country_check_idx",
),
),
# ... AlterModelOptions para Meta.ordering
]
Lo realmente importante:
apps.get_model("streams", "Tag"), nofrom streams.models import Tag. Eso me da una versión "histórica" del modelo en el estado de la migración, no la versión actual del código. Es la única forma correcta de manipular datos dentro de una migración.- El bucle
iterator()procesa filas una a una sin cargarlas todas en memoria. Para 6.500 estaciones no haría falta, pero es buena costumbre. reverse_code=migrations.RunPython.noop. Si me pidenmigrate streams 0001, el dedupe no se revierte (no se pueden recrear los duplicados). El noop es la decisión consciente.
Ejecutado contra los datos reales (6510 estaciones, ~3000 tags), la migración corrió en menos de un segundo y dejó la base íntegra. Probé con una copia del SQLite real antes de marcarla como lista, no contra una base de tests vacía.
El otro arreglo: capa de servicios sin import a api/
El antes:
# streams/services/stations/station_service.py
from api.serializers.output.station_serializer import StationSerializer # ❌
from streams.repositories.stations.stations_repository import StreamRepositoryORM
class StationService:
def __init__(self, request):
self.request = request
def get_json_from_all_stations(self):
stream_repository = StreamRepositoryORM()
stations = stream_repository.get_all_stations()
return StationSerializer(stations, many=True).data
Dos problemas:
streams/importa deapi/. Esto rompe la flecha que dije al inicio:api → streams, no al revés. La capa de dominio no debe conocer la capa de presentación.get_json_from_all_stationsno se llamaba desde ningún sitio. Código muerto.
El después:
# streams/services/stations/station_service.py
from streams.repositories.stations.stations_repository import StreamRepositoryORM
class StationService:
def __init__(self):
self._repository = StreamRepositoryORM()
def get_all_distinct_country_codes(self):
return self._repository.get_all_distinct_countries()
Sin imports a api/. Sin request en el constructor. Sin método muerto. Diez líneas. Y el CountryViewSet que lo consumía, ajustado:
# api/viewsets/country_viewset.py
@swagger_auto_schema(responses={200: _CountryListSerializer})
def list(self, request):
countries = list(StationService().get_all_distinct_country_codes())
return Response({"countries": countries})
El serializer: explícito en lugar de __all__/depth=1
Antes:
class StationSerializer(GeoModelSerializer):
class Meta:
model = Station
fields = '__all__'
depth = 1
geo_field = 'location'
def to_representation(self, instance):
print(instance) # 🔥
data = super().to_representation(instance)
if data.get('country_code'):
data['country_code'] = data['country_code'].upper()
return data
Cuatro pecados en seis líneas: __all__, depth=1, print por request, y normalización en serializer cuando ya se hace al guardar. Después: serializer explícito con nested serializers, sin print, sin normalización redundante (ver el post de la API REST para la versión final).
Resultado
- Base limpia, con
unique=Trueaplicado y datos preservados. - Capa de dominio sin dependencia hacia la capa de presentación.
- Serializer eficiente (
prefetch_relateden el viewset + nested explícito).
En el siguiente post: cómo robustecí la sincronización para que un registro raro de radio-browser no me reventara la base, y la operación del backend (Docker, gunicorn, logging).