# Shipyard: Jak vytvořit šablonu Django SaaS připravenou pro produkci bez rutiny
Spouštění nového Django projektu pro SaaS produkt obvykle spotřebuje 2–3 týdny na nastavení základních komponent. Shipyard tento problém řeší tím, že poskytuje šablonu připravenou pro produkci s přednastavenými moduly. Probereme architekturu a technická řešení, která šetří čas vývoje.
Stak a struktura projektu
Shipyard je postavený na moderním staku zaměřeném na produkční zátěž. Hlavní komponenty:
- Django 5 + DRF 3.15: Přístup API-first bez HTML šablon
- PostgreSQL 16 + Redis 7: Samostatné kontejnery pro databázi a cache
- Celery 5 + Beat + Flower: Asynchronní úkoly a monitorování
- Docker Compose: Dva konfigy – pro vývoj a produkci
- Stripe Webhooks: Zpracování událostí s idempotencí
- Multi-tenancy s RBAC: Řetězec User → TeamMembership → Team
Struktura projektu je striktně modulární:
shipyard/
├── apps/
│ ├── core/
│ ├── users/
│ ├── teams/
│ ├── billing/
│ ├── notifications/
│ └── api/
├── config/
├── docker/
└── docker-compose.yml
Každá aplikace odpovídá za svou oblast odpovědnosti. Například billing obsahuje modely předplatného, zatímco api infrastrukturu DRF bez business logiky.
Architektura základních modelů
Klíčový prvek tvoří abstraktní třídy v 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
Tyto mixiny se používají ve všech modelech. UUID místo integer PK bylo zvoleno záměrně: předvídatelné ID v URL nejsou bezpečné a migrace z integer na UUID v provozní databázi je bolestivá. TimestampedModel zajišťuje audit změn bez duplikování kódu.
V users je implementována vlastní model User s e-mailem jako hlavním identifikátorem:
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 je přidáno na model uživatele pro případy, kdy je billing vázaný na fyzickou osobu. Samostatně jsou implementovány tokeny pro ověření e-mailu a reset hesla přes modely EmailVerificationToken a PasswordResetToken – bez cache, pouze databáze pro audit.
Multi-tenancy přes RBAC
Shipyard používá kompromisní přístup k multi-tenancy. Místo rozdělení schémat (django-tenants) nebo globálního tenant_id se aplikuje řetězec vlastnictví:
User → TeamMembership → Team
TeamMembership slouží jako through-tabuka s polem role. Na Team se ukládají limity (max_members, max_projects), denormalizované z Plan pro rychlou kontrolu bez JOIN. To snižuje složitost dotazů při práci s limity.
Tři role jsou implementovány přes DRF permissions:
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()
Pattern je mechanický: všechny ViewSet filtrují queryset podle team_id z URL. To snižuje riziko úniku dat mezi tenanty ve srovnání s ručním přidáváním tenant_id do každého dotazu.
Systém billingu a webhooků
Model Plan zrcadlí 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()
Denormalizované limity v Plan umožňují okamžitou kontrolu kvót bez volání Stripe. Při vytváření týmu se spustí signál post_save, který provizuje Stripe customer – tým je ihned připravený k platbě.
Zpracování webhooků je postavené na idempotenci. Model WebhookEvent zaznamenává zpracované události:
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 zabraňuje dvojitému zpracování událostí při opakovaných požadavcích ze Stripe.
Co je důležité
- UUID místo integer PK – volba je udělaná jednou provždy, migrace v provozní databázi není možná
- Centralizovaný health check – endpointy /health/ a /ready/ pro integraci s Docker a Kubernetes
- DRF infrastruktura v samostatné app – verziování, paginace a zpracování chyb jsou vynechány z business logiky
- Audit e-mailů přes EmailLog – zaznamenání každého odeslaného e-mailu pro podporu
- Dva Docker Compose – dev konfig s volume, prod s multi-stage buildem
Integrace se Stripe vyžaduje zvláštní pozornost k idempotenci. Každá událost webhooku se kontroluje na unikátnost přes event_id. To je kritické pro operace jako inkaso plateb, kde opakované zpracování vede k dvojitému inkasu. Shipyard používá model WebhookEvent jako přirozený mechanismus blokování.
Pro notifikace je implementován šablonátor s základním layoutem. Všechny e-maily (welcome, verify_email, invitation) dědí společnou strukturu, což usnadňuje údržbu. Šablony jsou uložené v apps/notifications/templates/notifications/ s oddělením HTML a textových verzí.
V prod konfigu Docker Compose se používá multi-stage build. První stage – instalace závislostí, druhý – kopírování kódu. To snižuje velikost finálního image a zrychluje deploy. Pro CI/CD jsou nastaveny tři GitHub Actions workflow: lint testy při PR, build image při mergi do main, deploy podle release tagu.
— Editorial Team
Zatím žádné komentáře.