Migrating from Django πŸŽΈβž‘οΈπŸŽ‹ΒΆ

This guide helps Django developers transition to Tatami, showing how Django’s MTV pattern translates to Tatami’s modern API architecture.

Why Consider Tatami?ΒΆ

As a Django developer, you’ll find Tatami offers:

  • βœ… API-first design - Built specifically for modern web APIs

  • βœ… Simpler deployment - No complex WSGI/ASGI configuration

  • βœ… Auto-discovery - Automatic component registration like Django apps

  • βœ… Type safety - Full type hint support throughout

  • βœ… Modern async - Built on ASGI from the ground up

  • βœ… Less boilerplate - Convention over configuration

Key Concept MappingΒΆ

Django to Tatami TranslationΒΆ

Django Concept

Tatami Equivalent

Purpose

Views (Class-based)

Class-based Routers

Handle HTTP requests

URL patterns

Router decorators

Define endpoints

Models

Pydantic models

Data validation/serialization

Services/Managers

Service classes

Business logic

Middleware

Middleware classes

Request/response processing

Apps

Router modules

Organize functionality

Settings

YAML configuration

App configuration

Basic Django to Tatami TranslationΒΆ

Django Views β†’ Tatami RoutersΒΆ

Django Class-Based Views:

# Django views.py
from django.http import JsonResponse
from django.views import View
from django.utils.decorators import method_decorator
from django.views.decorators.csrf import csrf_exempt
from .models import Post
from .serializers import PostSerializer

@method_decorator(csrf_exempt, name='dispatch')
class PostView(View):
    def get(self, request, post_id=None):
        if post_id:
            try:
                post = Post.objects.get(id=post_id)
                return JsonResponse(PostSerializer(post).data)
            except Post.DoesNotExist:
                return JsonResponse({'error': 'Post not found'}, status=404)
        else:
            posts = Post.objects.all()
            return JsonResponse([PostSerializer(p).data for p in posts], safe=False)

    def post(self, request):
        serializer = PostSerializer(data=request.POST)
        if serializer.is_valid():
            post = serializer.save()
            return JsonResponse(PostSerializer(post).data, status=201)
        return JsonResponse(serializer.errors, status=400)

Equivalent Tatami Router:

# routers/posts.py
from tatami import router, get, post
from pydantic import BaseModel
from typing import List, Optional

class Post(BaseModel):
    id: Optional[int] = None
    title: str
    content: str
    author: str

class PostCreate(BaseModel):
    title: str
    content: str
    author: str

class Posts(router('/posts')):
    """Post management endpoints"""

    def __init__(self, post_service: PostService):
        super().__init__()
        self.post_service = post_service

    @get('/')
    def list_posts(self) -> List[Post]:
        """Get all posts"""
        return self.post_service.get_all()

    @get('/{post_id}')
    def get_post(self, post_id: int) -> Post:
        """Get post by ID"""
        post = self.post_service.get_by_id(post_id)
        if not post:
            return {'error': 'Post not found'}, 404
        return post

    @post('/')
    def create_post(self, post_data: PostCreate) -> Post:
        """Create a new post"""
        return self.post_service.create(post_data)

Django URLs β†’ Tatami RoutingΒΆ

Django URL Configuration:

# urls.py
from django.urls import path, include
from . import views

app_name = 'blog'

urlpatterns = [
    path('posts/', views.PostView.as_view(), name='post-list'),
    path('posts/<int:post_id>/', views.PostView.as_view(), name='post-detail'),
    path('users/', include('users.urls')),
    path('comments/', include('comments.urls')),
]

# Main urls.py
from django.contrib import admin
from django.urls import path, include

urlpatterns = [
    path('admin/', admin.site.urls),
    path('api/v1/', include('blog.urls')),
]

Tatami Auto-Discovery:

# routers/posts.py - automatically discovered
class Posts(router('/api/v1/posts')):
    # Routes automatically registered
    pass

# routers/users.py - automatically discovered
class Users(router('/api/v1/users')):
    # Routes automatically registered
    pass

