How to Structure a Django Service Inside a Go Based Microservices Architecture

Ingeniero de Software Dublín

Ingeniero de Software Dublín

Ingeniero Construido Para Escalar

Más Allá Del Código

Lo Que Dicen

Construyamos Juntos

Mensaje Recibido

Política de Privacidad

Términos de Uso

Política de Cookies

Aviso Legal

Últimas Noticias

Trabajo Seleccionado

Showcase

Lo Que Ofrecemos

Industrias Que Servimos

Más Allá De La Pantalla

Construido Juntos

View View
Nben Malla
Nben Malla

Software Engineer

A software engineer who builds systems that scale, modernizes platforms that matter and ships code that holds up long after the project ends.

Based in Dublin, Ireland, with a presence across Bristol, Groningen and Kathmandu, the work spans FinTech platforms, SaaS products and open source contributions, collaborating with engineering teams across Nepal, Ireland, the Netherlands, New Zealand and the United States.

From leading legacy modernization for global banking clients to architecting microservices in Go, Java and Python, the focus has always been the same. Understand the problem deeply, build it right and make sure the people depending on it never have to think about it failing.

  • Leer artículo Leer artículo

    Tutorials 13 mins

    How to Structure a Django Service Inside a Go Based Microservices Architecture

    Nben M. 06 Feb, 2026 13 mins

    How to Structure a Django Service Inside a Go Based Microservices Architecture

    Most teams running Go microservices reach a point where they need capabilities that Go handles less gracefully than they expected. A backoffice admin interface. A workflow that involves complex data relationships and rapid iteration on business rules. A reporting pipeline where the query logic changes weekly and nobody wants to recompile a binary every time. These are not architecture failures. They are the natural edges of what Go is optimised for.

    Django fills those edges well. The ORM handles complex relational queries without ceremony. The admin interface is production-ready in a few dozen lines of configuration. The ecosystem for data processing, PDF generation, email templating and async task execution is mature and well-maintained. Python's iteration speed on data-heavy problems is meaningfully faster than Go's.

    I have run this combination in production: Go services handling the high-throughput, latency-sensitive paths, and a Django service handling the operational and reporting surfaces that the Go services expose data for. The integration works cleanly when the boundaries are clear and the contracts between services are explicit. It creates problems when Django starts reaching into Go service databases or Go services start calling Django endpoints for anything on the critical path.

    This article is about how to draw those boundaries correctly.

    When Django Belongs in a Go Architecture

    The decision to add a Django service should follow from a clear capability gap, not from familiarity or convenience. The cases where Django earns its place alongside Go services are specific.

    Django belongs when you need a backoffice or admin interface with complex data relationships. Go does not have an equivalent to Django Admin. Building one from scratch in Go is a significant investment with no leverage from the ecosystem. Django Admin, configured properly, can handle CRUD operations, filtering, search and custom actions for dozens of models in a day of work.

    Django belongs when a domain has rapidly evolving business logic that is expressed more naturally in Python. Data transformation pipelines, rules engines, financial report generation: these change frequently, involve complex conditional logic, and benefit from Python's expressiveness and the available libraries. Recompiling and redeploying a Go binary every time a business rule changes is a friction cost that accumulates.

    Django belongs when you need Celery for async task processing with scheduling, retry logic and task monitoring. Go has worker pool patterns and libraries, but Celery's combination of task routing, priority queues, scheduled tasks and a monitoring interface (Flower) is difficult to replicate without significant investment.

    Django does not belong on any path that requires low latency. It does not belong as a gateway that Go services route through. It does not belong reading directly from Go service databases. Every one of those placements creates coupling that is expensive to undo.

    Project Structure

    The Django service lives as a first-class service in the architecture, not a special case. Its project structure reflects that.

    markdown
    services/
      payments/          # Go service
      accounts/          # Go service
      operations/        # Django service
        config/
          settings/
            base.py
            production.py
            development.py
          urls.py
          celery.py
          wsgi.py
          asgi.py
        apps/
          accounts/      # Read model for account data
          payments/      # Read model for payment data
          reports/       # Reporting and export
          admin/         # Custom admin configuration
        tests/
        Dockerfile
        requirements/
          base.txt
          production.txt
          development.txt
        manage.py
        Makefile

    Settings split by environment. Requirements split by environment. Apps named by the domain they serve, not by technical function. The admin app contains custom Django Admin configuration rather than scattering it across domain apps.

    Split settings from day one. A single settings.py that conditionally branches on environment variables becomes unreadable under any meaningful operational complexity.

    python
    # config/settings/base.py
    from pathlib import Path
    import os
    
    BASE_DIR = Path(__file__).resolve().parent.parent.parent
    
    SECRET_KEY = os.environ["DJANGO_SECRET_KEY"]
    
    INSTALLED_APPS = [
        "django.contrib.admin",
        "django.contrib.auth",
        "django.contrib.contenttypes",
        "django.contrib.sessions",
        "django.contrib.messages",
        "django.contrib.staticfiles",
        # Third party
        "rest_framework",
        "django_celery_beat",
        "django_celery_results",
        # Internal
        "apps.accounts",
        "apps.payments",
        "apps.reports",
        "apps.admin",
    ]
    
    DATABASES = {
        "default": {
            "ENGINE": "django.db.backends.postgresql",
            "NAME": os.environ["DB_NAME"],
            "USER": os.environ["DB_USER"],
            "PASSWORD": os.environ["DB_PASSWORD"],
            "HOST": os.environ["DB_HOST"],
            "PORT": os.environ.get("DB_PORT", "5432"),
            "CONN_MAX_AGE": 60,
            "OPTIONS": {"sslmode": os.environ.get("DB_SSLMODE", "require")},
        }
    }
    python
    # config/settings/production.py
    from .base import *
    
    DEBUG = False
    
    ALLOWED_HOSTS = os.environ["ALLOWED_HOSTS"].split(",")
    
    CELERY_BROKER_URL = os.environ["CELERY_BROKER_URL"]
    CELERY_RESULT_BACKEND = "django-db"
    CELERY_BEAT_SCHEDULER = "django_celery_beat.schedulers:DatabaseScheduler"
    
    LOGGING = {
        "version": 1,
        "disable_existing_loggers": False,
        "formatters": {
            "json": {
                "()": "pythonjsonlogger.jsonlogger.JsonFormatter",
                "format": "%(asctime)s %(name)s %(levelname)s %(message)s",
            }
        },
        "handlers": {
            "console": {
                "class": "logging.StreamHandler",
                "formatter": "json",
            }
        },
        "root": {"handlers": ["console"], "level": "INFO"},
    }

    JSON logging in production, matching the format your Go services use, means your log aggregator processes all services the same way. Structured logs from Django and Go services appear in the same dashboards with the same query syntax.

    Owning Its Own Database

    The Django service owns its own database. It does not share a schema with any Go service, and no Go service reads from the Django service's database.

    The Django service's database contains two categories of data. First, read models: denormalised representations of data owned by Go services, populated via event consumption. Second, operational data owned exclusively by the Django service: report configurations, scheduled tasks, admin audit logs, user preferences for the backoffice.

    python
    # apps/accounts/models.py
    from django.db import models
    
    class Account(models.Model):
        """
        Read model populated from the accounts service event stream.
        This model is never written to by Django business logic.
        Write operations happen in the accounts Go service.
        """
        id = models.UUIDField(primary_key=True)
        owner_id = models.UUIDField(db_index=True)
        currency = models.CharField(max_length=3)
        balance_cents = models.BigIntegerField()
        status = models.CharField(max_length=50)
        created_at = models.DateTimeField()
        updated_at = models.DateTimeField()
        synced_at = models.DateTimeField(auto_now=True)
    
        class Meta:
            db_table = "accounts_read"
            managed = True
    
        @property
        def balance_display(self):
            major = self.balance_cents // 100
            minor = self.balance_cents % 100
            return f"{major}.{minor:02d} {self.currency}"
    
        def __str__(self):
            return f"Account {self.id} ({self.currency})"

    The docstring on the model is not optional. Every read model should state explicitly that it is populated by events and never written to by Django. The next developer inheriting this codebase should not have to infer that from the absence of write operations in the code.

    synced_at records when the read model was last updated from the event stream. It is useful for monitoring: if synced_at for a significant portion of records is more than a few minutes behind, the event consumer is lagging and the read model is stale.

    Consuming Events from Go Services

    Go services publish events when their state changes. The Django service consumes those events and updates its read models. This is the only legitimate data flow from Go services into the Django service's database.

    python
    # apps/accounts/consumers.py
    import json
    import logging
    from django.utils import timezone
    from .models import Account
    
    logger = logging.getLogger(__name__)
    
    def handle_account_created(event: dict) -> None:
        payload = event["payload"]
        Account.objects.update_or_create(
            id=payload["account_id"],
            defaults={
                "owner_id": payload["owner_id"],
                "currency": payload["currency"],
                "balance_cents": payload["balance_cents"],
                "status": payload["status"],
                "created_at": payload["created_at"],
                "updated_at": payload["created_at"],
            },
        )
        logger.info(
            "account read model created",
            extra={"account_id": payload["account_id"], "event_id": event["id"]},
        )
    
    def handle_account_updated(event: dict) -> None:
        payload = event["payload"]
        updated = Account.objects.filter(id=payload["account_id"]).update(
            balance_cents=payload["balance_cents"],
            status=payload["status"],
            updated_at=payload["updated_at"],
        )
        if not updated:
            logger.warning(
                "account not found for update, creating from event",
                extra={"account_id": payload["account_id"], "event_id": event["id"]},
            )
            handle_account_created(event)

    update_or_create on handle_account_created makes the consumer idempotent. If the same event is processed twice, the second write produces the same result as the first. Event consumers must be idempotent. Message brokers guarantee at-least-once delivery, not exactly-once.

    The handle_account_updated fallback to creation handles the case where an update event arrives before its corresponding creation event, which can happen when a consumer starts from a point in the stream after the creation event was published. Log the anomaly and recover rather than failing.

    A Celery task wraps each consumer function to provide retry logic and dead-letter handling:

    python
    # apps/accounts/tasks.py
    from celery import shared_task
    from .consumers import handle_account_created, handle_account_updated
    
    @shared_task(
        bind=True,
        max_retries=5,
        default_retry_delay=10,
        queue="events",
        acks_late=True,
    )
    def process_account_event(self, event: dict) -> None:
        handlers = {
            "account.created": handle_account_created,
            "account.updated": handle_account_updated,
        }
    
        event_type = event.get("type")
        handler = handlers.get(event_type)
    
        if not handler:
            # Unknown event type: log and discard, do not retry
            import logging
            logging.getLogger(__name__).warning(
                "unhandled event type", extra={"event_type": event_type}
            )
            return
    
        try:
            handler(event)
        except Exception as exc:
            raise self.retry(exc=exc)

    acks_late=True means the message is only acknowledged after the task completes successfully. If the task fails and does not retry, the message stays in the queue. Combined with max_retries, this gives five chances to process the event before it is sent to the dead-letter queue for manual inspection.

    Exposing Data Back to Go Services

    The Django service exposes a REST API using Django REST Framework for any data that Go services need from it. Go services call this API; they do not read the Django database directly.

    python
    # apps/reports/serializers.py
    from rest_framework import serializers
    from .models import Report
    
    class ReportSerializer(serializers.ModelSerializer):
        class Meta:
            model = Report
            fields = ["id", "report_type", "status", "generated_at", "download_url"]
            read_only_fields = fields
    
    # apps/reports/views.py
    from rest_framework import generics, permissions
    from .models import Report
    from .serializers import ReportSerializer
    from .authentication import ServiceTokenAuthentication
    
    class ReportDetailView(generics.RetrieveAPIView):
        queryset = Report.objects.all()
        serializer_class = ReportSerializer
        authentication_classes = [ServiceTokenAuthentication]
        permission_classes = [permissions.IsAuthenticated]
        lookup_field = "id"

    Service-to-service authentication uses shared tokens rather than user sessions. The ServiceTokenAuthentication class validates a static token from the Authorization header. Tokens are rotated via environment variables without code changes.

    python
    # apps/reports/authentication.py
    import os
    from rest_framework.authentication import BaseAuthentication
    from rest_framework.exceptions import AuthenticationFailed
    
    class ServiceTokenAuthentication(BaseAuthentication):
        def authenticate(self, request):
            token = request.headers.get("Authorization", "").removeprefix("Bearer ")
            if not token:
                return None
    
            valid_tokens = os.environ.get("SERVICE_API_TOKENS", "").split(",")
            if token not in valid_tokens:
                raise AuthenticationFailed("invalid service token")
    
            return (ServicePrincipal(token), None)
    
    class ServicePrincipal:
        def __init__(self, token: str):
            self.token = token
            self.is_authenticated = True

    SERVICE_API_TOKENS accepts a comma-separated list of valid tokens, which means you can rotate tokens by adding the new token to the list, updating the caller, then removing the old token. Zero-downtime token rotation without a dedicated secrets management system.

    The Django Admin as a Production Tool

    Django Admin is the primary reason Django earns its place in many Go-based architectures. Configured properly, it provides a fully functional backoffice without custom frontend development.

    python
    # apps/admin/admin.py
    from django.contrib import admin
    from django.utils.html import format_html
    from apps.accounts.models import Account
    from apps.payments.models import Payment
    
    @admin.register(Account)
    class AccountAdmin(admin.ModelAdmin):
        list_display = ["id", "owner_id", "currency", "balance_display", "status", "synced_at"]
        list_filter = ["currency", "status"]
        search_fields = ["id", "owner_id"]
        readonly_fields = ["id", "owner_id", "currency", "balance_cents", "status",
                           "created_at", "updated_at", "synced_at"]
        ordering = ["-created_at"]
    
        def has_add_permission(self, request):
            return False
    
        def has_change_permission(self, request, obj=None):
            return False
    
        def has_delete_permission(self, request, obj=None):
            return False

    Read models in the admin are read-only. has_add_permission, has_change_permission and has_delete_permission all return False. The admin interface is for viewing and searching, not for writing data that the Go service owns. Writing through the admin bypasses the Go service's validation, event publishing and audit trail.

    For operations that require writes, the admin uses custom actions that call the appropriate Go service API rather than writing to the database directly:

    python
    @admin.register(Payment)
    class PaymentAdmin(admin.ModelAdmin):
        list_display = ["id", "account_id", "amount_display", "status", "created_at"]
        list_filter = ["status", "currency"]
        search_fields = ["id", "account_id"]
        readonly_fields = list_display
        actions = ["request_refund"]
    
        @admin.action(description="Request refund via payments service")
        def request_refund(self, request, queryset):
            import httpx
            for payment in queryset.filter(status="settled"):
                response = httpx.post(
                    f"{settings.PAYMENTS_SERVICE_URL}/v1/payments/{payment.id}/refund",
                    headers={"Authorization": f"Bearer {settings.PAYMENTS_SERVICE_TOKEN}"},
                    timeout=10.0,
                )
                if response.status_code == 202:
                    self.message_user(request, f"Refund requested for payment {payment.id}")
                else:
                    self.message_user(
                        request,
                        f"Refund failed for {payment.id}: {response.json().get('message')}",
                        level="error",
                    )

    Admin actions that need to modify data owned by a Go service make HTTP calls to that service's API. The Go service validates the request, publishes the event, updates its own database, and the Django read model updates when the event arrives. The data flow is consistent regardless of whether the trigger came from an end user, an API call, or an admin action.

    The Dockerfile

    markdown
    FROM python:3.12-slim AS base
    WORKDIR /app
    
    ENV PYTHONDONTWRITEBYTECODE=1 \
        PYTHONUNBUFFERED=1 \
        PIP_NO_CACHE_DIR=1
    
    FROM base AS builder
    COPY requirements/production.txt .
    RUN pip install --prefix=/install -r production.txt
    
    FROM base AS runtime
    COPY --from=builder /install /usr/local
    COPY . .
    
    RUN addgroup --system django && adduser --system --ingroup django django
    USER django
    
    EXPOSE 8000
    CMD ["gunicorn", "config.wsgi:application", \
         "--bind", "0.0.0.0:8000", \
         "--workers", "4", \
         "--timeout", "30", \
         "--access-logfile", "-", \
         "--error-logfile", "-"]

    PYTHONDONTWRITEBYTECODE and PYTHONUNBUFFERED are not optional in containers. Bytecode files create noise in the image layer diff. Unbuffered output means log lines appear immediately rather than after Python's output buffer fills. A non-root user runs the process. The multi-stage build keeps the final image free of build tools.

    Gunicorn workers are set to four as a starting point. The correct number is two times the number of available CPU cores plus one. For a container with two vCPUs, that is five. For one vCPU, that is three. Adjust based on your deployment target.

    Conclusion

    Django in a Go microservices architecture is not a compromise. It is a deliberate tool selection for the problems Django solves better than Go: complex relational data interfaces, rapid iteration on business logic, production-grade admin tooling, and async task processing with built-in monitoring.

    The integration holds together when the boundaries are respected. The Django service owns its own database. It populates read models from events, never from direct database reads. It exposes data through APIs, never through shared schemas. Admin actions that write data call Go service APIs rather than writing to read models directly.

    Violate any of those boundaries and the Django service becomes a liability: a second writer to data that the Go service is supposed to own, a direct database reader that breaks when the Go service changes its schema, a tight coupling point that makes both services harder to change independently. Respect the boundaries and Django becomes the most productive part of the architecture for the surfaces it is suited for.