Powrót do strony głównej

Django SaaS boilerplate Shipyard: production-szablon bez rutyny

Analiza Shipyard — production-ready Django boilerplate dla aplikacji SaaS. Analiza architektury wielodzierżawności, integracji Stripe i rozwiązań technicznych oszczędzających 2 tygodnie rozwoju. Przykłady kodu i best practices.

Shipyard: Gotowy do produkcji szablon Django dla SaaS
Advertisement 728x90

# Shipyard: Jak stworzyć gotowy do produkcji szablon Django SaaS bez rutyny

Rozpoczęcie nowego projektu Django dla produktu SaaS zazwyczaj pochłania 2-3 tygodnie na konfigurację podstawowych komponentów. Shipyard rozwiązuje ten problem, oferując gotowy do produkcji szablon z prekonfigurowanymi modułami. Omawiamy architekturę i rozwiązania techniczne, które oszczędzają czas development.

Stos technologiczny i struktura projektu

Shipyard jest zbudowany na nowoczesnym stosie technologicznym, zorientowanym na obciążenia produkcyjne. Główne komponenty:

  • Django 5 + DRF 3.15: Podejście API-first bez szablonów HTML
  • PostgreSQL 16 + Redis 7: Oddzielne kontenery dla bazy danych i pamięci podręcznej
  • Celery 5 + Beat + Flower: Asynchroniczne zadania i monitorowanie
  • Docker Compose: Dwa pliki konfiguracyjne — dla rozwoju i produkcji
  • Stripe Webhooks: Obsługa zdarzeń z idempotentnością
  • Wielodostępność z RBAC: Łańcuch User → TeamMembership → Team

Struktura projektu jest ściśle modułowa:

Google AdInline article slot
shipyard/
├── apps/
│   ├── core/
│   ├── users/
│   ├── teams/
│   ├── billing/
│   ├── notifications/
│   └── api/
├── config/
├── docker/
└── docker-compose.yml

Każda aplikacja odpowiada za swoją strefę odpowiedzialności. Na przykład, billing zawiera modele subskrypcji, a api — infrastrukturę DRF bez logiki biznesowej.

Architektura podstawowych modeli

Kluczowym elementem są klasy abstrakcyjne w core/models.py:

python
# 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

Te miksiny są używane we wszystkich modelach. UUID zamiast klucza głównego typu integer wybrano świadomie: przewidywalne ID w URL są nebezpechne, a migracja z integer na UUID w działającej bazie danych jest bolesna. TimestampedModel zapewnia audyt zmian bez duplikowania kodu.

Google AdInline article slot

W users zaimplementowano niestandardowy model User z emailem jako głównym identyfikatorem:

python
# apps/users/models.py
class User(UUIDModel, AbstractBaseUser, PermissionsMixin):
    email = models.EmailField(unique=True)
    full_name = models.CharField(max_length=255, blank=True)
    is_email_verified = models.BooleanField(default=False)
    stripe_customer_id = models.CharField(max_length=255, blank=True, null=True)

    USERNAME_FIELD = "email"
    REQUIRED_FIELDS = ["full_name"]

Pole stripe_customer_id dodano do modelu użytkownika w przypadkach, gdy rozliczenia są powiązane z osobą fizyczną. Osobno zaimplementowano tokeny weryfikacji i resetu hasła za pomocą modeli EmailVerificationToken i PasswordResetToken — bez pamięci podręcznej, tylko baza danych dla audytu.

Wielodostępność przez RBAC

Shipyard stosuje kompromisowe podejście do wielodostępności. Zamiast rozdzielania schematów (django-tenants) lub globalnego tenant_id stosowany jest łańcuch własności:

Google AdInline article slot
User → TeamMembership → Team

TeamMembership działa jako tabela through z polem role. W Team przechowywane są limity (max_members, max_projects), zdenormalizowane z Plan dla szybkiej weryfikacji bez JOIN. To zmniejsza złożoność zapytań podczas pracy z limitami.

Trzy role są zaimplementowane przez uprawnienia DRF:

python
# apps/teams/permissions.py
class IsTeamMember(BasePermission):
    def has_permission(self, request, view):
        team_id = view.kwargs.get('team_id')
        return TeamMembership.objects.filter(
            user=request.user,
            team_id=team_id
        ).exists()

Wzór jest mechaniczny: wszystkie ViewSet filtrują queryset po team_id z URL. To zmniejsza ryzyko wycieku danych między tenantami w porównaniu z ręcznym dodawaniem tenant_id do każdego zapytania.

System rozliczeń i webhooków

Model Plan odzwierciedla Stripe Product + Prices:

python
class Plan(models.Model):
    stripe_product_id = models.CharField(max_length=255)
    stripe_price_id_monthly = models.CharField(max_length=255)
    stripe_price_id_yearly = models.CharField(max_length=255)
    max_members = models.PositiveIntegerField()
    max_projects = models.PositiveIntegerField()

Zdenormalizowane limity w Plan pozwalają na natychmiastową weryfikację limitów bez odwoływania się do Stripe. Podczas tworzenia zespołu uruchamiany jest sygnał post_save, który prowizjonuje klienta Stripe — zespół jest od razu gotowy do płatności.

Obsługa webhooków opiera się na idempotentności. Model WebhookEvent rejestruje przetworzone zdarzenia:

python
class WebhookEvent(models.Model):
    event_id = models.CharField(max_length=255, unique=True)
    event_type = models.CharField(max_length=255)
    payload = models.JSONField()
    processed_at = models.DateTimeField(null=True)

To zapobiega podwójnemu przetwarzaniu zdarzeń przy powtarzanych żądaniach ze Stripe.

Co ważne

  • UUID zamiast klucza głównego typu integer — wybór zrobiony raz na zawsze, migracja w działającej bazie danych jest niemożliwa
  • Centralizowany health check — endpointy /health/ i /ready/ do integracji z Docker i Kubernetes
  • Infrastruktura DRF w osobnej aplikacji — wersjonowanie, paginacja i obsługa błędów wyodrębnione z logiki biznesowej
  • Audyt e-maili przez EmailLog — rejestrowanie każdego wysłanego e-maila dla wsparcia
  • Dwa Docker Compose — konfiguracja dev z wolumenem, prod z wieloetapową kompilacją

Integracja ze Stripe wymaga szczególnej uwagi na idempotentność. Każde zdarzenie webhooka jest sprawdzane pod kątem unikalności przez event_id. To kluczowe dla operacji takich jak pobieranie środków, gdzie powtarzane przetwarzanie doprowadzi do podwójnego obciążenia. Shipyard używa modelu WebhookEvent jako naturalnego mechanizmu blokady.

Dla powiadomień zaimplementowano silnik szablonów z podstawowym układem. Wszystkie e-maile (welcome, verify_email, invitation) dziedziczą wspólną strukturę, co ułatwia utrzymanie. Szablony są przechowywane w apps/notifications/templates/notifications/ z podziałem na wersje HTML i tekstowe.

W konfiguracji produkcyjnej Docker Compose używana jest wieloetapowa kompilacja. Pierwszy etap to instalacja zależności, drugi — kopiowanie kodu. To zmniejsza rozmiar końcowego obrazu i przyspiesza wdrożenie. Dla CI/CD skonfigurowano trzy workflow GitHub Actions: testy lint przy PR, budowa obrazu przy mergu do main, wdrożenie po tagu release.

— Editorial Team

Advertisement 728x90

Czytaj dalej