Как я перестал копипастить одно и то же в каждом Django-проекте и собрал boilerplate

от автора

Как я перестал копипастить одно и то же в каждом Django-проекте и собрал boilerplate

Каждый раз, когда начинаешь новый SaaS-проект на Django, первые две недели уходят на одно и то же. Сначала — кастомная модель пользователя с UUID вместо integer PK, потому что потом не переедешь. Потом JWT-аутентификация, настройка SimpleJWT, написание RegisterViewLoginViewLogoutView — всё это уже было в прошлом проекте, но лежит в другом репозитории и просто так не скопируешь. Дальше Docker Compose: сервисы webdbrediscelerycelery-beatflower — шесть штук, которые надо поднять и связать между собой. Потом разбираться с Celery, который в новой версии изменил синтаксис конфига. Stripe webhooks с идемпотентностью — отдельная история. Мультиарендность, роли, permissions — ещё неделя.

В итоге к первой рабочей фиче добираешься к концу третьей недели.

Добавь сюда ещё один нюанс: каждый раз это проект с немного другим стеком. Где-то Kafka вместо Redis, где-то allauth с самого начала, где-то биллинг на пользователя, а не на команду. Но ядро остаётся одним: каждый раз перед стартом ты тратишь одинаково одни и те же две недели. Вот это и хотелось исправить.

Я прошёл через это несколько раз и в какой-то момент решил, что хватит. Собрал Django SaaS boilerplate под названием Shipyard — не как набор сниппетов в Notion, а как полноценный, готовый к production репозиторий, который можно клонировать и сразу писать продуктовую логику.

В этой статье разберу, что там внутри, почему выбраны именно эти компоненты и какие конкретные технические решения показались мне наиболее интересными.


Что входит в Shipyard

Прежде чем переходить к деталям — стек целиком.

Django 5 + Django REST Framework 3.15 — API-first с самого начала, не как надстройка над HTML-шаблонами. DRF выбран потому, что он де-факто стандарт, хорошо документирован и у большинства джуниоров в команде не вызовет вопросов.

PostgreSQL 16 + Redis 7 — PostgreSQL как основная БД: UUID-поля, JSONB для метаданных планов, pg_trgm для будущего поиска. Redis — брокер для Celery и кэш. Два отдельных контейнера, не один Redis на всё подряд.

Celery 5 + Celery Beat + Flower — асинхронные задачи (отправка email, синхронизация со Stripe), периодические задачи через Beat, мониторинг через Flower на порту 5555. Beat хранит расписание в БД через django-celery-beat, а не в хардкоженном CELERYBEAT_SCHEDULE.

Docker Compose — два файла: docker-compose.yml для разработки и docker-compose.prod.yml для production. Dev-конфиг монтирует локальную директорию как volume, prod использует многоэтапную сборку образа. Подробнее об entrypoint — ниже.

GitHub Actions CI/CD — три workflow: ci.yml (lint + тесты на каждый push/PR), build.yml (сборка и пуш Docker-образа при мерже в main), deploy.yml (деплой по release-тегу).

Stripe subscriptions + webhooks — модели PlanSubscriptionInvoiceWebhookEvent. Checkout session, customer portal, обработка webhook-событий с идемпотентностью.

Мультиарендность с RBAC — цепочка User → TeamMembership → Team. Три роли: owneradminmember. DRF permissions на уровне view и объекта.

Django Unfold — тема для Django Admin. Не принципиально, но стандартный admin в 2025 году выглядит dated, а Unfold даёт нормальный UI без написания собственного шаблона.

Структура проекта:

textshipyard/├── apps/│   ├── core/          # базовые модели, health check, утилиты│   ├── users/         # кастомный User, JWT auth, email verification│   ├── teams/         # Team, TeamMembership, роли, приглашения│   ├── billing/       # Plan, Subscription, Invoice, WebhookEvent│   ├── notifications/ # Celery tasks для email, EmailLog│   └── api/           # DRF router, versioning, throttling├── config/│   ├── settings/│   │   ├── base.py│   │   ├── development.py│   │   └── production.py│   └── celery.py├── docker/│   ├── dev/│   └── prod/└── docker-compose.yml

