Un-F*ck Your Python Project Structure

Un-F*ck Your Python Project Structure

Structure, SOLID principles, and typing for scalable sanity


Why Structure Matters

You're not building a toy script. You're building a living, breathing system. One that people will (hopefully) maintain, extend, and maybe even love.

But without structure, it's just chaos in slow motion.

A growing Python codebase without discipline becomes a tangle of:  

- deeply coupled logic
- duplicate types and data models
- untyped functions
- and fragile spaghetti workflows.

The fix? Structure. Discipline. SOLID principles. Typing.


SOLID Principles in Python (Yes, They Apply)

Let’s break down a few practical ways to apply SOLID principles in Python - even without a compiler to yell at you.

S - Single Responsibility Principle (SRP)

Keep each module/class/function focused on one thing.
If your class handles business logic and talks to the database, you're already in trouble.

📌 Tip:
Split responsibilities into layers - domain logic, application services, and infrastructure.


O - Open/Closed Principle

Your modules should be open for extension, closed for modification.
Use strategy patterns, abstract base classes, or protocols.

📌 Tip:
Define interfaces in a ports/ or interfaces/ module and plug in implementations via dependency injection.


L - Liskov Substitution Principle

If you subclass something, make sure it can be used in place of its parent without breaking things.
More relevant in type-heavy or class-based Python, but still useful when designing APIs and services.

📌 Tip:
Don't subclass just to "reuse" code, prefer composition. Let duck typing + protocols help you.


I - Interface Segregation Principle

Don't force your objects to implement methods they don’t need.
In Python, this means keeping your interfaces lean - think Protocol or @runtime_checkable in typing.

📌 Tip:
Keep interfaces per use case - don’t build god-objects or all-in-one service layers.


D - Dependency Inversion Principle

High-level modules should not depend on low-level details.
Define behavior in abstract terms. Concrete implementations come later.

📌 Tip:
Use constructor injection or function parameters to inject dependencies - don't hardcode infrastructure logic into your business logic.


Project Structure: Stop the Bleeding

As your project scales, it must grow in a way that keeps concerns isolated and dependencies visible.

Here’s a high-level example layout for a modular, scalable Python app:

myapp/
├── domain/
│   ├── models/         # Business entities, logic
│   ├── services/       # Stateless business rules
│   └── events.py       # Domain-level events or signals
├── application/
│   ├── use_cases/      # Orchestrates workflows (sagas)
│   └── interfaces.py   # Abstract ports to be implemented
├── infrastructure/
│   ├── db/             # Repositories, data access
│   ├── api/            # FastAPI, Flask, etc.
│   └── external/       # 3rd-party integrations
├── shared/
│   ├── dtos/           # Typed data transfer objects
│   ├── enums/
│   └── errors/
└── main.py             # Entry point (or FastAPI app)

The Case for Shared Data Models

Ever seen a step in a workflow return a class that’s defined inside that step?

That means no other module can rely on it without creating entropy, and your code will start rotting. So other modules start redefining similar - but slightly different - structures.

Soon you’ve got multiple competing definitions of "Customer", and your sanity is officially out the window.

🛠 Fix it:

Define shared DTOs or pydantic models in a neutral location (shared/dtos/).

Steps or modules can use internal draft models as needed, but convert to shared models before outputting.

Don't force your shared models to conform to internal module requirements. This leads to leaky abstractions and unnecessary complications.

Example:

# shared/dtos/user.py
class UserProfileDTO(BaseModel):
    id: UUID
    name: str
    email: str

# module_a/internal_model.py
class _RawUserData(BaseModel):
    full_name: str
    email_address: str
    internal_thing: str # Something only used internal to this module

def transform_to_profile(raw: _RawUserData) -> UserProfileDTO:
    return UserProfileDTO(
        id=uuid4(),  # Example transformation
        name=raw.full_name,
        email=raw.email_address,
    )

Typing Isn’t Optional Anymore

Type hints aren’t decoration—they’re your first line of defense.

Without typing, you’re leaving:

  • bugs uncatchable until runtime
  • refactors dangerous
  • your coworkers (and future you) clueless.

Use:

  • mypy or pyright for static checking (locally and in CI)
  • pydantic for runtime model validation.
  • typing.Protocol for interface contracts.

Bonus: with proper typing, your IDE becomes 10x more powerful.


Think in Workflows (and Sagas)

Non-trivial apps often follow complex flows:

  • onboarding
  • subscription upgrades
  • fulfillment processes
  • claim processing

Each of these is a saga - a sequence of steps that pass data forward.

Design each step as a self-contained, reusable module.
Keep inputs and outputs explicit and typed.
Use a coordinator to manage orchestration.

And again: don’t let each module define its own output data types - that’s a recipe for fragmentation.


Wrapping Up

Good Python architecture isn’t about purity - it’s about practical maintainability.
Following SOLID, enforcing typing, and designing around workflows is how you future-proof your codebase.

Otherwise?
You’re just writing tomorrow’s legacy system a little faster.