Back to Blog

Rescuing Legacy Python Apps with DDD

- 7 min read

You've inherited a Python codebase. The route handlers are 500 lines long. Business logic lives in models, background tasks, middleware, and even templates. Tests are flaky or nonexistent. Sound familiar?

Don't Rewrite. Strangle.

The temptation is to start fresh. Don't. Rewrites fail more often than they succeed. Instead, use the Strangler Fig pattern: gradually replace pieces of the legacy system while it continues running.

Here's how to apply it with DDD patterns:

Step 1: Identify a Bounded Context

Pick one area of your application that's causing pain. Maybe it's the billing system, or user registration, or order processing. This becomes your first Bounded Context.

Create a new folder structure alongside your existing code:

src/
├── api/routes/           # Legacy
├── models/               # Legacy
└── domain/               # New DDD code
    └── billing/
        ├── domain/
        │   ├── invoice.py
        │   ├── invoice_id.py
        │   └── invoice_repository.py
        ├── application/
        │   └── create_invoice_handler.py
        └── infrastructure/
            └── sqlalchemy_invoice_repository.py

Step 2: Extract Value Objects First

Value Objects are the safest place to start. They have no dependencies on the rest of the system:

# Before: stringly-typed chaos
user.email = request.json.get('email')

# After: self-validating Value Object
@dataclass(frozen=True)
class Email:
    value: str

    def __post_init__(self):
        if not re.match(r'^[^@]+@[^@]+\.[^@]+$', self.value):
            raise ValueError(f"Invalid email: {self.value}")

user.email = Email(request.json.get('email'))

Start using Value Objects in new code immediately. Refactor existing code when you touch it.

Step 3: Create a Repository Adapter

You probably can't change your SQLAlchemy models right away. That's fine. Create a repository that adapts between your domain and the existing models:

class SQLAlchemyInvoiceRepository(InvoiceRepositoryInterface):
    def __init__(self, session: Session):
        self.session = session

    def save(self, invoice: Invoice) -> None:
        # Map domain object to existing SQLAlchemy model
        model = self.session.get(InvoiceModel, invoice.id.value) or InvoiceModel()
        model.id = invoice.id.value
        model.customer_id = invoice.customer_id.value
        model.amount = invoice.total.cents
        model.status = invoice.status.value
        self.session.add(model)
        self.session.commit()

    def find_by_id(self, id: InvoiceId) -> Invoice | None:
        model = self.session.get(InvoiceModel, id.value)
        if not model:
            return None

        # Map SQLAlchemy model to domain object
        return Invoice(
            id=InvoiceId(model.id),
            customer_id=CustomerId(model.customer_id),
            total=Money.from_cents(model.amount),
            status=InvoiceStatus(model.status),
        )

Step 4: Route New Features Through the Domain

When you add new features to your Bounded Context, build them properly from the start:

# New route - thin, delegates to domain
@router.post("/invoices")
def create_invoice(
    request: CreateInvoiceRequest,
    handler: CreateInvoiceHandler = Depends()
) -> JSONResponse:
    command = CreateInvoiceCommand(
        customer_id=CustomerId(request.customer_id),
        items=request.items,
    )

    invoice_id = handler.handle(command)

    return JSONResponse({"id": invoice_id.value})

Step 5: Gradually Migrate Existing Functionality

As you fix bugs or add features to legacy code, migrate it to the new structure. Over time, the legacy code shrinks and the domain grows.

Key rules for migration:

  • Never break existing tests (if you have them)
  • Keep the API the same - changes are internal only
  • One piece at a time - don't try to migrate everything at once
  • Add tests as you go - the domain is easy to unit test

The 6-Month Checkpoint

After 6 months of gradual migration, you should have:

  • One or two Bounded Contexts fully migrated
  • A clear folder structure for domain code
  • Value Objects replacing primitives in critical areas
  • Repositories abstracting data access
  • Command handlers for complex operations
  • Unit tests covering domain logic

The legacy code is still there, but it's shrinking. New features are built correctly. The team is learning the patterns.

Want a complete migration guide?

Chapter 31 (Migrating Legacy Code) of Pragmatic DDD with Python covers this in detail, with real-world examples and common pitfalls to avoid.

Get the book