shipyard/ ├── apps/ │ ├── core/ # базовые модели, health check, утилиты │ ├── users/ # кастомный User, JWT auth, email verification │ ├── teams/ # Team, TeamMembership, роли, приглашения │ ├── billing/ # Plan, Subscription, Invoice, WebhookEvent │ ├── notifications/ # Celery tasks для email, EmailLog │ └── api/ # DRF router, versioning, throttling ├── config/ │ ├── settings/ │ │ ├── base.py │ │ ├── development.py │ │ └── production.py │ └── celery.py ├── docker/ │ ├── dev/ │ └── prod/ └── docker-compose.yml


Архитектура приложений

core

Здесь живут два абстрактных миксина, которые используются почти везде:

python# apps/core/models.pyimport uuidfrom django.db import modelsclass TimestampedModel(models.Model):    created_at = models.DateTimeField(auto_now_add=True)    updated_at = models.DateTimeField(auto_now=True)    class Meta:        abstract = Trueclass UUIDModel(models.Model):    id = models.UUIDField(        primary_key=True,        default=uuid.uuid4,        editable=False,    )    class Meta:        abstract = True

# apps/core/models.py import uuid from django.db import models class TimestampedModel(models.Model): created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) class Meta: abstract = True class UUIDModel(models.Model): id = models.UUIDField( primary_key=True, default=uuid.uuid4, editable=False, ) class Meta: abstract = True

Большинство моделей наследует оба. UUID как первичный ключ — выбор сделан однажды, чтобы не возвращаться к нему потом. Предсказуемые auto-increment ID в URL — не лучшая идея с точки зрения безопасности, и переезжать с integer на UUID в существующей БД болезненно.

Кроме моделей, здесь HealthCheckView и ReadinessView по путям /health/ и /ready/ — нужны для Docker healthcheck и Kubernetes liveness/readiness probe.

users

Кастомная модель User с email как основным идентификатором:

python# apps/users/models.pyclass User(UUIDModel, AbstractBaseUser, PermissionsMixin):    email = models.EmailField(unique=True)    full_name = models.CharField(max_length=255, blank=True)    avatar = models.ImageField(upload_to="avatars/", blank=True, null=True)    is_active = models.BooleanField(default=True)    is_staff = models.BooleanField(default=False)    is_email_verified = models.BooleanField(default=False)    timezone = models.CharField(max_length=50, default="UTC")    stripe_customer_id = models.CharField(max_length=255, blank=True, null=True)    USERNAME_FIELD = "email"    REQUIRED_FIELDS = ["full_name"]    objects = CustomUserManager()    class Meta:        db_table = "users_user"        indexes = [            models.Index(fields=["email"]),            models.Index(fields=["stripe_customer_id"]),        ]

# apps/users/models.py class User(UUIDModel, AbstractBaseUser, PermissionsMixin): email = models.EmailField(unique=True) full_name = models.CharField(max_length=255, blank=True) avatar = models.ImageField(upload_to="avatars/", blank=True, null=True) is_active = models.BooleanField(default=True) is_staff = models.BooleanField(default=False) is_email_verified = models.BooleanField(default=False) timezone = models.CharField(max_length=50, default="UTC") stripe_customer_id = models.CharField(max_length=255, blank=True, null=True) USERNAME_FIELD = "email" REQUIRED_FIELDS = ["full_name"] objects = CustomUserManager() class Meta: db_table = "users_user" indexes = [ models.Index(fields=["email"]), models.Index(fields=["stripe_customer_id"]), ]

CustomUserManager переопределяет create_user и create_superuser под email-based аутентификацию. Поле stripe_customer_id на модели пользователя — это для случая, когда billing привязан к физическому лицу, а не к команде. В SaaS с командами обычно customer создаётся на Team, но иметь поле на обоих не вредно.

Дополнительно в этом app-е: EmailVerificationToken и PasswordResetToken — модели с полями tokenexpires_atused_at. Никакого кэша или отдельного хранилища — просто строки в БД, что удобно при отладке и аудите.