# routers/comments.py - automatically discovered
class Comments(router('/api/v1/comments')):
    # Routes automatically registered
    pass

Django Models β†’ Pydantic Models + ServicesΒΆ

Django Models:

# models.py
from django.db import models
from django.contrib.auth.models import User

class Post(models.Model):
    title = models.CharField(max_length=200)
    content = models.TextField()
    author = models.ForeignKey(User, on_delete=models.CASCADE)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        ordering = ['-created_at']

    def __str__(self):
        return self.title

    @classmethod
    def get_by_author(cls, author):
        return cls.objects.filter(author=author)

Tatami Pydantic Models + Service:

# models.py (Pydantic models for API)
from pydantic import BaseModel
from datetime import datetime
from typing import Optional

class Post(BaseModel):
    id: Optional[int] = None
    title: str
    content: str
    author_id: int
    created_at: Optional[datetime] = None
    updated_at: Optional[datetime] = None

class PostCreate(BaseModel):
    title: str
    content: str
    author_id: int

# services/post_service.py (Business logic)
from typing import List, Optional

class PostService:
    def __init__(self, post_repository: PostRepository):
        self.post_repository = post_repository

    def get_all(self) -> List[Post]:
        return self.post_repository.find_all()

    def get_by_id(self, post_id: int) -> Optional[Post]:
        return self.post_repository.find_by_id(post_id)

    def get_by_author(self, author_id: int) -> List[Post]:
        return self.post_repository.find_by_author(author_id)

    def create(self, post_data: PostCreate) -> Post:
        return self.post_repository.create(post_data)

Django Forms/Serializers β†’ Pydantic ValidationΒΆ

Django Forms/Serializers:

# forms.py or serializers.py
from django import forms
from rest_framework import serializers
from .models import Post

class PostForm(forms.ModelForm):
    class Meta:
        model = Post
        fields = ['title', 'content']

    def clean_title(self):
        title = self.cleaned_data['title']
        if len(title) < 5:
            raise forms.ValidationError("Title must be at least 5 characters")
        return title

# Or with DRF
class PostSerializer(serializers.ModelSerializer):
    class Meta:
        model = Post
        fields = ['id', 'title', 'content', 'author', 'created_at']

    def validate_title(self, value):
        if len(value) < 5:
            raise serializers.ValidationError("Title must be at least 5 characters")
        return value

Tatami Pydantic Validation:

# models.py
from pydantic import BaseModel, validator
from typing import Optional

class PostCreate(BaseModel):
    title: str
    content: str
    author_id: int

    @validator('title')
    def title_must_be_long_enough(cls, v):
        if len(v) < 5:
            raise ValueError('Title must be at least 5 characters')
        return v

    @validator('content')
    def content_not_empty(cls, v):
        if not v.strip():
            raise ValueError('Content cannot be empty')
        return v

Migration StrategiesΒΆ

2. Gradual Router MigrationΒΆ

Migrate Django apps one by one:

# Django app structure
myproject/
β”œβ”€β”€ blog/           # Migrate first
β”œβ”€β”€ users/          # Migrate second
β”œβ”€β”€ comments/       # Migrate third
└── notifications/  # Migrate last
# Tatami equivalent
tatami-api/
β”œβ”€β”€ routers/
β”‚   β”œβ”€β”€ blog.py     # Migrated from blog app
β”‚   β”œβ”€β”€ users.py    # Migrated from users app
β”‚   └── comments.py # Migrated from comments app
└── services/       # Business logic extracted

3. Microservice SplitΒΆ

Use Tatami for new microservices:

# Keep Django for web frontend
django-web/
β”œβ”€β”€ templates/
β”œβ”€β”€ static/
└── views.py  # Renders HTML, calls APIs

# New Tatami API services
tatami-api/
β”œβ”€β”€ user-service/    # User management
β”œβ”€β”€ post-service/    # Content management
└── auth-service/    # Authentication

Common Migration PatternsΒΆ

Django Admin β†’ Custom Admin InterfaceΒΆ

Django Admin:

# admin.py
from django.contrib import admin
from .models import Post