teams

Здесь реализована мультиарендность. Цепочка владения:

textUser → TeamMembership → Team

User → TeamMembership → Team

TeamMembership — это through-таблица для M2M между User и Team, дополненная полем role. На Team хранятся лимиты (max_membersmax_projects), которые заполняются при активации подписки из Plan. Это денормализация, но намеренная: проверка лимита при каждом действии не должна требовать JOIN через billing.

TeamInvitation — модель приглашений со статусами pending / accepted / expired / revoked. Приглашение — это запись в БД с токеном и TTL, а не просто ссылка в письме. Это позволяет отозвать приглашение, посмотреть список ожидающих и не бояться, что кто-то перехватит ссылку двухлетней давности.

Сигнал post_save на Team провизионирует Stripe customer при создании команды — команда сразу готова к биллингу, даже если подписку активируют позже.

billing

Четыре модели:

  • Plan — зеркалит Stripe Product + Prices. Редактируется через Django Admin. Содержит stripe_product_idstripe_price_id_monthlystripe_price_id_yearly, а также денормализованные лимиты: max_membersmax_projectshas_api_access.

  • Subscription — связь Team (OneToOne) с Plan и всеми полями Stripe subscription: статус, период, cancel_at_period_end, trial.

  • Invoice — запись каждого инвойса с ссылками на PDF и hosted URL от Stripe.

  • WebhookEvent — журнал обработанных событий для идемпотентности.

notifications

EmailLog — аудит каждого исходящего письма: кому, что, когда, статус. Это избавляет от вопросов «а дошло ли письмо» в поддержке.

Шаблоны писем в apps/notifications/templates/notifications/ — HTML с базовым лейаутом и наследованием: welcome.htmlverify_email.htmlpassword_reset.htmlinvitation.htmlbilling_alert.htmlsubscription_cancelled.html.

api

Здесь нет бизнес-логики — только инфраструктура DRF:

  • router.py — DefaultRouter со всеми зарегистрированными ViewSet-ами

  • throttling.py — AnonRateThrottleUserRateThrottleBurstThrottle

  • renderers.py — JSONRenderer с конвертом {"data": ..., "meta": ...}

  • exceptions.py — единый обработчик ошибок → единый JSON-формат ошибок

  • pagination.py — CursorPagination для списков (более стабильная, чем page-based, при высокой частоте обновлений)

Все endpoint-ы с префиксом /api/v1/. Версионирование через NamespaceVersioning.


Интересные технические решения

1. Мультиарендность через RBAC

При проектировании мультиарендности есть несколько подходов. Самый простой — добавить tenant_id на каждую таблицу и фильтровать по нему в каждом QuerySet. Это работает, но требует дисциплины: легко забыть добавить фильтр в одном месте и случайно показать данные другого тенанта. Второй подход — отдельные схемы PostgreSQL для каждого тенанта (django-tenants). Это изоляция на уровне БД, но усложняет деплой, миграции и аналитику через несколько схем одновременно.

В Shipyard выбран средний путь: один набор таблиц, но с явной цепочкой владения через TeamMembership. Каждый ресурс приложения имеет FK на Team. Все ViewSet-ы фильтруют queryset по team_id из URL, и этот паттерн достаточно механический, чтобы его сложно было пропустить при code review.

Три роли и соответствующие DRF permissions:

python# apps/teams/permissions.pyfrom rest_framework.permissions import BasePermissionfrom apps.teams.models import TeamMembershipclass IsTeamMember(BasePermission):    def has_permission(self, request, view):        team_id = view.kwargs.get("team_id")        return TeamMembership.objects.filter(            team_id=team_id,            user=request.user,        ).exists()class IsTeamAdmin(BasePermission):    def has_permission(self, request, view):        team_id = view.kwargs.get("team_id")        return TeamMembership.objects.filter(            team_id=team_id,            user=request.user,            role__in=[TeamMembership.Role.ADMIN, TeamMembership.Role.OWNER],        ).exists()class IsTeamOwner(BasePermission):    def has_permission(self, request, view):        team_id = view.kwargs.get("team_id")        return TeamMembership.objects.filter(            team_id=team_id,            user=request.user,            role=TeamMembership.Role.OWNER,        ).exists()

# apps/teams/permissions.py from rest_framework.permissions import BasePermission from apps.teams.models import TeamMembership class IsTeamMember(BasePermission): def has_permission(self, request, view): team_id = view.kwargs.get("team_id") return TeamMembership.objects.filter( team_id=team_id, user=request.user, ).exists() class IsTeamAdmin(BasePermission): def has_permission(self, request, view): team_id = view.kwargs.get("team_id") return TeamMembership.objects.filter( team_id=team_id, user=request.user, role__in=[TeamMembership.Role.ADMIN, TeamMembership.Role.OWNER], ).exists() class IsTeamOwner(BasePermission): def has_permission(self, request, view): team_id = view.kwargs.get("team_id") return TeamMembership.objects.filter( team_id=team_id, user=request.user, role=TeamMembership.Role.OWNER, ).exists()

В ViewSet permissions выставляются по методу:

python# apps/teams/views.pyclass TeamMembershipViewSet(viewsets.ModelViewSet):    def get_permissions(self):        if self.action in ("update", "partial_update", "destroy"):            return [IsAuthenticated(), IsTeamAdmin()]        return [IsAuthenticated(), IsTeamMember()]

# apps/teams/views.py class TeamMembershipViewSet(viewsets.ModelViewSet): def get_permissions(self): if self.action in ("update", "partial_update", "destroy"): return [IsAuthenticated(), IsTeamAdmin()] return [IsAuthenticated(), IsTeamMember()]

Каждый запрос делает один дополнительный SELECT в teams_membership. Можно кэшировать через Redis с ключом membership:{user_id}:{team_id} и инвалидировать при изменении роли, но на старте это preoptimization — QuerySet с индексом по (team_id, user_id) справляется быстро.

Индекс (team, role) на TeamMembership помогает, когда начинают накапливаться тысячи membership-записей:

pythonclass Meta:    db_table = "teams_membership"    unique_together = [("team", "user")]    indexes = [models.Index(fields=["team", "role"])]

class Meta: db_table = "teams_membership" unique_together = [("team", "user")] indexes = [models.Index(fields=["team", "role"])]

2. Stripe webhooks с идемпотентностью

Stripe официально гарантирует доставку «at least once» — это значит, что событие может прийти несколько раз. Кроме того, в production несколько воркеров Celery или Gunicorn-процессов могут получить одно событие параллельно через разные HTTP-запросы, если у вас горизонтальный деплой. Если обработчик не идемпотентен, можно активировать одну подписку дважды или выставить дублирующий инвойс.

Модель WebhookEvent:

python# apps/billing/models.pyclass WebhookEvent(TimestampedModel):    stripe_event_id = models.CharField(max_length=255, unique=True, db_index=True)    event_type = models.CharField(max_length=100)    payload = models.JSONField()    processed_at = models.DateTimeField(null=True, blank=True)    error = models.TextField(blank=True)    class Meta:        db_table = "billing_webhook_event"

# apps/billing/models.py class WebhookEvent(TimestampedModel): stripe_event_id = models.CharField(max_length=255, unique=True, db_index=True) event_type = models.CharField(max_length=100) payload = models.JSONField() processed_at = models.DateTimeField(null=True, blank=True) error = models.TextField(blank=True) class Meta: db_table = "billing_webhook_event"

Логика обработки в webhooks.py:

python# apps/billing/webhooks.pyfrom django.utils import timezonefrom apps.billing.models import WebhookEventWEBHOOK_HANDLERS = {    "checkout.session.completed": handle_checkout_completed,    "customer.subscription.updated": handle_subscription_updated,    "customer.subscription.deleted": handle_subscription_deleted,    "invoice.payment_succeeded": handle_invoice_paid,    "invoice.payment_failed": handle_invoice_payment_failed,}def process_webhook(event) -> None:    obj, created = WebhookEvent.objects.get_or_create(        stripe_event_id=event["id"],        defaults={            "event_type": event["type"],            "payload": event,        },    )    if not created:        # Событие уже обрабатывалось — пропускаем        return    handler = WEBHOOK_HANDLERS.get(event["type"])    if handler is None:        return    try:        handler(event)        obj.processed_at = timezone.now()        obj.save(update_fields=["processed_at"])    except Exception as exc:        obj.error = str(exc)        obj.save(update_fields=["error"])        raise