@admin.register(Post)
class PostAdmin(admin.ModelAdmin):
    list_display = ['title', 'author', 'created_at']
    list_filter = ['author', 'created_at']
    search_fields = ['title', 'content']

Tatami Admin API:

# routers/admin.py
class Admin(router('/admin')):
    def __init__(self, post_service: PostService):
        self.post_service = post_service

    @get('/posts')
    def list_posts(
        self,
        author: Optional[str] = None,
        search: Optional[str] = None
    ) -> List[Post]:
        return self.post_service.admin_list(author, search)

Django Authentication β†’ JWT/Custom AuthΒΆ

Django Authentication:

# Django views
from django.contrib.auth.decorators import login_required

@login_required
def protected_view(request):
    return JsonResponse({'user': request.user.username})

Tatami Authentication:

# middleware/auth_middleware.py
from starlette.middleware.base import BaseHTTPMiddleware

class AuthMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request, call_next):
        # JWT validation logic
        response = await call_next(request)
        return response

# routers/protected.py
class Protected(router('/protected')):
    def __init__(self, auth_service: AuthService):
        self.auth_service = auth_service

    @get('/')
    def protected_endpoint(self, request) -> dict:
        user = self.auth_service.get_current_user(request)
        return {'user': user.username}

Django Signals β†’ Event ServicesΒΆ

Django Signals:

# signals.py
from django.db.models.signals import post_save
from django.dispatch import receiver
from .models import Post

@receiver(post_save, sender=Post)
def post_created(sender, instance, created, **kwargs):
    if created:
        send_notification(instance.author, f"Post '{instance.title}' created")

Tatami Event Services:

# services/post_service.py
class PostService:
    def __init__(self, notification_service: NotificationService):
        self.notification_service = notification_service

    def create_post(self, post_data: PostCreate) -> Post:
        post = self.post_repository.create(post_data)
        # Explicit event handling
        self.notification_service.send_post_created(post)
        return post

Django Middleware β†’ Tatami MiddlewareΒΆ

Django Middleware:

# middleware.py
class CustomMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        # Process request
        request.custom_header = request.META.get('HTTP_X_CUSTOM')
        response = self.get_response(request)
        # Process response
        response['X-Custom-Response'] = 'processed'
        return response

Tatami Middleware:

# middleware/custom_middleware.py
from starlette.middleware.base import BaseHTTPMiddleware

class CustomMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request, call_next):
        # Process request
        custom_header = request.headers.get('x-custom')
        request.state.custom_data = custom_header

        response = await call_next(request)

        # Process response
        response.headers['x-custom-response'] = 'processed'
        return response

Configuration MigrationΒΆ

Django Settings β†’ Tatami ConfigΒΆ

Django settings.py:

# settings.py
import os
from pathlib import Path

BASE_DIR = Path(__file__).resolve().parent.parent

SECRET_KEY = os.environ.get('SECRET_KEY')
DEBUG = os.environ.get('DEBUG', 'False').lower() == 'true'

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.postgresql',
        'NAME': os.environ.get('DB_NAME'),
        'USER': os.environ.get('DB_USER'),
        'PASSWORD': os.environ.get('DB_PASSWORD'),
        'HOST': os.environ.get('DB_HOST', 'localhost'),
        'PORT': os.environ.get('DB_PORT', '5432'),
    }
}

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'rest_framework',
    'blog',
    'users',
]

Tatami config.yaml:

# config.yaml
app:
  name: "My API"
  secret_key: "${SECRET_KEY}"
  debug: false

database:
  engine: "postgresql"
  name: "${DB_NAME}"
  user: "${DB_USER}"
  password: "${DB_PASSWORD}"
  host: "${DB_HOST:localhost}"
  port: "${DB_PORT:5432}"
# config-dev.yaml
app:
  debug: true

database:
  host: "localhost"
  name: "dev_db"

Database MigrationΒΆ

Repository PatternΒΆ

# repositories/post_repository.py
from typing import List, Optional
from .models import PostModel