# apps/billing/webhooks.py from django.utils import timezone from apps.billing.models import WebhookEvent WEBHOOK_HANDLERS = { "checkout.session.completed": handle_checkout_completed, "customer.subscription.updated": handle_subscription_updated, "customer.subscription.deleted": handle_subscription_deleted, "invoice.payment_succeeded": handle_invoice_paid, "invoice.payment_failed": handle_invoice_payment_failed, } def process_webhook(event) -> None: obj, created = WebhookEvent.objects.get_or_create( stripe_event_id=event["id"], defaults={ "event_type": event["type"], "payload": event, }, ) if not created: # Событие уже обрабатывалось — пропускаем return handler = WEBHOOK_HANDLERS.get(event["type"]) if handler is None: return try: handler(event) obj.processed_at = timezone.now() obj.save(update_fields=["processed_at"]) except Exception as exc: obj.error = str(exc) obj.save(update_fields=["error"]) raise

get_or_create по stripe_event_id — атомарная операция благодаря unique=True. Если два параллельных воркера получат одно событие одновременно, один из них получит IntegrityError и не пройдёт дальше. Это надёжнее, чем exists() + create(), где между двумя операциями есть гонка.

Отдельно: view для вебхуков проверяет Stripe signature перед тем, как парсить payload. Это делается через stripe.Webhook.construct_event с STRIPE_WEBHOOK_SECRET из переменной окружения:

python# apps/billing/views.pyimport stripefrom django.conf import settingsfrom rest_framework.views import APIViewfrom rest_framework.response import Responsefrom rest_framework import statusfrom apps.billing.webhooks import process_webhookclass StripeWebhookView(APIView):    authentication_classes = []    permission_classes = []    def post(self, request):        payload = request.body        sig_header = request.META.get("HTTP_STRIPE_SIGNATURE", "")        try:            event = stripe.Webhook.construct_event(                payload, sig_header, settings.STRIPE_WEBHOOK_SECRET            )        except (ValueError, stripe.error.SignatureVerificationError):            return Response(status=status.HTTP_400_BAD_REQUEST)        process_webhook(event)        return Response({"status": "ok"})

# apps/billing/views.py import stripe from django.conf import settings from rest_framework.views import APIView from rest_framework.response import Response from rest_framework import status from apps.billing.webhooks import process_webhook class StripeWebhookView(APIView): authentication_classes = [] permission_classes = [] def post(self, request): payload = request.body sig_header = request.META.get("HTTP_STRIPE_SIGNATURE", "") try: event = stripe.Webhook.construct_event( payload, sig_header, settings.STRIPE_WEBHOOK_SECRET ) except (ValueError, stripe.error.SignatureVerificationError): return Response(status=status.HTTP_400_BAD_REQUEST) process_webhook(event) return Response({"status": "ok"})

Без проверки сигнатуры любой может отправить произвольное событие на endpoint и, например, активировать чужую подписку.

3. Docker entrypoint с проверкой БД и условными миграциями

Типичная проблема с Docker Compose: web-контейнер стартует раньше, чем PostgreSQL успевает принять соединения. depends_on: db в Compose-файле гарантирует только то, что контейнер db запущен, но не то, что PostgreSQL внутри него готов принимать подключения. На медленной машине или при первом запуске с инициализацией тома Django успевает упасть с connection refused раньше, чем Postgres поднимется.