class PostRepository:
    def __init__(self, db_session):
        self.db = db_session

    def find_all(self) -> List[Post]:
        models = self.db.query(PostModel).all()
        return [Post.from_orm(m) for m in models]

    def find_by_id(self, post_id: int) -> Optional[Post]:
        model = self.db.query(PostModel).get(post_id)
        return Post.from_orm(model) if model else None

    def create(self, post_data: PostCreate) -> Post:
        model = PostModel(**post_data.dict())
        self.db.add(model)
        self.db.commit()
        return Post.from_orm(model)

Testing MigrationΒΆ

Django Tests β†’ Tatami TestsΒΆ

Django Tests:

# tests.py
from django.test import TestCase, Client
from django.contrib.auth.models import User
from .models import Post

class PostTestCase(TestCase):
    def setUp(self):
        self.user = User.objects.create_user('testuser', 'test@example.com')
        self.client = Client()

    def test_create_post(self):
        response = self.client.post('/api/posts/', {
            'title': 'Test Post',
            'content': 'Test content',
            'author': self.user.id
        })
        self.assertEqual(response.status_code, 201)
        self.assertEqual(Post.objects.count(), 1)

Tatami Tests:

# tests/test_posts.py
import httpx
import pytest
from tatami import BaseRouter
from routers.posts import Posts
from services.post_service import PostService

def test_create_post():
    # Mock dependencies
    mock_service = Mock(spec=PostService)
    mock_service.create.return_value = Post(
        id=1, title="Test Post", content="Test content"
    )

    # Setup app
    app = BaseRouter()
    app.include_router(Posts(mock_service))

    # Test request
    with httpx.Client(app=app, base_url="http://test") as client:
        response = client.post("/posts/", json={
            'title': 'Test Post',
            'content': 'Test content',
            'author_id': 1
        })

        assert response.status_code == 201
        assert response.json()['title'] == 'Test Post'

Project Structure ComparisonΒΆ

Django Project StructureΒΆ

django-project/
β”œβ”€β”€ manage.py
β”œβ”€β”€ requirements.txt
β”œβ”€β”€ myproject/
β”‚   β”œβ”€β”€ __init__.py
β”‚   β”œβ”€β”€ settings.py
β”‚   β”œβ”€β”€ urls.py
β”‚   └── wsgi.py
β”œβ”€β”€ blog/                    # Django app
β”‚   β”œβ”€β”€ __init__.py
β”‚   β”œβ”€β”€ admin.py
β”‚   β”œβ”€β”€ apps.py
β”‚   β”œβ”€β”€ models.py
β”‚   β”œβ”€β”€ views.py
β”‚   β”œβ”€β”€ urls.py
β”‚   β”œβ”€β”€ serializers.py
β”‚   └── migrations/
β”œβ”€β”€ users/                   # Django app
β”‚   β”œβ”€β”€ models.py
β”‚   β”œβ”€β”€ views.py
β”‚   └── urls.py
β”œβ”€β”€ static/
└── templates/

Tatami Project StructureΒΆ

tatami-project/
β”œβ”€β”€ config.yaml              # Configuration
β”œβ”€β”€ routers/                 # API endpoints (auto-discovered)
β”‚   β”œβ”€β”€ posts.py            # Blog functionality
β”‚   β”œβ”€β”€ users.py            # User management
β”‚   └── auth.py             # Authentication
β”œβ”€β”€ services/               # Business logic (auto-discovered)
β”‚   β”œβ”€β”€ post_service.py     # Post operations
β”‚   β”œβ”€β”€ user_service.py     # User operations
β”‚   └── auth_service.py     # Authentication logic
β”œβ”€β”€ repositories/           # Data access (auto-discovered)
β”‚   β”œβ”€β”€ post_repository.py  # Post data access
β”‚   └── user_repository.py  # User data access
β”œβ”€β”€ middleware/             # Middleware (auto-discovered)
β”‚   β”œβ”€β”€ auth_middleware.py  # Authentication
β”‚   └── cors_middleware.py  # CORS handling
β”œβ”€β”€ models/                 # Pydantic models
β”‚   β”œβ”€β”€ post.py
β”‚   └── user.py
└── static/                 # Static files (auto-served)