bash# docker/dev/entrypoint.sh#!/bin/shset -eecho "Waiting for PostgreSQL..."until pg_isready -h "$POSTGRES_HOST" -p "${POSTGRES_PORT:-5432}" -U "$POSTGRES_USER"; do  sleep 1doneecho "PostgreSQL is ready."if [ "${RUN_MIGRATIONS:-true}" = "true" ]; then  echo "Running migrations..."  python manage.py migrate --noinputfiif [ "${COLLECT_STATIC:-false}" = "true" ]; then  python manage.py collectstatic --noinputfiexec "$@"

# docker/dev/entrypoint.sh #!/bin/sh set -e echo "Waiting for PostgreSQL..." until pg_isready -h "$POSTGRES_HOST" -p "${POSTGRES_PORT:-5432}" -U "$POSTGRES_USER"; do sleep 1 done echo "PostgreSQL is ready." if [ "${RUN_MIGRATIONS:-true}" = "true" ]; then echo "Running migrations..." python manage.py migrate --noinput fi if [ "${COLLECT_STATIC:-false}" = "true" ]; then python manage.py collectstatic --noinput fi exec "$@"

pg_isready — утилита из пакета postgresql-client, добавленная в Dockerfile. Она пингует сервер без открытия full-connection, что быстрее и дешевле чем python manage.py check --database default в цикле. Цикл с sleep 1 вполне достаточен для dev — в production Compose обычно всё равно не используется.

Переменная RUN_MIGRATIONS позволяет отключить автомиграции. Это нужно, например, когда разворачиваешь несколько реплик приложения: только одна должна запускать migrate, остальные — стартовать сразу. Параллельный запуск migrate несколькими процессами обычно безопасен благодаря блокировкам в Django, но лучше не испытывать судьбу. В production это управляется через отдельный release command (например, в Heroku/Render) или init-контейнер.

Переменная COLLECT_STATIC аналогично: в dev-окружении статика раздаётся напрямую через runserver, в prod — через Nginx после collectstatic в CI.

4. Настройки, разделённые по окружениям

Стандартный паттерн, но важно сделать его правильно с самого начала.

textconfig/settings/├── base.py        # всё общее├── development.py # DEBUG=True, console email backend├── production.py  # Gunicorn, S3, Redis cache, Sentry, strict security└── testing.py     # быстрый hasher, тестовая БД

config/settings/ ├── base.py # всё общее ├── development.py # DEBUG=True, console email backend ├── production.py # Gunicorn, S3, Redis cache, Sentry, strict security └── testing.py # быстрый hasher, тестовая БД

base.py содержит INSTALLED_APPSMIDDLEWARE, конфиг DRF, Celery, базовые настройки без секретов. Секреты — только через os.environ, никаких fallback-значений в production-файле:

python# config/settings/base.pyimport osfrom pathlib import PathBASE_DIR = Path(__file__).resolve().parent.parent.parentSECRET_KEY = os.environ["SECRET_KEY"]  # KeyError если не задан — это намеренноDATABASES = {    "default": {        "ENGINE": "django.db.backends.postgresql",        "NAME": os.environ["POSTGRES_DB"],        "USER": os.environ["POSTGRES_USER"],        "PASSWORD": os.environ["POSTGRES_PASSWORD"],        "HOST": os.environ.get("POSTGRES_HOST", "localhost"),        "PORT": os.environ.get("POSTGRES_PORT", "5432"),    }}

# config/settings/base.py import os from pathlib import Path BASE_DIR = Path(__file__).resolve().parent.parent.parent SECRET_KEY = os.environ["SECRET_KEY"] # KeyError если не задан — это намеренно DATABASES = { "default": { "ENGINE": "django.db.backends.postgresql", "NAME": os.environ["POSTGRES_DB"], "USER": os.environ["POSTGRES_USER"], "PASSWORD": os.environ["POSTGRES_PASSWORD"], "HOST": os.environ.get("POSTGRES_HOST", "localhost"), "PORT": os.environ.get("POSTGRES_PORT", "5432"), } }

development.py:

python# config/settings/development.pyfrom .base import *  # noqaDEBUG = TrueALLOWED_HOSTS = ["*"]EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"CELERY_TASK_ALWAYS_EAGER = True  # задачи выполняются синхронно в devCELERY_TASK_EAGER_PROPAGATES = True

# config/settings/development.py from .base import # noqa DEBUG = True ALLOWED_HOSTS = [""] EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" CELERY_TASK_ALWAYS_EAGER = True # задачи выполняются синхронно в dev CELERY_TASK_EAGER_PROPAGATES = True

CELERY_TASK_ALWAYS_EAGER = True в dev — одно из самых удобных решений: задачи выполняются синхронно прямо в процессе Django, не нужен отдельный воркер для локальной разработки. Но важно выключить это в testing.py, иначе тесты на асинхронное поведение не будут работать честно.

production.py добавляет Sentry, S3-хранилище для медиафайлов, Redis как кэш-бэкенд, SECURE_HSTS_SECONDSSESSION_COOKIE_SECURE и прочие security headers.


Что не вошло и почему

Покрытие тестами — отдельный раздел. В каждом app-е есть директория tests/ с test_models.pytest_views.py и factories.py (factory_boy). В tests/ на верхнем уровне — интеграционные тесты: test_auth_flow.pytest_team_flow.pytest_billing_flow.py. В tests/fixtures/stripe_webhook_events/ лежат JSON-файлы с реальными Stripe-событиями для тестирования webhook-обработчиков без моков. Конфигурация pytest и coverage — в pyproject.toml.

Frontend. Shipyard — это чистый API-backend. Никаких шаблонов, никакого HTMX, никакого React в репозитории. Выбор фронтенд-стека — это отдельное решение, и делать его за разработчика было бы самонадеянно. Next.js, Nuxt, SvelteKit, мобильное приложение — всё это подключается к тем же /api/v1/ endpoint-ам.

OAuth / социальная аутентификация. Код для этого есть (файлы adapters.py и pipeline.py в apps/users/), но не подключён по умолчанию. Причина простая: настройка OAuth-приложений в Google и GitHub требует реальных callback URL-ов, что неудобно при первом запуске. Добавить django-allauth в INSTALLED_APPS и прописать credentials в .env — 20 минут работы, когда это действительно нужно.

Kubernetes. Есть два Docker Compose файла: dev и prod. Kubernetes — это другой уровень сложности, который оправдан при определённом масштабе. На старте большинству SaaS хватает одного VPS с Docker Compose и правильно настроенным Nginx. Добавлять Helm charts в boilerplate значило бы усложнить точку входа без реальной пользы.

WebSocket / ASGI. asgi.py есть — переключиться на Daphne или Uvicorn несложно, если понадобится real-time. Но Celery + polling через REST решает большинство задач без усложнения деплоя.

Многоязычность. Директория locale/en/LC_MESSAGES/ в репозитории есть, django.po присутствует — Django i18n подключён, но переводы не заполнены. Это инфраструктура под будущее.


Результат

Запуск занимает пять команд:

bashgit clone https://github.com/EvgeniyMalykh/Shipyard.gitcd Shipyardcp .env.example .envdocker compose up --builddocker compose exec web python manage.py createsuperuser

git clone https://github.com/EvgeniyMalykh/Shipyard.git cd Shipyard cp .env.example .env docker compose up --build docker compose exec web python manage.py createsuperuser

После этого доступны:

Я выложил Shipyard на GitHub: github.com/EvgeniyMalykh/Shipyard. Там же лежит ARCHITECTURE.md на 2000+ строк — полный разбор каждого файла, схемы данных, flow аутентификации и Stripe-интеграции.

Ранний доступ с подробной документацией по настройке и деплою доступен на Gumroad. Это early access — буду рад фидбэку по архитектурным решениям, которые кажутся спорными или которые у вас сделаны иначе.

Если статья была полезна и вы делаете что-то похожее своими руками — напишите в комментариях, какие компромиссы вы сделали. В частности интересно, как другие решают вопрос с мультиарендностью: через отдельные схемы PostgreSQL, через tenant_id на каждой таблице или как-то иначе. Ну и по любым другим архитектурным решениям — тоже интересно услышать.

ссылка на оригинал статьи https://habr.com/ru/articles/1025002/