Migration AdvantagesΒΆ

PerformanceΒΆ

Django: - Synchronous by default - Complex async configuration - ORM can generate inefficient queries

Tatami: - Async-first architecture - Built on Starlette/ASGI - Direct control over database queries

Development SpeedΒΆ

Django:

# Multiple files for simple CRUD
# models.py
class Post(models.Model): pass

# serializers.py
class PostSerializer(serializers.ModelSerializer): pass

# views.py
class PostViewSet(viewsets.ModelViewSet): pass

# urls.py
router.register(r'posts', PostViewSet)

Tatami:

# Single file for simple CRUD
class Posts(router('/posts')):
    @get('/')
    def list_posts(self) -> List[Post]: pass

    @post('/')
    def create_post(self, post: PostCreate) -> Post: pass

Type SafetyΒΆ

Django: Limited type hints, runtime errors

def get_posts(request):
    # No type hints for request/response
    posts = Post.objects.all()
    # Serialization can fail at runtime
    return JsonResponse([serialize(p) for p in posts])

Tatami: Full type safety throughout

@get('/')
def list_posts(self) -> List[Post]:  # Return type enforced
    return self.post_service.get_all()  # Validated automatically

Migration GotchasΒΆ

1. No Built-in AdminΒΆ

Django Admin doesn’t translate directly. Consider:

  • Build custom admin interface

  • Use existing admin tools (Django Admin + API calls)

  • Third-party admin solutions

2. Different Database PatternsΒΆ

Django uses Active Record pattern, Tatami uses Repository pattern:

# Django Active Record
post = Post.objects.get(id=1)
post.title = "Updated"
post.save()
# Tatami Repository Pattern
post = self.post_repository.find_by_id(1)
updated_post = post.copy(update={'title': 'Updated'})
self.post_repository.save(updated_post)

3. Authentication DifferencesΒΆ

Django has built-in user model and session auth. Tatami typically uses JWT:

# Django - built-in sessions
if request.user.is_authenticated:
    pass
# Tatami - JWT/custom auth
user = self.auth_service.verify_token(request.headers.get('authorization'))

Migration ChecklistΒΆ

Planning PhaseΒΆ

  • [ ] Audit Django project - Identify API vs web functionality

  • [ ] Map Django apps - Plan Tatami router organization

  • [ ] Review models - Plan Pydantic model structure

  • [ ] Identify dependencies - What services need injection?

  • [ ] Plan database migration - ORM to Repository pattern

Implementation PhaseΒΆ

  • [ ] Create Tatami project - tatami create new-api

  • [ ] Convert models - Django models to Pydantic + SQLAlchemy

  • [ ] Migrate views - Django views to Tatami routers

  • [ ] Extract services - Business logic to service classes

  • [ ] Setup repositories - Data access layer

  • [ ] Convert middleware - Django to Starlette middleware

Testing PhaseΒΆ

  • [ ] Port tests - Django tests to Tatami tests

  • [ ] Test endpoints - Verify API functionality

  • [ ] Performance testing - Compare response times

  • [ ] Integration testing - Test with existing Django frontend

Deployment PhaseΒΆ

  • [ ] Update CI/CD - Use tatami run for deployment

  • [ ] Configure load balancer - Route to Tatami API

  • [ ] Monitor performance - Track API metrics

  • [ ] Plan rollback - Keep Django API as backup

Why Migrate from Django?ΒΆ

For API-focused projects, Tatami offers:

  • πŸš€ Better Performance - Async-first architecture

  • 🧹 Cleaner Code - Less boilerplate, better organization

  • ⚑ Faster Development - Convention over configuration

  • πŸ”’ Type Safety - Catch errors at development time

  • πŸ“¦ Modern Patterns - Built for current Python practices

  • πŸ› οΈ Better Testing - Clear dependencies, easy mocking

The migration effort creates a more maintainable, performant API! πŸŽ‹

Note: Keep Django for admin interfaces and complex web apps. Use Tatami for clean, fast APIs.